Finalize safe session rollover without nested lock deadlock

Hop-State: A_06FN5XPBA1JMJNPS2MAR0D0
Hop-Proposal: R_06FN5XN2CX7XBZ4KF1JZZ2G
Hop-Task: T_06FN3MBF98GWD4NA5PA1RWG
Hop-Attempt: AT_06FN5TMC0J7DXV2JGF15T20
This commit is contained in:
Hop
2026-07-11 13:55:33 -07:00
parent e887c8476d
commit 2f00b2dd3f
7 changed files with 499 additions and 14 deletions
+138
View File
@@ -415,6 +415,41 @@ func (s *Store) SetAgentSessionHead(ctx context.Context, agent, sessionID, state
return nil return nil
} }
// RetargetAgentSessions moves interactive sessions that currently point into
// one attempt to a successor prompt. Reconciliation uses this so a follow-up
// received while conflicts are being resolved reaches the reconciliation
// workspace instead of reopening the stale source workspace.
func (s *Store) RetargetAgentSessions(ctx context.Context, agent, fromAttemptID, expectedHeadID, toStateID string) error {
if strings.TrimSpace(agent) == "" || strings.TrimSpace(fromAttemptID) == "" ||
strings.TrimSpace(expectedHeadID) == "" || strings.TrimSpace(toStateID) == "" {
return errors.New("hop: agent, source attempt, expected head, and target state are required")
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin agent session retarget: %w", err)
}
defer tx.Rollback()
attempt, err := attemptTx(ctx, tx, fromAttemptID)
if err != nil {
return err
}
if attempt.HeadStateID != expectedHeadID {
return &HeadChangedError{Scope: "attempt", Expected: expectedHeadID, Actual: attempt.HeadStateID}
}
if _, err := tx.ExecContext(ctx,
`UPDATE agent_sessions
SET head_state_id = ?, updated_at = ?
WHERE agent = ?
AND head_state_id IN (SELECT id FROM states WHERE attempt_id = ?)`,
toStateID, formatTime(time.Now().UTC()), agent, fromAttemptID); err != nil {
return fmt.Errorf("retarget agent sessions: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit agent session retarget: %w", err)
}
return nil
}
// CreateTaskAttemptPrompt atomically creates all three records. The prompt is // CreateTaskAttemptPrompt atomically creates all three records. The prompt is
// durable and the attempt head points to it before the transaction becomes // durable and the attempt head points to it before the transaction becomes
// visible, so an orchestrator may safely deliver the prompt after this returns. // visible, so an orchestrator may safely deliver the prompt after this returns.
@@ -567,6 +602,8 @@ func (s *Store) CreateAttemptPrompt(
attempt Attempt, attempt Attempt,
prompt State, prompt State,
parents []Parent, parents []Parent,
sessionSourceAttemptID string,
expectedSourceHeadID string,
) (Attempt, State, error) { ) (Attempt, State, error) {
now := time.Now().UTC() now := time.Now().UTC()
if attempt.ID == "" { if attempt.ID == "" {
@@ -625,6 +662,20 @@ func (s *Store) CreateAttemptPrompt(
if err := tx.QueryRowContext(ctx, `SELECT 1 FROM tasks WHERE id = ?`, attempt.TaskID).Scan(&taskExists); err != nil { if err := tx.QueryRowContext(ctx, `SELECT 1 FROM tasks WHERE id = ?`, attempt.TaskID).Scan(&taskExists); err != nil {
return Attempt{}, State{}, dbNotFound("task", attempt.TaskID, err) return Attempt{}, State{}, dbNotFound("task", attempt.TaskID, err)
} }
if sessionSourceAttemptID != "" {
if expectedSourceHeadID == "" {
return Attempt{}, State{}, errors.New("hop: reconciliation source head is required")
}
sourceAttempt, err := attemptTx(ctx, tx, sessionSourceAttemptID)
if err != nil {
return Attempt{}, State{}, err
}
if sourceAttempt.HeadStateID != expectedSourceHeadID {
return Attempt{}, State{}, &HeadChangedError{
Scope: "attempt", Expected: expectedSourceHeadID, Actual: sourceAttempt.HeadStateID,
}
}
}
base, err := stateTx(ctx, tx, attempt.BaseStateID) base, err := stateTx(ctx, tx, attempt.BaseStateID)
if err != nil { if err != nil {
return Attempt{}, State{}, fmt.Errorf("read reconciliation base state: %w", err) return Attempt{}, State{}, fmt.Errorf("read reconciliation base state: %w", err)
@@ -659,6 +710,16 @@ func (s *Store) CreateAttemptPrompt(
`UPDATE attempts SET head_state_id = ? WHERE id = ?`, prompt.ID, attempt.ID); err != nil { `UPDATE attempts SET head_state_id = ? WHERE id = ?`, prompt.ID, attempt.ID); err != nil {
return Attempt{}, State{}, fmt.Errorf("install reconciliation attempt head: %w", err) return Attempt{}, State{}, fmt.Errorf("install reconciliation attempt head: %w", err)
} }
if sessionSourceAttemptID != "" {
if _, err := tx.ExecContext(ctx,
`UPDATE agent_sessions
SET head_state_id = ?, updated_at = ?
WHERE agent = ?
AND head_state_id IN (SELECT id FROM states WHERE attempt_id = ?)`,
prompt.ID, formatTime(now), attempt.Agent, sessionSourceAttemptID); err != nil {
return Attempt{}, State{}, fmt.Errorf("retarget reconciliation sessions: %w", err)
}
}
if _, err := tx.ExecContext(ctx, if _, err := tx.ExecContext(ctx,
`UPDATE tasks SET status = 'reconciling' WHERE id = ?`, attempt.TaskID); err != nil { `UPDATE tasks SET status = 'reconciling' WHERE id = ?`, attempt.TaskID); err != nil {
return Attempt{}, State{}, fmt.Errorf("mark task reconciling: %w", err) return Attempt{}, State{}, fmt.Errorf("mark task reconciling: %w", err)
@@ -839,6 +900,17 @@ func (s *Store) CASAccept(
if err != nil { if err != nil {
return State{}, fmt.Errorf("read proposal parent: %w", err) return State{}, fmt.Errorf("read proposal parent: %w", err)
} }
if proposal.AttemptID != "" {
attempt, err := attemptTx(ctx, tx, proposal.AttemptID)
if err != nil {
return State{}, err
}
if attempt.HeadStateID != proposal.ID {
return State{}, &HeadChangedError{
Scope: "attempt", Expected: proposal.ID, Actual: attempt.HeadStateID,
}
}
}
if accepted.TaskID == "" { if accepted.TaskID == "" {
accepted.TaskID = proposal.TaskID accepted.TaskID = proposal.TaskID
} }
@@ -886,6 +958,17 @@ func (s *Store) CASAccept(
} }
} }
if accepted.TaskID != "" { if accepted.TaskID != "" {
if accepted.AttemptID != "" {
if _, err := tx.ExecContext(ctx,
`UPDATE attempts
SET status = 'completed'
WHERE task_id = ?
AND id != ?
AND status NOT IN ('accepted', 'completed', 'failed', 'cancelled', 'rejected')`,
accepted.TaskID, accepted.AttemptID); err != nil {
return State{}, fmt.Errorf("complete superseded task attempts: %w", err)
}
}
if _, err := tx.ExecContext(ctx, if _, err := tx.ExecContext(ctx,
`UPDATE tasks SET status = 'accepted' WHERE id = ?`, accepted.TaskID); err != nil { `UPDATE tasks SET status = 'accepted' WHERE id = ?`, accepted.TaskID); err != nil {
return State{}, fmt.Errorf("mark task accepted: %w", err) return State{}, fmt.Errorf("mark task accepted: %w", err)
@@ -949,6 +1032,33 @@ func (s *Store) AcceptedForProposal(ctx context.Context, proposalID string) (Sta
return state, true, nil return state, true, nil
} }
// AcceptedForTask returns the latest immutable accepted outcome produced by a
// task. Unlike the mutable task status, this remains authoritative when an
// older client has accidentally reactivated an already accepted task.
func (s *Store) AcceptedForTask(ctx context.Context, taskID string) (State, bool, error) {
if strings.TrimSpace(taskID) == "" {
return State{}, false, errors.New("hop: task ID is required")
}
var id string
err := s.db.QueryRowContext(ctx,
`SELECT id
FROM states
WHERE task_id = ? AND kind = 'accepted'
ORDER BY created_at DESC, id DESC
LIMIT 1`, taskID).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
return State{}, false, nil
}
if err != nil {
return State{}, false, fmt.Errorf("find accepted state for task %s: %w", taskID, err)
}
state, err := s.GetState(ctx, id)
if err != nil {
return State{}, false, err
}
return state, true, nil
}
// ReconciliationPrompt returns the prompt already created to reconcile a // ReconciliationPrompt returns the prompt already created to reconcile a
// proposal against a particular accepted head. The pair is unique by // proposal against a particular accepted head. The pair is unique by
// convention and makes `hop refresh` safe to retry. // convention and makes `hop refresh` safe to retry.
@@ -1105,6 +1215,34 @@ func (s *Store) GetParents(ctx context.Context, stateID string) ([]Parent, error
return parents, nil return parents, nil
} }
// StateDescendsFrom reports whether descendantID is the same state as, or has
// any typed-parent path to, ancestorID. Session rollover uses state ancestry so
// a mutable task status cannot hide unfinished prompts created after an older
// accepted outcome.
func (s *Store) StateDescendsFrom(ctx context.Context, descendantID, ancestorID string) (bool, error) {
if strings.TrimSpace(descendantID) == "" || strings.TrimSpace(ancestorID) == "" {
return false, errors.New("hop: descendant and ancestor state IDs are required")
}
var found int
err := s.db.QueryRowContext(ctx,
`WITH RECURSIVE ancestry(id) AS (
SELECT ?
UNION
SELECT parents.parent_state_id
FROM state_parents parents
JOIN ancestry ON parents.state_id = ancestry.id
)
SELECT 1 FROM ancestry WHERE id = ? LIMIT 1`,
descendantID, ancestorID).Scan(&found)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("inspect state ancestry: %w", err)
}
return found == 1, nil
}
func (s *Store) ParentByRole(ctx context.Context, stateID, role string) (Parent, error) { func (s *Store) ParentByRole(ctx context.Context, stateID, role string) (Parent, error) {
var parent Parent var parent Parent
err := s.db.QueryRowContext(ctx, err := s.db.QueryRowContext(ctx,
+87 -1
View File
@@ -194,6 +194,9 @@ func (s *Service) BeginPrompt(ctx context.Context, message, fromStateID, agent,
if redactedSession, findings := RedactPromptSecrets(sessionID); len(findings) > 0 || redactedSession != sessionID { if redactedSession, findings := RedactPromptSecrets(sessionID); len(findings) > 0 || redactedSession != sessionID {
return PromptResult{}, errors.New("hop: refusing to use a potential credential as an agent session ID") return PromptResult{}, errors.New("hop: refusing to use a potential credential as an agent session ID")
} }
// Serialize interactive prompt capture with other begins. Acceptance checks
// the proposal's attempt head again inside its SQLite transaction, so a begin
// racing a land makes that proposal stale without sharing accept.lock.
release, err := acquireProjectLock(ctx, s.Root, "prompt") release, err := acquireProjectLock(ctx, s.Root, "prompt")
if err != nil { if err != nil {
return PromptResult{}, err return PromptResult{}, err
@@ -204,9 +207,15 @@ func (s *Service) BeginPrompt(ctx context.Context, message, fromStateID, agent,
if stateID, exists, err := s.Store.AgentSessionHead(ctx, agent, sessionID); err != nil { if stateID, exists, err := s.Store.AgentSessionHead(ctx, agent, sessionID); err != nil {
return PromptResult{}, err return PromptResult{}, err
} else if exists { } else if exists {
continuable, err := s.sessionStateContinuable(ctx, stateID)
if err != nil {
return PromptResult{}, err
}
if continuable {
fromStateID = stateID fromStateID = stateID
} }
} }
}
result, err := s.CreatePrompt(ctx, message, fromStateID, agent) result, err := s.CreatePrompt(ctx, message, fromStateID, agent)
if err != nil { if err != nil {
return PromptResult{}, err return PromptResult{}, err
@@ -219,6 +228,68 @@ func (s *Service) BeginPrompt(ctx context.Context, message, fromStateID, agent,
return result, nil return result, nil
} }
// sessionStateContinuable decides whether an implicit interactive-session
// pointer still represents unfinished work. An immutable accepted state for
// the task ends ordinary session continuation even if an older Hop build later
// changed the task's mutable status back to active. A dirty post-proposal
// workspace is preserved by continuing it instead of silently abandoning work.
func (s *Service) sessionStateContinuable(ctx context.Context, stateID string) (bool, error) {
state, err := s.Store.GetState(ctx, stateID)
if err != nil {
return false, err
}
if state.TaskID == "" || state.AttemptID == "" {
return false, fmt.Errorf("session state %s does not belong to a task attempt", state.ID)
}
accepted, exists, err := s.Store.AcceptedForTask(ctx, state.TaskID)
if err != nil {
return false, err
}
if !exists {
return true, nil
}
attempt, err := s.Store.GetAttempt(ctx, state.AttemptID)
if err != nil {
return false, err
}
head, err := s.Store.GetState(ctx, attempt.HeadStateID)
if err != nil {
return false, err
}
covered, err := s.Store.StateDescendsFrom(ctx, accepted.ID, head.ID)
if err != nil {
return false, err
}
if !covered {
return true, nil
}
workspaceRepo, err := OpenRepository(attempt.Workspace)
if err != nil {
return false, err
}
workspaceTree, err := workspaceRepo.WorktreeTree(ctx, head.SourceTree)
if err != nil {
return false, err
}
if workspaceTree != head.SourceTree {
return true, nil
}
status := "completed"
if attempt.ID == accepted.AttemptID {
status = "accepted"
}
if attempt.Status != status {
if err := s.Store.UpdateAttemptStatus(ctx, attempt.ID, status); err != nil {
return false, err
}
}
if err := s.Store.UpdateTaskStatus(ctx, state.TaskID, "accepted"); err != nil {
return false, err
}
return false, nil
}
func (s *Service) createInitialPrompt(ctx context.Context, message, agent string) (PromptResult, error) { func (s *Service) createInitialPrompt(ctx context.Context, message, agent string) (PromptResult, error) {
accepted, err := s.Store.AcceptedHead(ctx) accepted, err := s.Store.AcceptedHead(ctx)
if err != nil { if err != nil {
@@ -603,6 +674,11 @@ func (s *Service) accept(ctx context.Context, proposalID string, checkCommand []
if err != nil { if err != nil {
return AcceptResult{}, err return AcceptResult{}, err
} }
if attempt.HeadStateID != proposal.ID {
return AcceptResult{}, &HeadChangedError{
Scope: "attempt", Expected: proposal.ID, Actual: attempt.HeadStateID,
}
}
baseStateID := proposal.CanonicalAnchorID baseStateID := proposal.CanonicalAnchorID
if baseStateID == "" { if baseStateID == "" {
baseStateID = attempt.BaseStateID baseStateID = attempt.BaseStateID
@@ -804,6 +880,11 @@ func (s *Service) Refresh(ctx context.Context, proposalID string) (RefreshResult
if err != nil { if err != nil {
return RefreshResult{}, err return RefreshResult{}, err
} }
if sourceAttempt.HeadStateID != proposal.ID {
return RefreshResult{}, &HeadChangedError{
Scope: "attempt", Expected: proposal.ID, Actual: sourceAttempt.HeadStateID,
}
}
task, err := s.Store.GetTask(ctx, proposal.TaskID) task, err := s.Store.GetTask(ctx, proposal.TaskID)
if err != nil { if err != nil {
return RefreshResult{}, err return RefreshResult{}, err
@@ -815,6 +896,9 @@ func (s *Service) Refresh(ctx context.Context, proposalID string) (RefreshResult
if existing, exists, err := s.Store.ReconciliationPrompt(ctx, proposal.ID, current.ID); err != nil { if existing, exists, err := s.Store.ReconciliationPrompt(ctx, proposal.ID, current.ID); err != nil {
return RefreshResult{}, err return RefreshResult{}, err
} else if exists { } else if exists {
if err := s.Store.RetargetAgentSessions(ctx, sourceAttempt.Agent, sourceAttempt.ID, proposal.ID, existing.ID); err != nil {
return RefreshResult{}, err
}
return s.reconciliationResult(ctx, existing, task, proposal, current, true) return s.reconciliationResult(ctx, existing, task, proposal, current, true)
} }
baseStateID := proposal.CanonicalAnchorID baseStateID := proposal.CanonicalAnchorID
@@ -901,7 +985,9 @@ func (s *Service) Refresh(ctx context.Context, proposalID string) (RefreshResult
_ = s.Repo.RemoveWorktree(context.Background(), workspace, true) _ = s.Repo.RemoveWorktree(context.Background(), workspace, true)
} }
}() }()
reconciliationAttempt, prompt, err = s.Store.CreateAttemptPrompt(ctx, reconciliationAttempt, prompt, parents) reconciliationAttempt, prompt, err = s.Store.CreateAttemptPrompt(
ctx, reconciliationAttempt, prompt, parents, sourceAttempt.ID, proposal.ID,
)
if err != nil { if err != nil {
return RefreshResult{}, err return RefreshResult{}, err
} }
+250 -1
View File
@@ -242,7 +242,7 @@ func TestOpenProjectWaitsForInitializationLock(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
case <-time.After(5 * time.Second): case <-time.After(10 * time.Second):
t.Fatal("OpenProject did not resume after initialization lock release") t.Fatal("OpenProject did not resume after initialization lock release")
} }
} }
@@ -364,6 +364,255 @@ func TestCLIBeginAutoInitializesAndContinuesCodexSession(t *testing.T) {
}) })
} }
func TestBeginRollsAcceptedReconciliationSessionToCurrentHead(t *testing.T) {
ctx := context.Background()
service, _ := newTestProject(t, map[string]string{"shared.txt": "color=base\n"})
session, err := service.BeginPrompt(ctx, "Use blue", "", "codex", "thread-rollover")
if err != nil {
t.Fatal(err)
}
concurrent, err := service.CreatePrompt(ctx, "Use red", "", "other")
if err != nil {
t.Fatal(err)
}
writeTestFile(t, filepath.Join(session.Workspace, "shared.txt"), "color=blue\n")
writeTestFile(t, filepath.Join(concurrent.Workspace, "shared.txt"), "color=red\n")
sessionProposal, err := service.Propose(ctx, session.Prompt.ID, "Blue")
if err != nil {
t.Fatal(err)
}
concurrentProposal, err := service.Propose(ctx, concurrent.Prompt.ID, "Red")
if err != nil {
t.Fatal(err)
}
if _, err := service.Land(ctx, concurrentProposal.Proposal.ID, nil); err != nil {
t.Fatal(err)
}
if _, err := service.Land(ctx, sessionProposal.Proposal.ID, nil); err == nil {
t.Fatal("stale session proposal unexpectedly landed without reconciliation")
} else {
var conflict *ConflictError
if !errors.As(err, &conflict) {
t.Fatalf("land error = %v, want ConflictError", err)
}
}
reconciliation, err := service.Refresh(ctx, sessionProposal.Proposal.ID)
if err != nil {
t.Fatal(err)
}
reconciliationHead, exists, err := service.Store.AgentSessionHead(ctx, "codex", "thread-rollover")
if err != nil {
t.Fatal(err)
}
if !exists || reconciliationHead != reconciliation.Prompt.ID {
t.Fatalf("session head = %q, %v; want reconciliation prompt %s", reconciliationHead, exists, reconciliation.Prompt.ID)
}
writeTestFile(t, filepath.Join(reconciliation.Workspace, "shared.txt"), "color=red-blue\n")
if _, err := service.RunCheck(ctx, reconciliation.Prompt.ID, []string{
"sh", "-c", `test "$(cat shared.txt)" = "color=red-blue"`,
}); err != nil {
t.Fatal(err)
}
resolvedProposal, err := service.Propose(ctx, reconciliation.Prompt.ID, "Resolve red and blue")
if err != nil {
t.Fatal(err)
}
if _, err := service.Land(ctx, resolvedProposal.Proposal.ID, nil); err != nil {
t.Fatal(err)
}
sourceAttempt, err := service.Store.GetAttempt(ctx, session.Attempt.ID)
if err != nil {
t.Fatal(err)
}
if sourceAttempt.Status != "completed" {
t.Fatalf("source attempt status = %q, want completed", sourceAttempt.Status)
}
// Rollover must use the latest global accepted state, not merely this task's
// accepted outcome.
later, err := service.CreatePrompt(ctx, "Add a later accepted change", "", "other")
if err != nil {
t.Fatal(err)
}
writeTestFile(t, filepath.Join(later.Workspace, "later.txt"), "later\n")
laterProposal, err := service.Propose(ctx, later.Prompt.ID, "Later accepted change")
if err != nil {
t.Fatal(err)
}
laterAccepted, err := service.Land(ctx, laterProposal.Proposal.ID, nil)
if err != nil {
t.Fatal(err)
}
next, err := service.BeginPrompt(ctx, "Next request", "", "codex", "thread-rollover")
if err != nil {
t.Fatal(err)
}
assertFreshRollover := func(label string, result PromptResult) {
t.Helper()
if result.Checkpoint != nil {
t.Fatalf("%s continued accepted session with checkpoint %s", label, result.Checkpoint.ID)
}
if result.Attempt.ID == session.Attempt.ID || result.Attempt.ID == reconciliation.Attempt.ID {
t.Fatalf("%s reused completed attempt %s", label, result.Attempt.ID)
}
if result.Task.ID == session.Task.ID {
t.Fatalf("%s reopened accepted task %s", label, result.Task.ID)
}
if result.Attempt.BaseStateID != laterAccepted.State.ID {
t.Fatalf("%s base = %s, want %s", label, result.Attempt.BaseStateID, laterAccepted.State.ID)
}
if result.Prompt.CanonicalAnchorID != laterAccepted.State.ID ||
result.Prompt.SourceTree != laterAccepted.State.SourceTree ||
result.Prompt.GitCommit != laterAccepted.State.GitCommit {
t.Fatalf("%s prompt was not rooted at latest accepted head: %#v; accepted=%#v", label, result.Prompt, laterAccepted.State)
}
if contents, err := os.ReadFile(filepath.Join(result.Workspace, "shared.txt")); err != nil || string(contents) != "color=red-blue\n" {
t.Fatalf("%s shared workspace = %q, err=%v", label, string(contents), err)
}
if contents, err := os.ReadFile(filepath.Join(result.Workspace, "later.txt")); err != nil || string(contents) != "later\n" {
t.Fatalf("%s later workspace = %q, err=%v", label, string(contents), err)
}
}
assertFreshRollover("normal reconciliation session", next)
head, exists, err := service.Store.AgentSessionHead(ctx, "codex", "thread-rollover")
if err != nil {
t.Fatal(err)
}
if !exists || head != next.Prompt.ID {
t.Fatalf("session head = %q, %v; want %s", head, exists, next.Prompt.ID)
}
// Older Hop builds could reactivate both records after acceptance. The
// immutable accepted outcome, not these mutable statuses, must drive rollover.
if err := service.Store.UpdateAttemptStatus(ctx, session.Attempt.ID, "active"); err != nil {
t.Fatal(err)
}
if err := service.Store.UpdateTaskStatus(ctx, session.Task.ID, "active"); err != nil {
t.Fatal(err)
}
if err := service.Store.SetAgentSessionHead(ctx, "codex", "legacy-thread-rollover", session.Prompt.ID); err != nil {
t.Fatal(err)
}
legacyNext, err := service.BeginPrompt(ctx, "Legacy next request", "", "codex", "legacy-thread-rollover")
if err != nil {
t.Fatal(err)
}
assertFreshRollover("legacy session", legacyNext)
sourceAttempt, err = service.Store.GetAttempt(ctx, session.Attempt.ID)
if err != nil {
t.Fatal(err)
}
if sourceAttempt.Status == "active" {
t.Fatalf("source attempt %s was left active", sourceAttempt.ID)
}
head, exists, err = service.Store.AgentSessionHead(ctx, "codex", "legacy-thread-rollover")
if err != nil {
t.Fatal(err)
}
if !exists || head != legacyNext.Prompt.ID {
t.Fatalf("legacy session head = %q, %v; want %s", head, exists, legacyNext.Prompt.ID)
}
}
func TestLandRejectsProposalSupersededByFollowup(t *testing.T) {
ctx := context.Background()
service, initial := newTestProject(t, map[string]string{"base.txt": "base\n"})
started, err := service.BeginPrompt(ctx, "Create a change", "", "codex", "thread-stale-proposal")
if err != nil {
t.Fatal(err)
}
writeTestFile(t, filepath.Join(started.Workspace, "change.txt"), "first\n")
proposal, err := service.Propose(ctx, started.Prompt.ID, "First change")
if err != nil {
t.Fatal(err)
}
followup, err := service.BeginPrompt(ctx, "Refine that change", "", "codex", "thread-stale-proposal")
if err != nil {
t.Fatal(err)
}
if followup.Attempt.ID != started.Attempt.ID || followup.Checkpoint == nil {
t.Fatalf("follow-up did not advance the original attempt: %#v", followup)
}
if _, err := service.Land(ctx, proposal.Proposal.ID, nil); !errors.Is(err, ErrAttemptHeadChanged) {
t.Fatalf("superseded proposal land error = %v, want ErrAttemptHeadChanged", err)
}
parents := canonicalizeParents([]Parent{
{StateID: initial.ID, Role: "canonical_parent", Order: 0},
{StateID: proposal.Proposal.ID, Role: "proposal_parent", Order: 1},
})
accepted := State{
ID: newID("a"),
Kind: StateAccepted,
TaskID: proposal.Proposal.TaskID,
AttemptID: proposal.Proposal.AttemptID,
CanonicalAnchorID: initial.ID,
SourceTree: proposal.Proposal.SourceTree,
GitCommit: proposal.Proposal.GitCommit,
Summary: proposal.Proposal.Summary,
Agent: proposal.Proposal.Agent,
CreatedAt: time.Now().UTC(),
Parents: parents,
}
accepted.Digest, err = digestState(accepted, parents)
if err != nil {
t.Fatal(err)
}
if _, err := service.Store.CASAccept(ctx, initial.ID, accepted, parents); !errors.Is(err, ErrAttemptHeadChanged) {
t.Fatalf("transactional stale proposal error = %v, want ErrAttemptHeadChanged", err)
}
head, err := service.Store.AcceptedHead(ctx)
if err != nil {
t.Fatal(err)
}
if head.ID != initial.ID {
t.Fatalf("superseded proposal advanced accepted head to %s", head.ID)
}
}
func TestBeginKeepsUnfinishedStateCreatedAfterAcceptedOutcome(t *testing.T) {
ctx := context.Background()
service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"})
started, err := service.BeginPrompt(ctx, "Land the first change", "", "codex", "thread-post-accept")
if err != nil {
t.Fatal(err)
}
writeTestFile(t, filepath.Join(started.Workspace, "landed.txt"), "landed\n")
proposal, err := service.Propose(ctx, started.Prompt.ID, "Landed change")
if err != nil {
t.Fatal(err)
}
if _, err := service.Land(ctx, proposal.Proposal.ID, nil); err != nil {
t.Fatal(err)
}
// Although agents must not mutate a frozen proposal, preserve any such
// residual work rather than silently rolling it away.
writeTestFile(t, filepath.Join(started.Workspace, "pending.txt"), "pending\n")
continued, err := service.BeginPrompt(ctx, "Preserve the pending work", "", "codex", "thread-post-accept")
if err != nil {
t.Fatal(err)
}
if continued.Attempt.ID != started.Attempt.ID || continued.Checkpoint == nil {
t.Fatalf("dirty accepted workspace was not preserved: %#v", continued)
}
assertTreeFiles(t, service, continued.Checkpoint.GitCommit, map[string]string{
"base.txt": "base\n",
"landed.txt": "landed\n",
"pending.txt": "pending\n",
})
again, err := service.BeginPrompt(ctx, "Continue that pending work", "", "codex", "thread-post-accept")
if err != nil {
t.Fatal(err)
}
if again.Attempt.ID != started.Attempt.ID || again.Checkpoint == nil {
t.Fatalf("post-acceptance prompt lineage was abandoned: %#v", again)
}
}
func TestDoctorRejectsCommitTreeAndDigestMismatch(t *testing.T) { func TestDoctorRejectsCommitTreeAndDigestMismatch(t *testing.T) {
ctx := context.Background() ctx := context.Background()
service, initial := newTestProject(t, map[string]string{"base.txt": "base\n"}) service, initial := newTestProject(t, map[string]string{"base.txt": "base\n"})
+7 -4
View File
@@ -29,9 +29,11 @@ only the shell-added final newline. Never copy the credential anywhere else.
`hop begin` performs the Desktop bootstrap: `hop begin` performs the Desktop bootstrap:
- Initialize Hop automatically when the project has not used it before. - Initialize Hop automatically when the project has not used it before.
- Use `CODEX_THREAD_ID` to bind this Codex task to one Hop attempt. - Use `CODEX_THREAD_ID` to bind this Codex task to its unfinished Hop work.
- Create a prompt state and isolated workspace on the first turn. - Create a prompt state and isolated workspace on the first turn.
- Checkpoint prior workspace effects and append a prompt state on follow-ups. - Checkpoint prior workspace effects and append follow-ups until that work lands.
- Follow a reconciliation into its fresh attempt, then start the first prompt
after landing from the latest accepted state instead of reopening old work.
- Redact detected API keys, tokens, passwords, private keys, authorization - Redact detected API keys, tokens, passwords, private keys, authorization
headers, and credential-bearing connection strings before persistence. headers, and credential-bearing connection strings before persistence.
@@ -114,8 +116,9 @@ For a read-only or informational turn, the prompt state is sufficient; do not
invent a proposal when the workspace tree is unchanged. invent a proposal when the workspace tree is unchanged.
Do not edit a frozen proposal. A user follow-up triggers this skill again; Do not edit a frozen proposal. A user follow-up triggers this skill again;
run `hop begin` again before acting. Session binding selects the existing run `hop begin` again before acting. Session binding selects unfinished work
attempt automatically, so the user never needs to carry state IDs. automatically and rolls completed work onto the latest accepted state, so the
user never needs to carry state IDs.
## Auto-accept by default ## Auto-accept by default
+7 -3
View File
@@ -37,7 +37,9 @@ Prompt, checkpoint, and proposal states may reference identical Git trees while
Interactive agents may begin without these variables. `hop begin` returns the Interactive agents may begin without these variables. `hop begin` returns the
equivalent IDs and workspace, while `CODEX_THREAD_ID` binds later messages in equivalent IDs and workspace, while `CODEX_THREAD_ID` binds later messages in
the same Codex task to the existing attempt. the same Codex task to unfinished work. Follow-ups before acceptance continue
the attempt; the first prompt after acceptance starts a fresh task and attempt
at the latest accepted state.
## Command contract ## Command contract
@@ -111,8 +113,10 @@ accepted state, including projects created with older Hop builds.
`hop begin` is the Codex Desktop entry point. It initializes Hop when necessary, `hop begin` is the Codex Desktop entry point. It initializes Hop when necessary,
captures the current message before the agent performs project work, and uses captures the current message before the agent performs project work, and uses
`CODEX_THREAD_ID` as the default session key. A later `hop begin` in the same `CODEX_THREAD_ID` as the default session key. A later `hop begin` in the same
Codex task checkpoints the prior workspace before appending the follow-up Codex task checkpoints the prior workspace before appending a follow-up while
prompt state. that work remains unfinished. Reconciliation transfers the session to its fresh
attempt. After a proposal is accepted, the next `hop begin` starts from the
latest accepted state and never reopens the completed workspace.
Pass the original message to `hop begin` without model-side redaction. Hop's Pass the original message to `hop begin` without model-side redaction. Hop's
sanitizer replaces detected credential values before any durable write and sanitizer replaces detected credential values before any durable write and
+5 -2
View File
@@ -35,8 +35,11 @@ capture boundary.
## Follow-up messages ## Follow-up messages
A later `hop begin` with the same Codex task session checkpoints existing A later `hop begin` with the same Codex task session checkpoints existing
workspace effects, appends a new prompt state, and continues the same attempt. workspace effects, appends a new prompt state, and continues the same attempt
The user does not carry state IDs between messages. while that work remains unfinished. If Hop prepares reconciliation, the session
follows its fresh workspace. After the result lands, the next prompt starts a
new task and attempt rooted at the latest accepted state. Completed workspaces
are never reopened, and the user does not carry state IDs between messages.
## Controller-grade capture ## Controller-grade capture
+4 -2
View File
@@ -21,8 +21,10 @@ different moments and causal roles.
## Task ## Task
A task groups the prompts and attempts pursuing one user outcome. Follow-up A task groups the prompts and attempts pursuing one user outcome. Follow-up
messages in the same Codex task stay connected automatically through messages pursuing unfinished work stay connected automatically through
`CODEX_THREAD_ID`. `CODEX_THREAD_ID`. Once that outcome is accepted, the next message starts a new
Hop task at the latest accepted state even when the Codex conversation stays
open.
## Attempt and workspace ## Attempt and workspace