package hop import ( "context" "errors" "fmt" "os" "path/filepath" "sort" "strings" "time" ) const acceptedRef = "accepted" type Service struct { Root string Store *Store Repo *Repository } func InitProject(ctx context.Context, path string) (*Service, State, error) { repo, err := EnsureRepository(path) if err != nil { return nil, State{}, err } root := repo.Root() trackedHopPaths, err := repo.TrackedPaths(ctx, ".hop") if err != nil { return nil, State{}, err } if len(trackedHopPaths) > 0 { return nil, State{}, fmt.Errorf("cannot initialize Hop: .hop is already tracked as project source (for example %s)", trackedHopPaths[0]) } hopDir := filepath.Join(root, ".hop") if err := os.MkdirAll(filepath.Join(hopDir, "workspaces"), 0o755); err != nil { return nil, State{}, fmt.Errorf("create Hop project directory: %w", err) } store, err := OpenStore(filepath.Join(hopDir, "hop.db")) if err != nil { return nil, State{}, err } service := &Service{Root: root, Store: store, Repo: repo} initialized, err := store.IsInitialized(ctx) if err != nil { service.Close() return nil, State{}, err } if initialized { head, err := store.AcceptedHead(ctx) if err != nil { service.Close() return nil, State{}, err } if err := service.reconcileDerivedRefs(ctx, true); err != nil { service.Close() return nil, State{}, err } return service, head, nil } commit, tree, err := repo.Snapshot(ctx, "hop: initial project state\n") if err != nil { service.Close() return nil, State{}, err } initial := State{ ID: newID("a"), Kind: StateAccepted, SourceTree: tree, GitCommit: commit, Summary: "Initial project state", Agent: "hop", CreatedAt: time.Now().UTC(), } initial.Digest, err = digestState(initial, nil) if err != nil { service.Close() return nil, State{}, err } if err := service.pinState(ctx, initial); err != nil { service.Close() return nil, State{}, err } initial, err = store.CreateInitialState(ctx, root, initial) if err != nil { service.Close() return nil, State{}, err } if err := repo.UpdateHiddenRef(ctx, acceptedRef, commit); err != nil { service.Close() return nil, State{}, err } return service, initial, nil } func OpenProject(start string) (*Service, error) { root, err := FindHopRoot(start) if err != nil { return nil, err } store, err := OpenStore(filepath.Join(root, ".hop", "hop.db")) if err != nil { return nil, err } repo, err := OpenRepository(root) if err != nil { store.Close() return nil, err } recordedRoot, err := store.RepositoryRoot(context.Background()) if err != nil { store.Close() return nil, err } recordedInfo, recordedErr := os.Stat(recordedRoot) rootInfo, rootErr := os.Stat(root) if recordedErr != nil || rootErr != nil { store.Close() if recordedErr != nil { return nil, fmt.Errorf("inspect recorded Hop root: %w", recordedErr) } return nil, fmt.Errorf("inspect discovered Hop root: %w", rootErr) } if !os.SameFile(recordedInfo, rootInfo) { store.Close() return nil, fmt.Errorf("Hop database belongs to %s, not %s", recordedRoot, root) } service := &Service{Root: root, Store: store, Repo: repo} reconcileAccepted := os.Getenv("HOP_ACCEPTANCE_LOCK_HELD") != "1" if err := service.reconcileDerivedRefs(context.Background(), reconcileAccepted); err != nil { service.Close() return nil, err } return service, nil } func (s *Service) Close() error { if s == nil || s.Store == nil { return nil } return s.Store.Close() } func (s *Service) CreatePrompt(ctx context.Context, message, fromStateID, agent string) (PromptResult, error) { if strings.TrimSpace(message) == "" { return PromptResult{}, fmt.Errorf("prompt text is required") } message, redactions := RedactPromptSecrets(message) agent, _ = RedactPromptSecrets(agent) var result PromptResult var err error if fromStateID == "" { result, err = s.createInitialPrompt(ctx, message, agent) } else { result, err = s.createFollowupPrompt(ctx, message, fromStateID, agent) } if err != nil { return PromptResult{}, err } result.Redactions = redactions return result, nil } // BeginPrompt is the interactive-agent entry point. It resolves follow-up // ancestry from a stable harness session ID, creates the prompt state before // project effects, and advances the session pointer for the next turn. func (s *Service) BeginPrompt(ctx context.Context, message, fromStateID, agent, sessionID string) (PromptResult, error) { if strings.TrimSpace(agent) == "" { agent = "agent" } if redactedAgent, findings := RedactPromptSecrets(agent); len(findings) > 0 || redactedAgent != agent { return PromptResult{}, errors.New("hop: refusing to use a potential credential as an agent name") } 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") } release, err := acquireProjectLock(ctx, s.Root, "prompt") if err != nil { return PromptResult{}, err } defer release() if fromStateID == "" && sessionID != "" { if stateID, exists, err := s.Store.AgentSessionHead(ctx, agent, sessionID); err != nil { return PromptResult{}, err } else if exists { fromStateID = stateID } } result, err := s.CreatePrompt(ctx, message, fromStateID, agent) if err != nil { return PromptResult{}, err } if sessionID != "" { if err := s.Store.SetAgentSessionHead(ctx, agent, sessionID, result.Prompt.ID); err != nil { return PromptResult{}, err } } return result, nil } func (s *Service) createInitialPrompt(ctx context.Context, message, agent string) (PromptResult, error) { accepted, err := s.Store.AcceptedHead(ctx) if err != nil { return PromptResult{}, err } now := time.Now().UTC() task := Task{ID: newID("t"), Title: promptTitle(message), Status: "active", CreatedAt: now} attemptID := newID("at") workspace := filepath.Join(s.Root, ".hop", "workspaces", attemptID) attempt := Attempt{ ID: attemptID, TaskID: task.ID, Agent: agent, Workspace: workspace, BaseStateID: accepted.ID, Status: "active", CreatedAt: now, } parents := canonicalizeParents([]Parent{ {StateID: accepted.ID, Role: "run_parent", Order: 0}, {StateID: accepted.ID, Role: "canonical_anchor", Order: 1}, }) prompt := State{ ID: newID("p"), Kind: StatePrompt, TaskID: task.ID, AttemptID: attempt.ID, CanonicalAnchorID: accepted.ID, SourceTree: accepted.SourceTree, GitCommit: accepted.GitCommit, Prompt: message, Agent: agent, CreatedAt: now, Parents: parents, } prompt.Digest, err = digestState(prompt, parents) if err != nil { return PromptResult{}, err } if err := s.pinState(ctx, prompt); err != nil { return PromptResult{}, err } task, attempt, prompt, err = s.Store.CreateTaskAttemptPrompt(ctx, task, attempt, prompt, parents) if err != nil { return PromptResult{}, err } if err := os.MkdirAll(filepath.Dir(workspace), 0o755); err != nil { _ = s.Store.UpdateAttemptStatus(ctx, attempt.ID, "failed") return PromptResult{}, err } if _, err := s.Repo.AddDetachedWorktree(ctx, workspace, accepted.GitCommit); err != nil { _ = s.Store.UpdateAttemptStatus(ctx, attempt.ID, "failed") return PromptResult{}, err } return PromptResult{ Prompt: prompt, Task: task, Attempt: attempt, Workspace: workspace, Deliver: s.deliveryEnvironment(prompt, attempt), }, nil } func (s *Service) createFollowupPrompt(ctx context.Context, message, fromStateID, agent string) (PromptResult, error) { from, err := s.Store.GetState(ctx, fromStateID) if err != nil { return PromptResult{}, err } if from.AttemptID == "" { return PromptResult{}, fmt.Errorf("state %s does not belong to an attempt", from.ID) } attempt, err := s.Store.GetAttempt(ctx, from.AttemptID) if err != nil { return PromptResult{}, err } task, err := s.Store.GetTask(ctx, attempt.TaskID) if err != nil { return PromptResult{}, err } checkpoint, err := s.checkpointAttempt(ctx, attempt) if err != nil { return PromptResult{}, err } accepted, err := s.Store.AcceptedHead(ctx) if err != nil { return PromptResult{}, err } if agent == "" { agent = attempt.Agent } parents := canonicalizeParents([]Parent{ {StateID: checkpoint.ID, Role: "run_parent", Order: 0}, {StateID: accepted.ID, Role: "canonical_anchor", Order: 1}, }) prompt := State{ ID: newID("p"), Kind: StatePrompt, TaskID: attempt.TaskID, AttemptID: attempt.ID, CanonicalAnchorID: accepted.ID, SourceTree: checkpoint.SourceTree, GitCommit: checkpoint.GitCommit, Prompt: message, Agent: agent, CreatedAt: time.Now().UTC(), Parents: parents, } prompt.Digest, err = digestState(prompt, parents) if err != nil { return PromptResult{}, err } if err := s.pinState(ctx, prompt); err != nil { return PromptResult{}, err } prompt, err = s.Store.AppendState(ctx, prompt, parents, checkpoint.ID) if err != nil { return PromptResult{}, mapHeadError(err) } _ = s.Store.UpdateAttemptStatus(ctx, attempt.ID, "active") _ = s.Store.UpdateTaskStatus(ctx, task.ID, "active") attempt, err = s.Store.GetAttempt(ctx, attempt.ID) if err != nil { return PromptResult{}, err } return PromptResult{ Prompt: prompt, Checkpoint: &checkpoint, Task: task, Attempt: attempt, Workspace: attempt.Workspace, Deliver: s.deliveryEnvironment(prompt, attempt), }, nil } func (s *Service) Checkpoint(ctx context.Context, stateID string) (State, error) { state, err := s.Store.GetState(ctx, stateID) if err != nil { return State{}, err } if state.AttemptID == "" { return State{}, fmt.Errorf("state %s does not belong to an attempt", state.ID) } attempt, err := s.Store.GetAttempt(ctx, state.AttemptID) if err != nil { return State{}, err } return s.checkpointAttempt(ctx, attempt) } func (s *Service) checkpointAttempt(ctx context.Context, attempt Attempt) (State, error) { workspaceRepo, err := OpenRepository(attempt.Workspace) if err != nil { return State{}, err } commit, tree, err := workspaceRepo.Snapshot(ctx, "hop: workspace checkpoint\n") if err != nil { return State{}, err } head, err := s.Store.GetState(ctx, attempt.HeadStateID) if err != nil { return State{}, err } parents := canonicalizeParents([]Parent{{StateID: head.ID, Role: "run_parent", Order: 0}}) checkpoint := State{ ID: newID("c"), Kind: StateCheckpoint, TaskID: attempt.TaskID, AttemptID: attempt.ID, CanonicalAnchorID: head.CanonicalAnchorID, SourceTree: tree, GitCommit: commit, Agent: attempt.Agent, CreatedAt: time.Now().UTC(), Parents: parents, } checkpoint.Digest, err = digestState(checkpoint, parents) if err != nil { return State{}, err } if err := s.pinState(ctx, checkpoint); err != nil { return State{}, err } checkpoint, err = s.Store.AppendState(ctx, checkpoint, parents, head.ID) if err != nil { return State{}, mapHeadError(err) } return checkpoint, nil } func (s *Service) RunCheck(ctx context.Context, stateID string, argv []string) (Check, error) { checkpoint, err := s.Checkpoint(ctx, stateID) if err != nil { return Check{}, err } attempt, err := s.Store.GetAttempt(ctx, checkpoint.AttemptID) if err != nil { return Check{}, err } checkID := newID("evidence") checkPath := filepath.Join(s.Root, ".hop", "checks", checkID) if err := os.MkdirAll(filepath.Dir(checkPath), 0o755); err != nil { return Check{}, err } if _, err := s.Repo.AddDetachedWorktree(ctx, checkPath, checkpoint.GitCommit); err != nil { return Check{}, err } env := []string{ "HOP_ROOT=" + s.Root, "HOP_STATE_ID=" + checkpoint.ID, "HOP_TASK_ID=" + checkpoint.TaskID, "HOP_ATTEMPT_ID=" + attempt.ID, "HOP_WORKSPACE=" + checkPath, } result, runErr := runWorkspaceCommand(ctx, checkPath, env, argv) removeErr := s.Repo.RemoveWorktree(ctx, checkPath, true) if runErr != nil { return Check{}, runErr } if removeErr != nil { return Check{}, removeErr } storedCommand, _ := redactSecretStrings(argv) storedOutput, _ := RedactPromptSecrets(result.Output) check := Check{ ID: checkID, AttemptID: attempt.ID, StateID: checkpoint.ID, TreeHash: checkpoint.SourceTree, Command: storedCommand, ExitCode: result.ExitCode, Output: storedOutput, CreatedAt: time.Now().UTC(), } check, err = s.Store.AddCheck(ctx, check) if err != nil { return Check{}, err } if check.ExitCode != 0 { return check, &CheckFailedError{Check: check} } return check, nil } func (s *Service) Propose(ctx context.Context, stateID, summary string) (ProposalResult, error) { state, err := s.Store.GetState(ctx, stateID) if err != nil { return ProposalResult{}, err } if state.AttemptID == "" { return ProposalResult{}, fmt.Errorf("state %s does not belong to an attempt", state.ID) } attempt, err := s.Store.GetAttempt(ctx, state.AttemptID) if err != nil { return ProposalResult{}, err } task, err := s.Store.GetTask(ctx, attempt.TaskID) if err != nil { return ProposalResult{}, err } workspaceRepo, err := OpenRepository(attempt.Workspace) if err != nil { return ProposalResult{}, err } commit, tree, err := workspaceRepo.Snapshot(ctx, "hop: proposal\n") if err != nil { return ProposalResult{}, err } if strings.TrimSpace(summary) == "" { summary = task.Title } summary, _ = RedactPromptSecrets(summary) parents := canonicalizeParents([]Parent{{StateID: attempt.HeadStateID, Role: "run_parent", Order: 0}}) proposal := State{ ID: newID("r"), Kind: StateProposal, TaskID: attempt.TaskID, AttemptID: attempt.ID, CanonicalAnchorID: attempt.BaseStateID, SourceTree: tree, GitCommit: commit, Summary: summary, Agent: attempt.Agent, CreatedAt: time.Now().UTC(), Parents: parents, } proposal.Digest, err = digestState(proposal, parents) if err != nil { return ProposalResult{}, err } if err := s.pinState(ctx, proposal); err != nil { return ProposalResult{}, err } proposal, err = s.Store.AppendState(ctx, proposal, parents, attempt.HeadStateID) if err != nil { return ProposalResult{}, mapHeadError(err) } _ = s.Store.UpdateAttemptStatus(ctx, attempt.ID, "proposed") _ = s.Store.UpdateTaskStatus(ctx, task.ID, "proposed") checks, err := s.Store.ListChecks(ctx, attempt.ID, tree) if err != nil { return ProposalResult{}, err } return ProposalResult{Proposal: proposal, Checks: checks}, nil } // Accept advances Hop's internal accepted lineage without changing the visible // project root. Controllers use this lower-level operation deliberately. func (s *Service) Accept(ctx context.Context, proposalID string, checkCommand []string) (AcceptResult, error) { return s.accept(ctx, proposalID, checkCommand, false) } // Land accepts a proposal and projects the resulting accepted tree into the // visible project root while preserving the user's HEAD, branch, and index. func (s *Service) Land(ctx context.Context, proposalID string, checkCommand []string) (AcceptResult, error) { return s.accept(ctx, proposalID, checkCommand, true) } func (s *Service) accept(ctx context.Context, proposalID string, checkCommand []string, materialize bool) (AcceptResult, error) { release, err := acquireProjectLock(ctx, s.Root, "accept") if err != nil { return AcceptResult{}, err } defer release() proposal, err := s.Store.GetState(ctx, proposalID) if err != nil { return AcceptResult{}, err } if proposal.Kind != StateProposal { return AcceptResult{}, fmt.Errorf("state %s is %s, not a proposal", proposal.ID, proposal.Kind) } if existing, exists, err := s.Store.AcceptedForProposal(ctx, proposal.ID); err != nil { return AcceptResult{}, err } else if exists { result := AcceptResult{State: existing} if materialize { if _, err := s.syncLocked(ctx); err != nil { return result, &CommittedStateError{State: existing, Err: fmt.Errorf("repair visible root after prior acceptance: %w", err)} } result.MaterializedRoot = s.Root } return result, nil } attempt, err := s.Store.GetAttempt(ctx, proposal.AttemptID) if err != nil { return AcceptResult{}, err } base, err := s.Store.GetState(ctx, attempt.BaseStateID) if err != nil { return AcceptResult{}, err } current, err := s.Store.AcceptedHead(ctx) if err != nil { return AcceptResult{}, err } proposalPaths, err := s.Repo.ChangedPaths(ctx, base.GitCommit, proposal.GitCommit) if err != nil { return AcceptResult{}, err } currentPaths, err := s.Repo.ChangedPaths(ctx, base.GitCommit, current.GitCommit) if err != nil { return AcceptResult{}, err } overlap := intersectPaths(proposalPaths, currentPaths) if len(overlap) > 0 { return AcceptResult{}, &ConflictError{Paths: overlap} } finalTree := proposal.SourceTree if current.SourceTree != base.SourceTree { var mergeConflicts []string finalTree, mergeConflicts, err = s.Repo.ComposeTrees(ctx, base.GitCommit, current.GitCommit, proposal.GitCommit) if err != nil { return AcceptResult{}, err } if len(mergeConflicts) > 0 { return AcceptResult{}, &ConflictError{Paths: mergeConflicts} } } acceptedID := newID("a") message := proposal.Summary if message == "" { 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) if err != nil { return AcceptResult{}, err } var recordedCheck *Check validationRef := "" defer func() { if validationRef != "" { _ = s.Repo.DeleteRef(ctx, "refs/hop/"+validationRef, commit) } }() if len(checkCommand) > 0 { checkID := newID("evidence") validationRef = "validation/" + checkID if err := s.Repo.UpdateHiddenRef(ctx, validationRef, commit); err != nil { return AcceptResult{}, fmt.Errorf("pin final validation tree: %w", err) } integrationPath := filepath.Join(s.Root, ".hop", "integration", acceptedID) if err := os.MkdirAll(filepath.Dir(integrationPath), 0o755); err != nil { return AcceptResult{}, err } if _, err := s.Repo.AddDetachedWorktree(ctx, integrationPath, commit); err != nil { return AcceptResult{}, err } result, runErr := runWorkspaceCommand(ctx, integrationPath, []string{ "HOP_ROOT=" + s.Root, "HOP_STATE_ID=" + acceptedID, "HOP_TASK_ID=" + proposal.TaskID, "HOP_ATTEMPT_ID=" + proposal.AttemptID, "HOP_ACCEPTANCE_LOCK_HELD=1", }, checkCommand) removeErr := s.Repo.RemoveWorktree(ctx, integrationPath, true) if runErr != nil { return AcceptResult{}, runErr } if removeErr != nil { return AcceptResult{}, removeErr } storedCommand, _ := redactSecretStrings(checkCommand) storedOutput, _ := RedactPromptSecrets(result.Output) check := Check{ ID: checkID, AttemptID: proposal.AttemptID, TreeHash: finalTree, Command: storedCommand, ExitCode: result.ExitCode, Output: storedOutput, CreatedAt: time.Now().UTC(), } if check.ExitCode != 0 { if failed, recorded := s.recordValidationFailure(ctx, proposal, current, commit, finalTree, checkCommand); recorded { check.StateID = failed.ID _ = s.Repo.DeleteRef(ctx, "refs/hop/"+validationRef, commit) validationRef = "" } } check, err = s.Store.AddCheck(ctx, check) if err != nil { return AcceptResult{}, err } recordedCheck = &check if check.ExitCode != 0 { return AcceptResult{}, &CheckFailedError{Check: check} } } var rootBase State if materialize { rootBase, err = s.prepareRootMaterialization(ctx, finalTree) if err != nil { return AcceptResult{}, err } } parents := canonicalizeParents([]Parent{ {StateID: current.ID, Role: "canonical_parent", Order: 0}, {StateID: proposal.ID, Role: "proposal_parent", Order: 1}, }) accepted := State{ ID: acceptedID, Kind: StateAccepted, TaskID: proposal.TaskID, AttemptID: proposal.AttemptID, CanonicalAnchorID: current.ID, SourceTree: finalTree, GitCommit: commit, Summary: proposal.Summary, Agent: proposal.Agent, CreatedAt: time.Now().UTC(), Parents: parents, } accepted.Digest, err = digestState(accepted, parents) if err != nil { return AcceptResult{}, err } if err := s.pinState(ctx, accepted); err != nil { return AcceptResult{}, err } accepted, err = s.Store.CASAccept(ctx, current.ID, accepted, parents) if err != nil { return AcceptResult{}, mapHeadError(err) } if validationRef != "" { _ = s.Repo.DeleteRef(ctx, "refs/hop/"+validationRef, commit) validationRef = "" } var warnings []string if err := s.Repo.UpdateHiddenRef(ctx, acceptedRef, commit); err != nil { warnings = append(warnings, "accepted state is durable in SQLite but refs/hop/accepted needs repair: "+err.Error()) } result := AcceptResult{ State: accepted, ProposalPaths: proposalPaths, CurrentPaths: currentPaths, Check: recordedCheck, Warnings: warnings, } if materialize { if err := s.Repo.MaterializeTree(ctx, rootBase.SourceTree, accepted.SourceTree); err != nil { return result, &CommittedStateError{State: accepted, Err: fmt.Errorf("visible root %s was not synchronized: %w", s.Root, err)} } if err := s.Store.CASMaterializedHead(ctx, rootBase.ID, accepted.ID); err != nil { return result, &CommittedStateError{State: accepted, Err: fmt.Errorf("record visible root synchronization: %w", err)} } result.MaterializedRoot = s.Root } return result, nil } // Sync projects the current accepted tree into a visible root that still // matches its durable materialized head. It is useful after controller-only accepts or // when upgrading a project created before automatic materialization existed. func (s *Service) Sync(ctx context.Context) (SyncResult, error) { release, err := acquireProjectLock(ctx, s.Root, "accept") if err != nil { return SyncResult{}, err } defer release() return s.syncLocked(ctx) } func (s *Service) syncLocked(ctx context.Context) (SyncResult, error) { current, err := s.Store.AcceptedHead(ctx) if err != nil { return SyncResult{}, err } rootBase, err := s.prepareRootMaterialization(ctx, current.SourceTree) if err != nil { return SyncResult{}, err } changed := rootBase.SourceTree != current.SourceTree if err := s.Repo.MaterializeTree(ctx, rootBase.SourceTree, current.SourceTree); err != nil { return SyncResult{}, err } if err := s.Store.CASMaterializedHead(ctx, rootBase.ID, current.ID); err != nil { return SyncResult{}, fmt.Errorf("record visible root synchronization: %w", err) } return SyncResult{ State: current, Root: s.Root, FromState: rootBase.ID, Changed: changed, }, nil } func (s *Service) prepareRootMaterialization(ctx context.Context, targetTree string) (State, error) { rootState, matches, err := s.visibleRootState(ctx) if err != nil { return State{}, err } if !matches { materialized, materializedErr := s.Store.MaterializedHead(ctx) if materializedErr != nil { return State{}, materializedErr } actual, snapshotErr := s.Repo.WorktreeTree(ctx, materialized.SourceTree) if snapshotErr != nil { return State{}, snapshotErr } paths, pathErr := s.Repo.ChangedPaths(ctx, materialized.SourceTree, actual) if pathErr != nil { return State{}, pathErr } if len(paths) == 0 { paths = []string{"."} } return State{}, &RootConflictError{Paths: paths} } if err := s.Repo.CheckIndexSafe(ctx, rootState.SourceTree); err != nil { return State{}, err } materialized, err := s.Store.MaterializedHead(ctx) if err != nil { return State{}, err } if rootState.ID != materialized.ID { if err := s.Store.CASMaterializedHead(ctx, materialized.ID, rootState.ID); err != nil { return State{}, fmt.Errorf("repair visible root projection marker: %w", err) } } conflicts, err := s.Repo.MaterializationConflicts(ctx, rootState.SourceTree, targetTree) if err != nil { return State{}, err } if len(conflicts) > 0 { return State{}, &RootConflictError{ Paths: conflicts, Reason: "visible project root contains ignored or untracked paths that landing would overwrite", } } return rootState, nil } func (s *Service) visibleRootState(ctx context.Context) (State, bool, error) { materialized, err := s.Store.MaterializedHead(ctx) if err != nil { return State{}, false, err } actualTree, err := s.Repo.WorktreeTree(ctx, materialized.SourceTree) if err != nil { return State{}, false, err } if actualTree == materialized.SourceTree { return materialized, true, nil } accepted, err := s.Store.AcceptedHead(ctx) if err != nil { return State{}, false, err } if accepted.ID == materialized.ID { return State{}, false, nil } actualTree, err = s.Repo.WorktreeTree(ctx, accepted.SourceTree) if err != nil { return State{}, false, err } if actualTree == accepted.SourceTree { return accepted, true, nil } return State{}, false, nil } func (s *Service) Undo(ctx context.Context) (State, error) { release, err := acquireProjectLock(ctx, s.Root, "accept") if err != nil { return State{}, err } defer release() current, err := s.Store.AcceptedHead(ctx) if err != nil { return State{}, err } canonicalParent, err := s.Store.ParentByRole(ctx, current.ID, "canonical_parent") if err != nil { if errors.Is(err, ErrNotFound) { return State{}, fmt.Errorf("initial accepted state cannot be undone") } return State{}, err } previous, err := s.Store.GetState(ctx, canonicalParent.StateID) if err != nil { return State{}, err } rootBase, err := s.prepareRootMaterialization(ctx, previous.SourceTree) if err != nil { return State{}, err } undoID := newID("a") commit, err := s.Repo.CommitTree(ctx, previous.SourceTree, []string{current.GitCommit}, fmt.Sprintf( "Undo %s\n\nHop-State: %s\nHop-Reverts: %s\n", current.ID, undoID, current.ID)) if err != nil { return State{}, err } parents := canonicalizeParents([]Parent{ {StateID: current.ID, Role: "canonical_parent", Order: 0}, {StateID: current.ID, Role: "reverts", Order: 1}, {StateID: previous.ID, Role: "restores", Order: 2}, }) undo := State{ ID: undoID, Kind: StateAccepted, CanonicalAnchorID: current.ID, SourceTree: previous.SourceTree, GitCommit: commit, Summary: "Undo " + current.ID, Agent: "hop", CreatedAt: time.Now().UTC(), Parents: parents, } undo.Digest, err = digestState(undo, parents) if err != nil { return State{}, err } if err := s.pinState(ctx, undo); err != nil { return State{}, err } undo, err = s.Store.CASAccept(ctx, current.ID, undo, parents) if err != nil { return State{}, mapHeadError(err) } var postCommitErrors []error if err := s.Repo.UpdateHiddenRef(ctx, acceptedRef, undo.GitCommit); err != nil { postCommitErrors = append(postCommitErrors, fmt.Errorf("repair refs/hop/accepted: %w", err)) } if err := s.Repo.MaterializeTree(ctx, rootBase.SourceTree, undo.SourceTree); err != nil { postCommitErrors = append(postCommitErrors, fmt.Errorf("visible root %s was not synchronized: %w", s.Root, err)) } else if err := s.Store.CASMaterializedHead(ctx, rootBase.ID, undo.ID); err != nil { postCommitErrors = append(postCommitErrors, fmt.Errorf("record visible root synchronization: %w", err)) } if len(postCommitErrors) > 0 { return undo, &CommittedStateError{State: undo, Err: errors.Join(postCommitErrors...)} } return undo, nil } // recordValidationFailure turns a failed final-tree check into an immutable // descendant state when the proposal is still the attempt head. If a follow-up // raced with validation, the caller retains the validation ref and tree-bound // evidence without rewriting the newer attempt lineage. func (s *Service) recordValidationFailure( ctx context.Context, proposal State, current State, commit string, tree string, command []string, ) (State, bool) { attempt, err := s.Store.GetAttempt(ctx, proposal.AttemptID) if err != nil || attempt.HeadStateID != proposal.ID { return State{}, false } parents := canonicalizeParents([]Parent{ {StateID: proposal.ID, Role: "run_parent", Order: 0}, {StateID: current.ID, Role: "integration_parent", Order: 1}, }) storedCommand, _ := redactSecretStrings(command) failed := State{ ID: newID("f"), Kind: StateFailed, TaskID: proposal.TaskID, AttemptID: proposal.AttemptID, CanonicalAnchorID: current.ID, SourceTree: tree, GitCommit: commit, Summary: "Final validation failed: " + shellQuote(storedCommand), Agent: "hop", CreatedAt: time.Now().UTC(), Parents: parents, } failed.Digest, err = digestState(failed, parents) if err != nil { return State{}, false } if err := s.pinState(ctx, failed); err != nil { return State{}, false } failed, err = s.Store.AppendState(ctx, failed, parents, proposal.ID) if err != nil { return State{}, false } _ = s.Store.UpdateAttemptStatus(ctx, proposal.AttemptID, "changes_requested") _ = s.Store.UpdateTaskStatus(ctx, proposal.TaskID, "changes_requested") return failed, true } func (s *Service) State(ctx context.Context, id string) (State, error) { return s.Store.GetState(ctx, id) } func (s *Service) EnvironmentForState(ctx context.Context, id string) (EnvironmentResult, error) { state, err := s.Store.GetState(ctx, id) if err != nil { return EnvironmentResult{}, err } if state.AttemptID == "" { return EnvironmentResult{}, fmt.Errorf("state %s does not belong to an agent attempt", id) } attempt, err := s.Store.GetAttempt(ctx, state.AttemptID) if err != nil { return EnvironmentResult{}, err } return EnvironmentResult{ State: state, Attempt: attempt, Workspace: attempt.Workspace, Variables: map[string]string{ "HOP_ROOT": s.Root, "HOP_STATE_ID": state.ID, "HOP_TASK_ID": state.TaskID, "HOP_ATTEMPT_ID": attempt.ID, "HOP_WORKSPACE": attempt.Workspace, }, }, nil } func (s *Service) Graph(ctx context.Context) ([]GraphRow, error) { return s.Store.Graph(ctx, "") } func (s *Service) History(ctx context.Context) ([]State, error) { return s.Store.AcceptedHistory(ctx, 1000) } func (s *Service) Status(ctx context.Context) (Status, error) { status, err := s.Store.Status(ctx) if err != nil { return Status{}, err } rootState, matches, err := s.visibleRootState(ctx) if err != nil { return Status{}, err } if !matches { status.RootStatus = "diverged" return status, nil } if err := s.Repo.CheckIndexSafe(ctx, rootState.SourceTree); err != nil { var conflict *RootConflictError if errors.As(err, &conflict) { status.RootStatus = "diverged" return status, nil } return Status{}, err } status.RootStateID = rootState.ID if rootState.ID == status.AcceptedHead.ID { status.RootStatus = "synchronized" } else { status.RootStatus = "stale" } return status, nil } func (s *Service) Diff(ctx context.Context, stateID string) (string, error) { state, err := s.Store.GetState(ctx, stateID) if err != nil { return "", err } var base State if state.Kind == StateAccepted { parent, parentErr := s.Store.ParentByRole(ctx, state.ID, "canonical_parent") if errors.Is(parentErr, ErrNotFound) { return s.Repo.Diff(ctx, "", state.GitCommit) } if parentErr != nil { return "", parentErr } base, err = s.Store.GetState(ctx, parent.StateID) } else if state.AttemptID != "" { attempt, attemptErr := s.Store.GetAttempt(ctx, state.AttemptID) if attemptErr != nil { return "", attemptErr } base, err = s.Store.GetState(ctx, attempt.BaseStateID) } else { return s.Repo.Diff(ctx, "", state.GitCommit) } if err != nil { return "", err } return s.Repo.Diff(ctx, base.GitCommit, state.GitCommit) } func (s *Service) Doctor(ctx context.Context, repair bool) (DoctorReport, error) { if repair { if os.Getenv("HOP_ACCEPTANCE_LOCK_HELD") == "1" { return DoctorReport{}, fmt.Errorf("doctor --repair cannot run inside a final-state validation command") } release, err := acquireProjectLock(ctx, s.Root, "accept") if err != nil { return DoctorReport{}, err } defer release() } head, err := s.Store.AcceptedHead(ctx) if err != nil { return DoctorReport{}, err } report := DoctorReport{OK: true, AcceptedState: head.ID, AcceptedCommit: head.GitCommit} if head.Kind != StateAccepted { report.OK = false report.Problems = append(report.Problems, fmt.Sprintf("accepted head %s has kind %s", head.ID, head.Kind)) } if err := s.Repo.Verify(ctx); err != nil { report.OK = false report.Problems = append(report.Problems, "Git connectivity check failed: "+err.Error()) } rows, err := s.Store.Graph(ctx, "") if err != nil { return DoctorReport{}, err } for _, row := range rows { state := row.State if err := s.Repo.VerifyObjects(ctx, state.GitCommit, state.SourceTree); err != nil { report.OK = false report.Problems = append(report.Problems, fmt.Sprintf("state %s references missing Git data: %v", state.ID, err)) continue } commitTree, treeErr := s.Repo.resolveTree(ctx, state.GitCommit) if treeErr != nil || commitTree != state.SourceTree { report.OK = false if treeErr != nil { report.Problems = append(report.Problems, fmt.Sprintf("state %s commit tree cannot be resolved: %v", state.ID, treeErr)) } else { report.Problems = append(report.Problems, fmt.Sprintf("state %s records tree %s but commit resolves to %s", state.ID, state.SourceTree, commitTree)) } } expectedDigest, digestErr := digestState(state, row.Parents) if digestErr != nil || expectedDigest != state.Digest { report.OK = false if digestErr != nil { report.Problems = append(report.Problems, fmt.Sprintf("state %s digest cannot be recomputed: %v", state.ID, digestErr)) } else { report.Problems = append(report.Problems, fmt.Sprintf("state %s digest mismatch", state.ID)) } } if problem := stateParentProblem(state, row.Parents); problem != "" { report.OK = false report.Problems = append(report.Problems, problem) } refName := "states/" + state.ID refCommit, exists, readErr := s.Repo.ReadHiddenRef(ctx, refName) if readErr != nil { return DoctorReport{}, readErr } if !exists || refCommit != state.GitCommit { report.OK = false report.Problems = append(report.Problems, fmt.Sprintf("%s does not pin state %s", refName, state.ID)) if repair { if err := s.Repo.UpdateHiddenRef(ctx, refName, state.GitCommit); err != nil { return DoctorReport{}, err } report.Repaired = true } } } refCommit, exists, err := s.Repo.ReadHiddenRef(ctx, acceptedRef) if err != nil { return DoctorReport{}, err } if exists { report.RefCommit = refCommit } if !exists || refCommit != head.GitCommit { report.OK = false report.Problems = append(report.Problems, "refs/hop/accepted does not match the SQLite accepted head") if repair { if err := s.Repo.UpdateHiddenRef(ctx, acceptedRef, head.GitCommit); err != nil { return DoctorReport{}, err } report.RefCommit = head.GitCommit report.Repaired = true } } if repair && report.Repaired { verified, verifyErr := s.Doctor(ctx, false) if verifyErr != nil { return DoctorReport{}, verifyErr } verified.Repaired = true return verified, nil } return report, nil } func stateParentProblem(state State, parents []Parent) string { hasRole := func(role string) bool { for _, parent := range parents { if parent.Role == role { return true } } return false } switch state.Kind { case StatePrompt: if state.TaskID == "" || state.AttemptID == "" || !hasRole("run_parent") || !hasRole("canonical_anchor") { return fmt.Sprintf("prompt state %s is missing task, attempt, run-parent, or canonical-anchor provenance", state.ID) } case StateCheckpoint, StateProposal, StateFailed, StateCancelled: if state.TaskID == "" || state.AttemptID == "" || !hasRole("run_parent") { return fmt.Sprintf("%s state %s is missing task, attempt, or run-parent provenance", state.Kind, state.ID) } case StateAccepted: isInitial := state.CanonicalAnchorID == "" && len(parents) == 0 if !isInitial && !hasRole("canonical_parent") { return fmt.Sprintf("accepted state %s is missing its canonical parent", state.ID) } } return "" } func (s *Service) pinState(ctx context.Context, state State) error { return s.Repo.UpdateHiddenRef(ctx, "states/"+state.ID, state.GitCommit) } // reconcileDerivedRefs repairs the small crash window between SQLite commits // and derived Git-ref updates. SQLite is authoritative, but every referenced // commit is pinned before normal operation resumes so Git GC cannot discard it. func (s *Service) reconcileDerivedRefs(ctx context.Context, reconcileAccepted bool) error { rows, err := s.Store.Graph(ctx, "") if err != nil { return err } for _, row := range rows { state := row.State if err := s.Repo.VerifyObjects(ctx, state.GitCommit, state.SourceTree); err != nil { return fmt.Errorf("state %s references unavailable Git data: %w", state.ID, err) } name := "states/" + state.ID commit, exists, err := s.Repo.ReadHiddenRef(ctx, name) if err != nil { return err } if !exists || commit != state.GitCommit { if err := s.Repo.UpdateHiddenRef(ctx, name, state.GitCommit); err != nil { return fmt.Errorf("repair Git pin for state %s: %w", state.ID, err) } } } if !reconcileAccepted { return nil } release, err := acquireProjectLock(ctx, s.Root, "accept") if err != nil { return err } defer release() head, err := s.Store.AcceptedHead(ctx) if err != nil { return err } commit, exists, err := s.Repo.ReadHiddenRef(ctx, acceptedRef) if err != nil { return err } if !exists || commit != head.GitCommit { if err := s.Repo.UpdateHiddenRef(ctx, acceptedRef, head.GitCommit); err != nil { return fmt.Errorf("repair accepted Git ref: %w", err) } } return nil } func (s *Service) deliveryEnvironment(state State, attempt Attempt) []string { return []string{ "HOP_ROOT=" + s.Root, "HOP_STATE_ID=" + state.ID, "HOP_TASK_ID=" + state.TaskID, "HOP_ATTEMPT_ID=" + attempt.ID, "HOP_WORKSPACE=" + attempt.Workspace, } } func promptTitle(prompt string) string { prompt = strings.TrimSpace(prompt) if newline := strings.IndexByte(prompt, '\n'); newline >= 0 { prompt = prompt[:newline] } if len(prompt) > 100 { prompt = prompt[:97] + "..." } return prompt } func intersectPaths(left, right []string) []string { rightSet := make(map[string]struct{}, len(right)) for _, path := range right { rightSet[path] = struct{}{} } var intersection []string for _, path := range left { if _, ok := rightSet[path]; ok { intersection = append(intersection, path) } } sort.Strings(intersection) return intersection } func mapHeadError(err error) error { var changed *HeadChangedError if errors.As(err, &changed) { return &StaleHeadError{Expected: changed.Expected, Actual: changed.Actual} } return err }