diff --git a/internal/engine/abandon_event_test.go b/internal/engine/abandon_event_test.go index 2522b069..7d49420c 100644 --- a/internal/engine/abandon_event_test.go +++ b/internal/engine/abandon_event_test.go @@ -2,6 +2,7 @@ package engine import ( "encoding/json" + "errors" "fmt" "sync" "testing" @@ -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. diff --git a/internal/engine/intent.go b/internal/engine/intent.go index ac1a5223..e4064523 100644 --- a/internal/engine/intent.go +++ b/internal/engine/intent.go @@ -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 } @@ -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 {