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
127 changes: 127 additions & 0 deletions internal/engine/abandon_event_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package engine

import (
"encoding/json"
"errors"
"fmt"
"sync"
"testing"
Expand Down Expand Up @@ -91,6 +92,132 @@ func TestAbandonProposedWritesActorLogEvent(t *testing.T) {
}
}

func TestAbandonSharedLifecycleWithoutLocalDraftWritesActorLogEvent(t *testing.T) {
for _, sharedStatus := range []domain.IntentStatus{
domain.StatusProposed,
domain.StatusSealedLocal,
} {
t.Run(string(sharedStatus), func(t *testing.T) {
dir, cleanup := testRepo(t)
defer cleanup()
svc := NewServiceFromRoot(dir)
if _, err := svc.Init("agent"); err != nil {
t.Fatalf("init: %v", err)
}

gitCmd(t, dir, "checkout", "-b", "feature/missing-draft")
start, err := svc.Start("published worktree was cleaned", "")
if err != nil {
t.Fatalf("start: %v", err)
}
writeFile(t, dir, "missing_draft.go", "package main\n")
gitCmd(t, dir, "add", "missing_draft.go")
gitCmd(t, dir, "commit", "-m", "missing-draft-target")
if _, err := svc.Append("seal before cleaning the worktree"); err != nil {
t.Fatalf("append: %v", err)
}

sr := validSealResult(start.IntentID)
data, _ := json.Marshal(sr)
if _, err := svc.SealSubmit(json.RawMessage(data)); err != nil {
t.Fatalf("seal: %v", err)
}
view, err := svc.Store.ReadMainlineView()
if err != nil {
t.Fatalf("read shared view: %v", err)
}
for i := range view.Intents {
if view.Intents[i].IntentID == start.IntentID {
view.Intents[i].Status = sharedStatus
}
}
if err := svc.Store.WriteMainlineView(view); err != nil {
t.Fatalf("write shared view: %v", err)
}
before := svc.readIntentViewByID(start.IntentID)
if before == nil || before.Status != sharedStatus {
t.Fatalf("expected shared %s view before draft cleanup, got %+v", sharedStatus, before)
}
if err := svc.Store.DeleteDraft(start.IntentID); err != nil {
t.Fatalf("delete local draft: %v", err)
}

res, err := svc.Abandon(start.IntentID, "superseded after worktree cleanup")
if err != nil {
t.Fatalf("abandon %s intent without local draft: %v", sharedStatus, err)
}
if res.PriorStatus != string(sharedStatus) {
t.Fatalf("expected PriorStatus=%s, got %s", sharedStatus, res.PriorStatus)
}
if res.EventID == "" {
t.Fatalf("expected abandon event for shared %s intent", sharedStatus)
}
if _, err := svc.Store.ReadDraft(start.IntentID); err == nil {
t.Fatal("abandon must not recreate a missing local draft")
}
after := svc.readIntentViewByID(start.IntentID)
if after == nil || after.Status != domain.StatusAbandoned {
t.Fatalf("expected shared view status abandoned, got %+v", after)
}
})
}
}

