nomadcode/services/core/internal/roadmapsync/retry.go
toki fad5627ae6 feat: milestone work item creation/sync 구현 및 roadmap sync 기능 추가
- Plane Todo projection 및 idempotency/retry 메커니즘 구현
- Roadmap sync identity 및 step 모델/DB 마이그레이션/쿼리 추가
- OpenAI 및 Plane adapter 개선
- roadmaps sync package: plane_format, plane_projection, retry 모듈 추가
- Storage layer: roadmap_sync_identities, roadmap_sync_steps 추가
- agent-task 아카이브 정리
2026-06-14 04:08:15 +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}
}
// Strict match: when a persisted identity exists it must agree on the full
// provider-side key (provider, tenant, project, work item id, Milestone
// path). A found-but-not-strictly-matching row means the trigger disagrees
// with what is stored (e.g. a different Plane workspace/project for the same
// work item id/path), which is a conflict rather than a silent fresh resume.
if in.Existing.Found && !in.Existing.Identity.MatchesStrict(identity) {
return ReconcileResult{Action: ActionConflict, Reason: reasonIdentityKeyMismatch}
}
identityKept := in.Existing.Found
// Revision guard: when an identity already exists for this work item/path,
// 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
}