nomadcode/services/core/internal/roadmapsync/retry.go
toki 528e9d6966 feat(roadmapsync): plane origin authoring roundtrip 기능 및 마일스톤 ID 지원
- roadmapsync pipeline에 retry 로직 및 개발 일치 검증 로직 추가
- gitosync scanner에 slug 재조정 및 plane 매칭 로직 강화
- roadmap sync identity 관리를 위한 migration(00009) 및 SQL 쿼리 추가
- milestone parser 테스트 및 retry 테스트 보강
- agent-ops 문서(PHASE, SDD, milestones) 갱신
- agent-task archive 및 작업 디렉터리 추가
2026-06-21 07:59:56 +09:00

173 lines
7 KiB
Go

package roadmapsync
import "strings"
// Step is the provider-neutral creation-cycle step the retry decision resumes
// from. The values mirror the persisted step ledger so a caller can read the
// ledger and pass the completed set in without re-mapping strings.
type Step string
const (
StepDevelopMatched Step = "develop_matched"
StepOriginalCommentPreserved Step = "original_comment_preserved"
StepPlaneBodyUpdated Step = "plane_body_updated"
StepPlaneTodoMoved Step = "plane_todo_moved"
)
// OrderedSteps is the creation-cycle order. A reconcile resumes at the first
// step not present in the completed set.
var OrderedSteps = []Step{
StepDevelopMatched,
StepOriginalCommentPreserved,
StepPlaneBodyUpdated,
StepPlaneTodoMoved,
}
// Action is the next thing a creation cycle should do after reconciling the
// persisted identity and step ledger against the current trigger.
type Action string
const (
// ActionResume runs the remaining steps starting at NextStep.
ActionResume Action = "resume"
// ActionComplete means every step is already recorded; nothing to do.
ActionComplete Action = "complete"
// ActionConflict means the trigger disagrees with the persisted identity
// (revision mismatch) and a human/upstream decision is required instead of a
// silent overwrite.
ActionConflict Action = "conflict"
// ActionSkipSelfMutation means the trigger was NomadCode's own mutation, so
// it must not be treated as a new authoring trigger.
ActionSkipSelfMutation Action = "skip_self_mutation"
)
// ExistingIdentity is the provider-neutral view of a persisted identity row the
// reconcile needs: the stored identity plus the revisions it was last written
// with. Found is false when no identity is persisted yet.
type ExistingIdentity struct {
Found bool
Identity Identity
ProviderRevision string
RoadmapRevision string
}
// ReconcileInput carries the current trigger and the persisted state to
// reconcile it against. Existing and CompletedSteps come from the identity map
// and step ledger; an empty CompletedSteps means a fresh cycle.
type ReconcileInput struct {
// Identity is the trigger's provider-neutral identity (the Plane ticket or
// Milestone path being re-processed).
Identity Identity
// ProviderRevision / RoadmapRevision are the revisions the current trigger
// observed. A mismatch against the persisted revisions is a conflict.
ProviderRevision string
RoadmapRevision string
// Actor is who caused this trigger. When it equals SelfActor the trigger is
// NomadCode's own mutation and is not a new authoring trigger.
Actor string
SelfActor string
Existing ExistingIdentity
CompletedSteps map[Step]bool
}
// ReconcileResult is the typed decision: what to do next, the resume step when
// ActionResume, whether an existing identity was reused, and a reason that is
// always populated so a not-resume decision is never silent.
type ReconcileResult struct {
Action Action
NextStep Step
IdentityKept bool
Reason string
}
// Reasons for a reconcile decision. Stable strings so a caller can branch on
// the cause.
const (
reasonInvalidReconcileIdentity = "trigger identity is invalid or incomplete"
reasonIdentityKeyMismatch = "persisted identity key does not match trigger (provider/tenant/project/work item/path)"
reasonSelfMutation = "trigger actor is NomadCode self mutation, not a new authoring trigger"
reasonProviderRevMismatch = "trigger provider revision does not match persisted identity"
reasonRoadmapRevMismatch = "trigger roadmap revision does not match persisted identity"
reasonAllStepsComplete = "all creation cycle steps already completed"
reasonResumeRemaining = "resuming creation cycle at first incomplete step"
)
// ReconcileCreationCycle decides the next action for a (possibly re-processed)
// creation cycle. It is the idempotency join described by plan 05: it reuses an
// existing identity when the same Plane ticket or Milestone path is
// re-processed, refuses to overwrite on a revision mismatch, skips NomadCode's
// own mutations, and otherwise resumes at the first step not yet in the ledger.
//
// The function reads no DB itself; callers supply the persisted identity and
// completed-step set so the decision stays provider-neutral and unit-testable.
func ReconcileCreationCycle(in ReconcileInput) ReconcileResult {
identity, err := in.Identity.Normalize()
if err != nil {
return ReconcileResult{Action: ActionConflict, Reason: reasonInvalidReconcileIdentity}
}
// Actor guard: a mutation NomadCode itself made (e.g. the body/title rewrite
// it just projected) must not be re-interpreted as a fresh user trigger.
self := strings.TrimSpace(in.SelfActor)
if self != "" && strings.TrimSpace(in.Actor) == self {
return ReconcileResult{Action: ActionSkipSelfMutation, Reason: reasonSelfMutation}
}
// Match: when a persisted identity exists it must agree on the minimal key
// (provider and canonical Milestone id). A found-but-not-matching row means
// the trigger disagrees with what is stored (e.g. different provider), which
// is a conflict rather than a silent fresh resume. Changes to path, tenant,
// project, or provider work item id are allowed safe attribute updates.
if in.Existing.Found && !in.Existing.Identity.Matches(identity) {
return ReconcileResult{Action: ActionConflict, Reason: reasonIdentityKeyMismatch}
}
identityKept := in.Existing.Found
// Revision guard: when an identity already exists for this milestone id,
// the trigger must observe the same revisions, otherwise resuming would
// silently overwrite work projected from a different develop state.
if identityKept {
if mismatch(in.Existing.ProviderRevision, in.ProviderRevision) {
return ReconcileResult{Action: ActionConflict, IdentityKept: true, Reason: reasonProviderRevMismatch}
}
if mismatch(in.Existing.RoadmapRevision, in.RoadmapRevision) {
return ReconcileResult{Action: ActionConflict, IdentityKept: true, Reason: reasonRoadmapRevMismatch}
}
}
next, ok := firstIncompleteStep(in.CompletedSteps)
if !ok {
return ReconcileResult{Action: ActionComplete, IdentityKept: identityKept, Reason: reasonAllStepsComplete}
}
return ReconcileResult{
Action: ActionResume,
NextStep: next,
IdentityKept: identityKept,
Reason: reasonResumeRemaining,
}
}
// firstIncompleteStep returns the earliest ordered step not present in the
// completed set. ok is false when every step is complete.
func firstIncompleteStep(completed map[Step]bool) (Step, bool) {
for _, step := range OrderedSteps {
if !completed[step] {
return step, true
}
}
return "", false
}
// mismatch reports whether a persisted revision and a trigger revision disagree.
// Empty values are treated as "unknown" and never conflict, so a first cycle
// that has not recorded a revision is not blocked.
func mismatch(persisted, trigger string) bool {
p := strings.TrimSpace(persisted)
t := strings.TrimSpace(trigger)
if p == "" || t == "" {
return false
}
return p != t
}