func TestAbandonWithoutLocalDraftRejectsUnavailableOrTerminalSharedState(t *testing.T) {
tests := []struct {
name string
status domain.IntentStatus
wantCode domain.ErrorCode
}{
{name: "missing everywhere", wantCode: domain.ErrNoActiveIntent},
{name: "drafting is local only", status: domain.StatusDrafting, wantCode: domain.ErrNoActiveIntent},
{name: "merged", status: domain.StatusMerged, wantCode: domain.ErrInvalidStatus},
{name: "abandoned", status: domain.StatusAbandoned, wantCode: domain.ErrInvalidStatus},
{name: "superseded", status: domain.StatusSuperseded, wantCode: domain.ErrInvalidStatus},
{name: "reverted", status: domain.StatusReverted, wantCode: domain.ErrInvalidStatus},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir, cleanup := testRepo(t)
defer cleanup()
svc := NewServiceFromRoot(dir)
initRes, err := svc.Init("agent")
if err != nil {
t.Fatalf("init: %v", err)
}
const intentID = "int_without_local_draft"
if tt.status != "" {
if err := svc.Store.WriteMainlineView(&domain.MainlineView{
SchemaVersion: 1,
Intents: []domain.IntentView{{
IntentID: intentID,
Status: tt.status,
}},
}); err != nil {
t.Fatalf("write shared view: %v", err)
}
}

cfg, err := svc.getTeamConfig()
if err != nil {
t.Fatalf("team config: %v", err)
}
actorRef := svc.Store.ActorLogRef(initRes.ActorID, cfg.Mainline.ActorLogPrefix)
beforeRef := svc.Git.ReadRef(actorRef)

_, err = svc.Abandon(intentID, "must not mutate invalid state")
var mlErr *domain.MainlineError
if !errors.As(err, &mlErr) || mlErr.Code != tt.wantCode {
t.Fatalf("expected %s, got %v", tt.wantCode, err)
}
if afterRef := svc.Git.ReadRef(actorRef); afterRef != beforeRef {
t.Fatalf("actor log changed for rejected state: before=%s after=%s", beforeRef, afterRef)
}
})
}
}

// Drafting-state abandon is still local-only; the draft files are
// the entire footprint, so we delete them rather than write a
// public event for a never-published intent.
Expand Down
32 changes: 22 additions & 10 deletions internal/engine/intent.go
Original file line number Diff line number Diff line change
Expand Up @@ -414,15 +414,25 @@ func (s *Service) Abandon(intentID string, reason string) (*AbandonResult, error
}

draft, err := s.Store.ReadDraft(intentID)
if err != nil {
if err != nil && !os.IsNotExist(err) {
return nil, domain.NewError(domain.ErrNoActiveIntent, fmt.Sprintf("intent %s not found", intentID))
}

iv := s.readIntentViewByID(intentID)
if draft == nil && (iv == nil || iv.Status == domain.StatusDrafting) {
return nil, domain.NewError(domain.ErrNoActiveIntent, fmt.Sprintf("intent %s not found", intentID))
}

effectiveStatus := draft.Status
var effectiveStatus domain.IntentStatus
// Sync/pin is the modern source of merged truth when it points at a new
// main commit. A no-op sealed intent can otherwise look merged because
// its code commit equals its base commit.
if iv := s.readIntentViewByID(intentID); viewTerminalStatusOverridesDraft(draft, iv) {
if draft != nil {
effectiveStatus = draft.Status
if viewTerminalStatusOverridesDraft(draft, iv) {
effectiveStatus = iv.Status
}
} else {
effectiveStatus = iv.Status
}

Expand Down Expand Up @@ -491,13 +501,15 @@ func (s *Service) Abandon(intentID string, reason string) (*AbandonResult, error
res.Warning = "No remote configured. Run `mainline publish` once you set one up."
}

// Update the local draft last so the file mirrors the event we
// just wrote — keeps `mainline status` and `show` in sync without
// waiting for a Sync round-trip.
draft.Status = domain.StatusAbandoned
draft.LastModifiedAt = core.Now()
if err := s.Store.WriteDraft(draft); err != nil {
return nil, fmt.Errorf("update draft: %w", err)
// Update an existing local draft last so the file mirrors the event we
// just wrote. A shared proposal remains actionable after its worktree-local
// cache is cleaned, and abandoning it must not manufacture a replacement.
if draft != nil {
draft.Status = domain.StatusAbandoned
draft.LastModifiedAt = core.Now()
if err := s.Store.WriteDraft(draft); err != nil {
return nil, fmt.Errorf("update draft: %w", err)
}
}

if err := s.refreshLocalViewIndexes(cfg); err != nil {
Expand Down
Loading