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 및 작업 디렉터리 추가
This commit is contained in:
toki 2026-06-21 07:59:56 +09:00
parent d1699ef724
commit 528e9d6966
37 changed files with 3305 additions and 275 deletions

View file

@ -38,13 +38,13 @@
- 경로: `agent-roadmap/archive/phase/agent-ops-mcp-control-plane/milestones/gito-http-webhook-consumer-readiness.md` - 경로: `agent-roadmap/archive/phase/agent-ops-mcp-control-plane/milestones/gito-http-webhook-consumer-readiness.md`
- 요약: Gito `gito.forgejo-branch-events.v1` signed HTTP webhook delivery를 NomadCode Core가 소비하고 branch revision 재검증/creation sync enqueue로 수렴하는 구현을 코드 레벨 종료 감사와 소규모 계약 보강 후 archive했다. - 요약: Gito `gito.forgejo-branch-events.v1` signed HTTP webhook delivery를 NomadCode Core가 소비하고 branch revision 재검증/creation sync enqueue로 수렴하는 구현을 코드 레벨 종료 감사와 소규모 계약 보강 후 archive했다.
- [진행중] Roadmap Driven Agent-Ops Automation
- 경로: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/roadmap-driven-agent-ops-automation.md`
- 요약: roadmap/action core와 Plane/Jira 기반 Milestone item 동기화의 상위 방향과 계약을 정리하는 활성 설계 마일스톤이며, 실제 구현은 slice 마일스톤으로 진행한다.
- [진행중] Plane-Origin Authoring Roundtrip Sync - [진행중] Plane-Origin Authoring Roundtrip Sync
- 경로: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-origin-authoring-roundtrip-sync.md` - 경로: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-origin-authoring-roundtrip-sync.md`
- 요약: 완료 처리된 Plane-origin creation sync를 최신 dev IOP Responses, workspace slot, Gito HTTP webhook, provider identity, Plane Todo projection 기준으로 재검증하고 보강하는 구현 마일스톤이다. - 요약: 현재 우선 구현 slice다. 완료 처리된 Plane-origin creation sync를 최신 dev IOP Responses, workspace slot, Gito HTTP webhook, filename slug canonical id, active Milestone reconcile, Plane 생성/갱신 기준으로 재검증하고 보강한다.
- [진행중] Roadmap Driven Agent-Ops Automation
- 경로: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/roadmap-driven-agent-ops-automation.md`
- 요약: roadmap/action core와 Plane/Jira 기반 Milestone item 동기화의 상위 방향과 계약을 정리하는 활성 설계 마일스톤이며, 실제 구현은 우선 slice인 Plane-Origin Authoring Roundtrip Sync에서 진행한다.
- [계획] Milestone Execution Lifecycle Sync - [계획] Milestone Execution Lifecycle Sync
- 경로: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-execution-lifecycle-sync.md` - 경로: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-execution-lifecycle-sync.md`

View file

@ -7,7 +7,7 @@
## 목표 ## 목표
Plane `Backlog + AGENT assignee` 상위 티켓에서 시작한 Milestone authoring이 IOP Responses workspace agent 실행, `develop` push, Gito HTTP webhook delivery, Core develop scan, provider identity match, Plane `Todo` projection까지 한 사이클로 수렴하도록 최신 dev 환경 기준으로 다시 닫는다. 사용자 메시지는 단순 authoring 입력으로 유지하고, provider identity와 push/projection 책임은 Core/runtime의 결정적 처리로 옮긴다. Plane `Backlog + AGENT assignee` 상위 티켓에서 시작한 Milestone authoring을 최신 dev 환경 기준으로 다시 닫되, Git/Plane 동기화의 canonical identity를 `agent-roadmap` Milestone 파일명 slug 하나로 정리한다. Gito HTTP webhook delivery는 단순 변경 파일 projection이 아니라 active roadmap Milestone reconcile을 깨우는 신호로 쓰고, archive를 제외한 모든 active Milestone이 Plane에 존재하도록 생성/갱신한다. Plane work item id와 roadmap path는 canonical id가 아니라 외부 참조와 현재 위치 속성으로만 취급한다.
## 상태 ## 상태
@ -34,9 +34,10 @@ Plane `Backlog + AGENT assignee` 상위 티켓에서 시작한 Milestone authori
- Plane-origin work item intake 이후 authoring 실행과 `develop` 반영 사이의 실제 runtime handoff - Plane-origin work item intake 이후 authoring 실행과 `develop` 반영 사이의 실제 runtime handoff
- IOP OpenAI-compatible Responses `/v1/responses` 호출의 long-running authoring 처리, timeout, queue timeout, stale monitoring 기준 - IOP OpenAI-compatible Responses `/v1/responses` 호출의 long-running authoring 처리, timeout, queue timeout, stale monitoring 기준
- authoring 프롬프트를 사용자 요청 중심으로 얇게 유지하면서 provider identity를 Milestone 문서 또는 sync ledger에 결정적으로 연결하는 방식 - Milestone 파일명 slug를 Git/Plane 동기화의 단일 canonical id로 사용하고, path와 Plane work item id는 mutable attribute/foreign key로 분리하는 방식
- Gito HTTP webhook delivery를 wakeup으로 받아 Core가 local develop checkout을 fetch하고 archive를 제외한 active Milestone 전체를 reconcile하는 흐름
- Plane에 없는 active Milestone을 생성하고, 이미 있는 Milestone은 같은 slug 기준으로 본문/제목/status/ledger를 갱신하는 흐름
- authoring 후 별도 push 단계가 필요할 때 IOP workspace agent에 push-only 요청을 보내고, remote `develop` 반영을 Core가 재검증하는 흐름 - authoring 후 별도 push 단계가 필요할 때 IOP workspace agent에 push-only 요청을 보내고, remote `develop` 반영을 Core가 재검증하는 흐름
- Gito HTTP webhook delivery를 wakeup으로 받아 Core가 local develop checkout을 fetch/scan하고 creation sync job으로 넘기는 흐름
- Plane 원문 댓글 보존, 본문/제목 갱신, `Todo` 이동, task/slot terminal state까지 이어지는 live-cycle smoke - Plane 원문 댓글 보존, 본문/제목 갱신, `Todo` 이동, task/slot terminal state까지 이어지는 live-cycle smoke
## 기능 ## 기능
@ -50,22 +51,31 @@ Plane-origin authoring이 짧은 사용자 입력과 workspace metadata만으로
- [x] [iop-progress] `/v1/responses` streaming 지원 여부를 dev IOP 계약으로 확인하고, stream 미지원 환경에서는 `authoring_run_updated_at`과 task metadata 기반 stale monitor로 대체한다. 검증: stream 미지원 응답이 silent failure가 아니라 명시 사유로 기록되고, stream 지원 시에는 진행 갱신 중단을 감지할 수 있다. - [x] [iop-progress] `/v1/responses` streaming 지원 여부를 dev IOP 계약으로 확인하고, stream 미지원 환경에서는 `authoring_run_updated_at`과 task metadata 기반 stale monitor로 대체한다. 검증: stream 미지원 응답이 silent failure가 아니라 명시 사유로 기록되고, stream 지원 시에는 진행 갱신 중단을 감지할 수 있다.
- [x] [push-command] authoring 결과 작성 이후 Core가 workspace agent에 `변경된 내용에 대해 develop 브런치에 푸시해` push-only 요청을 별도 단계로 보낼 수 있다. 검증: 첫 authoring 응답 성공만으로 task 완료를 단정하지 않고, push-only 단계와 remote `develop` 확인이 있어야 다음 단계로 넘어간다. - [x] [push-command] authoring 결과 작성 이후 Core가 workspace agent에 `변경된 내용에 대해 develop 브런치에 푸시해` push-only 요청을 별도 단계로 보낼 수 있다. 검증: 첫 authoring 응답 성공만으로 task 완료를 단정하지 않고, push-only 단계와 remote `develop` 확인이 있어야 다음 단계로 넘어간다.
### Epic: [identity-sync] Provider identity and develop scan ### Epic: [identity-sync] Plane-origin identity guard
사용자 프롬프트에 provider 계약을 넣지 않고도 pushed Milestone이 원래 Plane 티켓과 매칭되도록 identity 연결을 결정적으로 보장한다. 사용자 프롬프트에 provider 계약을 넣지 않고도 Plane-origin authoring 결과가 원래 Plane 티켓과 매칭되도록 기존 identity guard를 보강한다. 이 Epic은 이미 완료된 authoring-origin 안전장치이며, 신규 active reconcile의 canonical id는 아래 `canonical-reconcile` Epic에서 filename slug로 정리한다.
- [x] [identity-source] Plane work item identity는 task external ref, payload `work_item`, checkout metadata, project sync 설정에서 가져오고 사용자 작성 문구에 의존하지 않는다. 검증: `BuildAuthoringGenerateInput`이 workspace metadata를 유지하고, identity source 누락 시 authoring 또는 projection이 명시 오류로 멈춘다. - [x] [identity-source] Plane work item identity는 task external ref, payload `work_item`, checkout metadata, project sync 설정에서 가져오고 사용자 작성 문구에 의존하지 않는다. 검증: `BuildAuthoringGenerateInput`이 workspace metadata를 유지하고, identity source 누락 시 authoring 또는 projection이 명시 오류로 멈춘다.
- [ ] [identity-write] authoring 산출물에 `## Provider identity` block이 없더라도 Core/runtime이 provider, tenant, project, work item id, milestone id를 결정적으로 주입하거나 동등한 sync ledger를 만들어 `gitosync` scan이 매칭할 수 있게 한다. 검증: provider identity 없는 generated Milestone이 Gito callback 이후 조용히 drop되지 않고, match 가능 상태 또는 명시 failure로 수렴한다. - [x] [identity-write] authoring 산출물에 `## Provider identity` block이 없더라도 Core/runtime이 provider, tenant, project, work item id, milestone id를 결정적으로 주입하거나 동등한 sync ledger를 만들어 `gitosync` scan이 매칭할 수 있게 한다. 검증: provider identity 없는 generated Milestone이 Gito callback 이후 조용히 drop되지 않고, match 가능 상태 또는 명시 failure로 수렴한다.
- [x] [develop-match] pushed `develop` revision에서 실제 Milestone path 변경과 provider identity가 원래 Plane 티켓과 일치할 때만 Plane projection을 실행한다. 검증: slot-local dirty, push 실패, identity mismatch, milestone path 없음은 Plane `Todo` projection으로 넘어가지 않는다. - [x] [develop-match] pushed `develop` revision에서 실제 Milestone path 변경과 provider identity가 원래 Plane 티켓과 일치할 때만 Plane projection을 실행한다. 검증: slot-local dirty, push 실패, identity mismatch, milestone path 없음은 Plane `Todo` projection으로 넘어가지 않는다.
- [x] [idempotency-retry] 같은 Plane 티켓, 같은 Milestone path, 같은 Gito revision 재처리가 중복 Milestone/중복 Plane mutation 없이 남은 단계만 재시도한다. 검증: `roadmap_sync_identities`, `roadmap_sync_steps`, Gito revision dedup이 부분 실패 복구와 충돌 중단을 구분한다. - [x] [idempotency-retry] 같은 Plane 티켓, 같은 Milestone path, 같은 Gito revision 재처리가 중복 Milestone/중복 Plane mutation 없이 남은 단계만 재시도한다. 검증: `roadmap_sync_identities`, `roadmap_sync_steps`, Gito revision dedup이 부분 실패 복구와 충돌 중단을 구분한다.
### Epic: [canonical-reconcile] Filename slug based active Milestone reconcile
Git/Plane 동기화는 path나 provider work item id가 아니라 Milestone 파일명 slug 하나를 canonical id로 삼는다. path는 active/archive 위치를 찾기 위한 속성이고, provider work item id는 Plane foreign key다.
- [ ] [canonical-id] `agent-roadmap/phase/*/milestones/<milestone-id>.md``<milestone-id>`를 단일 canonical sync id로 정의하고, `roadmap_sync_identities`/parser/matcher/projection이 path 대신 이 id를 1차 판별값으로 사용한다. 검증: phase 이동, archive 이동 후보, provider work item id 변경이 있어도 같은 filename slug는 같은 Milestone으로 판별된다.
- [x] [active-index] Gito wakeup 이후 Core가 remote `develop`을 fetch한 뒤 `agent-roadmap/phase/*/milestones/*.md`만 active Milestone index로 스캔하고 `agent-roadmap/archive/**`는 sync 대상에서 제외한다. 검증: active 문서는 reconcile 후보가 되고 archive 문서는 생성/갱신 대상에서 제외되지만 slug lookup으로 이력 조회 후보를 찾을 수 있다.
- [ ] [plane-missing-create] active Milestone slug가 ledger와 Plane에서 발견되지 않으면 Plane work item을 생성하고, 생성된 Plane work item id를 slug ledger와 Milestone provider identity block 또는 동등한 sync marker에 기록한다. 검증: Plane에 없는 active Milestone 전체가 중복 없이 생성되고 제목에는 `[milestone-id]`가 들어간다.
- [ ] [slug-reconcile] 같은 Milestone slug 재처리는 중복 Plane item을 만들지 않고 path, provider work item id, revision, 완료 step만 갱신한다. 검증: 같은 slug의 재처리/부분 실패 복구는 resume되고, slug 충돌 또는 파일명 재사용은 자동 병합하지 않고 명시 failure로 멈춘다.
### Epic: [gito-plane] Gito callback and Plane projection ### Epic: [gito-plane] Gito callback and Plane projection
Gito HTTP webhook wakeup 이후 Core sync pipeline이 Plane Todo projection까지 닫히는지 실제 환경 기준으로 검증한다. Gito HTTP webhook wakeup 이후 Core sync pipeline이 active Milestone reconcile과 Plane Todo projection까지 닫히는지 실제 환경 기준으로 검증한다.
- [ ] [gito-delivery] Forgejo push -> Gito -> NomadCode Core signed HTTP webhook delivery가 remote `develop` push마다 들어온다. 검증: Gito subscription target, signed delivery 202, Core gito webhook log, duplicate delivery 처리 결과를 secret 없이 확인한다. - [ ] [gito-delivery] Forgejo push -> Gito -> NomadCode Core signed HTTP webhook delivery가 remote `develop` push마다 들어온다. 검증: Gito subscription target, signed delivery 202, Core gito webhook log, duplicate delivery 처리 결과를 secret 없이 확인한다.
- [ ] [scan-enqueue] Gito wakeup이 local develop checkout fetch/scan 이후 `roadmap_creation_sync` job enqueue로 이어진다. 검증: provider identity가 있는 Milestone 변경은 정확히 하나의 creation sync job으로 이어지고, identity 없는 변경은 명시 사유를 남긴다. - [ ] [scan-enqueue] Gito wakeup이 local develop checkout fetch와 active Milestone index scan 이후 slug 단위 reconcile job enqueue로 이어진다. 검증: 변경 파일 힌트와 무관하게 active Milestone 전체가 reconcile 후보가 되고, archive Milestone은 제외된다.
- [ ] [plane-todo] creation sync job이 Plane 원문 댓글 보존, Milestone 본문 projection, `[milestone-id]` 제목 갱신, `Todo` 이동까지 완료한다. 검증: Plane UI/API와 `roadmap_sync_steps`에서 `develop_matched`, `original_comment_preserved`, `plane_body_updated`, `plane_todo_moved`를 확인한다. - [ ] [plane-todo] reconcile job이 Plane 원문 댓글 보존, Milestone 본문 projection, `[milestone-id]` 제목 갱신, `Todo` 이동까지 완료한다. 검증: Plane UI/API와 `roadmap_sync_steps`에서 `milestone_id_matched`, `original_comment_preserved`, `plane_body_updated`, `plane_todo_moved`를 확인한다.
- [ ] [slot-finalize] projected, failed, timeout, queue timeout, push failed, identity missing 결과가 workspace slot 상태와 task metadata에 일관되게 반영된다. 검증: 성공은 slot `available`, 수동 복구가 필요한 git 상태는 `dirty`, bridge/provider 오류는 `error`로 수렴한다. - [ ] [slot-finalize] projected, failed, timeout, queue timeout, push failed, identity missing 결과가 workspace slot 상태와 task metadata에 일관되게 반영된다. 검증: 성공은 slot `available`, 수동 복구가 필요한 git 상태는 `dirty`, bridge/provider 오류는 `error`로 수렴한다.
### Epic: [live-gate] End-to-end live smoke gate ### Epic: [live-gate] End-to-end live smoke gate
@ -73,8 +83,8 @@ Gito HTTP webhook wakeup 이후 Core sync pipeline이 Plane Todo projection까
이 마일스톤은 unit/fake seam만으로 닫지 않고 실제 dev 환경에서 한 사이클 증거를 남긴다. 이 마일스톤은 unit/fake seam만으로 닫지 않고 실제 dev 환경에서 한 사이클 증거를 남긴다.
- [ ] [remote-baseline] standard remote runner, Core compose, IOP workspace clone, Gito service, Forgejo webhook, project sync DB 설정을 현재 요청 기준으로 reset/clean/reclone 또는 rebuild해 맞춘다. 검증: dirty runner 상태를 차단 사유로 삼지 않고 최신 local 작업 범위와 같은 테스트 환경으로 만든다. - [ ] [remote-baseline] standard remote runner, Core compose, IOP workspace clone, Gito service, Forgejo webhook, project sync DB 설정을 현재 요청 기준으로 reset/clean/reclone 또는 rebuild해 맞춘다. 검증: dirty runner 상태를 차단 사유로 삼지 않고 최신 local 작업 범위와 같은 테스트 환경으로 만든다.
- [ ] [full-cycle] 새 Plane Backlog 티켓을 AGENT에 assign한 뒤 Core task 생성, IOP authoring, push-only 단계, remote `develop` commit, Gito callback, roadmap sync identity/steps, Plane `Todo` projection까지 확인한다. 검증: `agent-test/live-cycle-smoke.md` 절차에 따라 secret redaction 상태의 live evidence가 남는다. - [ ] [full-cycle] 새 Plane Backlog 티켓을 AGENT에 assign한 authoring roundtrip과, Git에만 있는 active Milestone을 Plane에 생성하는 reconcile roundtrip을 함께 확인한다. 검증: `agent-test/live-cycle-smoke.md` 절차에 따라 secret redaction 상태의 live evidence가 남는다.
- [ ] [regression-doc] 이번 회귀 원인과 방지 기준을 `agent-test/live-cycle-smoke.md` 또는 core README의 비밀 없는 운영 규칙에 반영한다. 검증: provider identity 누락, IOP queue timeout, push-only 단계 누락, Gito delivery-only 성공을 각각 완료 조건으로 오인하지 않는 기준이 문서화된다. - [ ] [regression-doc] 이번 회귀 원인과 방지 기준을 `agent-test/live-cycle-smoke.md` 또는 core README의 비밀 없는 운영 규칙에 반영한다. 검증: filename slug canonical id, archive 제외, provider identity 누락, IOP queue timeout, push-only 단계 누락, Gito delivery-only 성공을 각각 완료 조건으로 오인하지 않는 기준이 문서화된다.
## 완료 리뷰 ## 완료 리뷰
@ -92,7 +102,9 @@ Gito HTTP webhook wakeup 이후 Core sync pipeline이 Plane Todo projection까
- Plane `Todo -> In Progress` 이후 실행 lifecycle - Plane `Todo -> In Progress` 이후 실행 lifecycle
- Milestone Task를 Plane 하위 티켓으로 생성하거나 plan/code-review 루프에 연결하는 작업 - Milestone Task를 Plane 하위 티켓으로 생성하거나 plan/code-review 루프에 연결하는 작업
- Agent-origin Milestone 생성 후 Plane 티켓을 만드는 역방향 생성 sync - Plane 외 provider(Jira 등)에 active Milestone missing-create를 적용하는 작업
- archive Milestone을 Plane과 계속 실시간 동기화하는 작업
- Milestone 파일명 slug rename/reuse를 자동 추론해 병합하는 작업
- IOP 내부 모델 라우팅, queue 구현, RAG, output validation 변경 - IOP 내부 모델 라우팅, queue 구현, RAG, output validation 변경
- 외부 agent 제어 표면 또는 MCP tool policy 확정 - 외부 agent 제어 표면 또는 MCP tool policy 확정
- 사용자 승인 없는 archive 이동 - 사용자 승인 없는 archive 이동
@ -100,15 +112,16 @@ Gito HTTP webhook wakeup 이후 Core sync pipeline이 Plane Todo projection까
## 작업 컨텍스트 ## 작업 컨텍스트
- 관련 경로: `services/core/internal/authoring/`, `services/core/internal/adapters/openai/`, `services/core/internal/scheduler/`, `services/core/internal/gitosync/`, `services/core/internal/roadmapsync/`, `services/core/internal/roadmapsyncpipeline/`, `services/core/internal/projectsync/`, `services/core/internal/workitempipeline/`, `services/core/internal/http/`, `services/core/internal/storage/`, `services/core/README.md`, `agent-test/live-cycle-smoke.md`, `packages/contracts/notes/flutter-core-api-candidates.md` - 관련 경로: `services/core/internal/authoring/`, `services/core/internal/adapters/openai/`, `services/core/internal/scheduler/`, `services/core/internal/gitosync/`, `services/core/internal/roadmapsync/`, `services/core/internal/roadmapsyncpipeline/`, `services/core/internal/projectsync/`, `services/core/internal/workitempipeline/`, `services/core/internal/http/`, `services/core/internal/storage/`, `services/core/README.md`, `agent-test/live-cycle-smoke.md`, `packages/contracts/notes/flutter-core-api-candidates.md`
- 표준선(선택): Plane 티켓 본문은 사용자 요청 원문이고, authoring 프롬프트도 사용자 요청 중심으로 둔다. provider identity, project sync 설정, push 검증, Gito delivery, Plane projection은 사용자 프롬프트가 아니라 Core/runtime의 결정적 책임이다. - 표준선(선택): Plane 티켓 본문은 사용자 요청 원문이고, authoring 프롬프트도 사용자 요청 중심으로 둔다. Git/Plane 동기화의 canonical Milestone id는 파일명 slug 하나이며, path와 Plane work item id는 각각 현재 위치와 provider foreign key로만 다룬다. provider identity, project sync 설정, push 검증, Gito delivery, Plane projection은 사용자 프롬프트가 아니라 Core/runtime의 결정적 책임이다.
- 선행 작업: `Milestone Work Item Creation Sync`, `Plane Work Item Webhook Intake`, `Gito HTTP Webhook Consumer Readiness` - 선행 작업: `Milestone Work Item Creation Sync`, `Plane Work Item Webhook Intake`, `Gito HTTP Webhook Consumer Readiness`
- 후속 작업: `Milestone Execution Lifecycle Sync` - 후속 작업: `Milestone Execution Lifecycle Sync`
- 현재 관찰: - 현재 관찰:
- 최신 dev smoke에서 Plane webhook과 Core task 생성, IOP Responses 호출, Gito delivery 자체는 관찰되었다. - 최신 dev smoke에서 Plane webhook과 Core task 생성, IOP Responses 호출, Gito delivery 자체는 관찰되었다.
- IOP `/v1/responses`는 현재 non-streaming 경로이며, 장시간/queue timeout 상황을 authoring 실패로 조기 확정할 위험이 있다. - IOP `/v1/responses`는 현재 non-streaming 경로이며, 장시간/queue timeout 상황을 authoring 실패로 조기 확정할 위험이 있다.
- 얇은 authoring prompt로 생성된 Milestone에는 provider identity block이 빠질 수 있고, 현재 `gitosync` scanner는 identity 없는 Milestone을 projection 대상으로 삼지 않는다. - 얇은 authoring prompt로 생성된 Milestone에는 provider identity block이 빠질 수 있으므로, identity writer가 push 전 주입하거나 mismatch/missing을 명시 failure로 막는다.
- 첫 authoring 호출만으로는 `develop` push가 보장되지 않으며, push-only workspace agent 요청과 remote `develop` 재검증이 필요하다. - 첫 authoring 호출만으로는 `develop` push가 보장되지 않으며, push-only workspace agent 요청과 remote `develop` 재검증이 필요하다.
- Gito webhook 202만으로는 Plane 동기화 완료가 아니며, `roadmap_sync_identities`, `roadmap_sync_steps`, Plane `Todo` 상태까지 확인해야 한다. - 현재 구현은 `roadmap_milestone_path`와 provider work item id에 강하게 기대고 있어, phase/archive 이동이나 path 변경 시 같은 마일스톤 판별이 깨질 수 있다.
- Gito webhook 202만으로는 Plane 동기화 완료가 아니며, active Milestone slug reconcile, `roadmap_sync_identities`, `roadmap_sync_steps`, Plane `Todo` 상태까지 확인해야 한다.
- 작업 현황 동기화(2026-06-20): - 작업 현황 동기화(2026-06-20):
- SDD는 `agent-roadmap/sdd/agent-ops-mcp-control-plane/plane-origin-authoring-roundtrip-sync/SDD.md`에 작성되었고 `[승인됨]`, `SDD 잠금: 해제`, 사용자 리뷰 없음 상태다. - SDD는 `agent-roadmap/sdd/agent-ops-mcp-control-plane/plane-origin-authoring-roundtrip-sync/SDD.md`에 작성되었고 `[승인됨]`, `SDD 잠금: 해제`, 사용자 리뷰 없음 상태다.
- 마일스톤 구현 잠금은 `해제` 상태이며, 활성 상태는 `[진행중]`이다. - 마일스톤 구현 잠금은 `해제` 상태이며, 활성 상태는 `[진행중]`이다.
@ -119,17 +132,24 @@ Gito HTTP webhook wakeup 이후 Core sync pipeline이 Plane Todo projection까
- `agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/02+01_iop_progress/complete.log` - `agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/02+01_iop_progress/complete.log`
- `agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/03+01,02_push_command/complete.log` - `agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/03+01,02_push_command/complete.log`
- `identity-source` Task는 `BuildAuthoringGenerateInputStrict`/scheduler authoring path가 provider work item id 누락을 명시 오류로 중단하도록 보강했고, 현재 workspace와 standard remote runner에서 `cd services/core && go test ./...` 통과 근거로 완료 처리했다. - `identity-source` Task는 `BuildAuthoringGenerateInputStrict`/scheduler authoring path가 provider work item id 누락을 명시 오류로 중단하도록 보강했고, 현재 workspace와 standard remote runner에서 `cd services/core && go test ./...` 통과 근거로 완료 처리했다.
- `identity-write` Task는 writer/workspace metadata 필수화, 기존 provider identity mismatch 차단, identity write failure/missing category와 slot error mapping 보강 근거로 완료 처리했다.
- 완료 근거:
- `agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/04+03_identity_write/complete.log`
- `develop-match` Task는 `roadmapsync.MatchDevelopMilestone`/`roadmapsyncpipeline.SyncCreation`의 not-ready gate 테스트와 현재 workspace/standard remote runner `cd services/core && go test ./...` 통과 근거로 완료 처리했다. - `develop-match` Task는 `roadmapsync.MatchDevelopMilestone`/`roadmapsyncpipeline.SyncCreation`의 not-ready gate 테스트와 현재 workspace/standard remote runner `cd services/core && go test ./...` 통과 근거로 완료 처리했다.
- `idempotency-retry` Task는 `roadmapsync.ReconcileCreationCycle`, `roadmap_sync_identities`, `roadmap_sync_steps`, `roadmapsyncpipeline`, `gitosync` dedup 테스트와 현재 workspace/standard remote runner `cd services/core && go test ./...` 통과 근거로 완료 처리했다. - `idempotency-retry` Task는 `roadmapsync.ReconcileCreationCycle`, `roadmap_sync_identities`, `roadmap_sync_steps`, `roadmapsyncpipeline`, `gitosync` dedup 테스트와 현재 workspace/standard remote runner `cd services/core && go test ./...` 통과 근거로 완료 처리했다.
- 현재 활성 `agent-task` plan/review/user-review/complete 파일은 없다. - 현재 활성 `agent-task` plan/review/user-review/complete 파일은 없다.
- 다음 실구현은 `identity-write` Task를 `agent-task/m-plane-origin-authoring-roundtrip-sync/` 아래 milestone 기반 plan으로 진행한다. - 남은 실구현/검증 후보는 `canonical-id`, `active-index`, `plane-missing-create`, `slug-reconcile`, `gito-delivery`, `scan-enqueue`, `plane-todo`, `slot-finalize`, `remote-baseline`, `full-cycle`, `regression-doc`다.
- 핸즈오프(2026-06-20): - 핸즈오프(2026-06-20):
- 현재 타겟은 이 마일스톤이다. 로컬 `agent-roadmap/current.md`의 활성 Milestone은 `Plane-Origin Authoring Roundtrip Sync` 하나로 정리되어 있다. - 현재 타겟은 이 마일스톤이다. 로컬 `agent-roadmap/current.md`의 활성 Milestone은 `Plane-Origin Authoring Roundtrip Sync` 하나로 정리되어 있다.
- SDD gate는 작성과 해제까지 완료되어 구현 plan 생성이 가능하다. - SDD gate는 작성과 해제까지 완료되어 구현 plan 생성이 가능하다.
- 다음 세션은 AGENTS 규칙을 다시 읽은 뒤 이 마일스톤, SDD, `agent-test/live-cycle-smoke.md`, `services/core/internal/authoring/`, `services/core/internal/adapters/openai/`, `services/core/internal/gitosync/`, `services/core/internal/roadmapsync/`, `services/core/internal/roadmapsyncpipeline/`를 우선 확인한다. - 다음 세션은 AGENTS 규칙을 다시 읽은 뒤 이 마일스톤, SDD, `agent-test/live-cycle-smoke.md`, `services/core/internal/authoring/`, `services/core/internal/adapters/openai/`, `services/core/internal/gitosync/`, `services/core/internal/roadmapsync/`, `services/core/internal/roadmapsyncpipeline/`를 우선 확인한다.
- 중요한 방향: Plane 티켓 본문과 authoring prompt는 사용자 요청 원문 중심으로 얇게 유지한다. provider identity, project sync 설정, push-only 단계, Gito callback, Plane Todo projection은 사용자 프롬프트가 아니라 Core/runtime의 결정적 책임으로 설계한다. - 중요한 방향: Plane 티켓 본문과 authoring prompt는 사용자 요청 원문 중심으로 얇게 유지한다. provider identity, project sync 설정, push-only 단계, Gito callback, Plane Todo projection은 사용자 프롬프트가 아니라 Core/runtime의 결정적 책임으로 설계한다.
- 현재 알려진 회귀: IOP authoring은 milestone 파일을 만들 수 있지만 remote `develop` push가 별도 조치 없이는 보장되지 않고, 생성된 Milestone에 `## Provider identity` block이 없으면 `gitosync` scanner가 projection 대상으로 삼지 않는다. - 현재 알려진 회귀: IOP authoring은 milestone 파일을 만들 수 있지만 remote `develop` push가 별도 조치 없이는 보장되지 않는다. provider identity block 누락은 identity writer가 push 전 주입하거나 mismatch/missing을 명시 failure로 막도록 보강되었고, 남은 검증은 Gito delivery -> active slug reconcile -> Plane 생성/갱신 -> Plane Todo projection full-cycle이다.
- 검증 기준: Gito webhook 202나 remote `develop` commit만으로 완료로 보지 않는다. `roadmap_sync_identities`, `roadmap_sync_steps`의 단계 기록과 Plane `Todo` 상태까지 확인해야 full-cycle 완료다. - 검증 기준: Gito webhook 202나 remote `develop` commit만으로 완료로 보지 않는다. active Milestone filename slug 기준 reconcile, `roadmap_sync_identities`, `roadmap_sync_steps`의 단계 기록과 Plane `Todo` 상태까지 확인해야 full-cycle 완료다.
- 원격 테스트 runner는 disposable mirror로 취급한다. dirty 상태를 이유로 멈추지 말고 reset/clean/reclone 또는 파일 동기화 후 현재 요청 기준 상태로 맞춰 테스트한다. - 원격 테스트 runner는 disposable mirror로 취급한다. dirty 상태를 이유로 멈추지 말고 reset/clean/reclone 또는 파일 동기화 후 현재 요청 기준 상태로 맞춰 테스트한다.
- 이 workspace에는 이미 여러 변경이 섞여 있으므로 다음 세션은 `git status --short`와 대상 파일 diff를 먼저 보고, 관련 없는 사용자/이전 변경을 되돌리지 않는다. - 이 workspace에는 이미 여러 변경이 섞여 있으므로 다음 세션은 `git status --short`와 대상 파일 diff를 먼저 보고, 관련 없는 사용자/이전 변경을 되돌리지 않는다.
- 작업 현황 동기화(2026-06-21):
- `active-index` Task는 `services/core/internal/gitosync/scanner.go`가 Gito wakeup 이후 `git diff`가 아니라 `git ls-tree -r --name-only <revision> -- agent-roadmap/phase agent-roadmap/archive`로 remote `develop` revision의 active Milestone 전체를 인덱싱하고, archive Milestone은 `ArchiveCandidates` slug lookup 후보로만 보존하도록 보강해 완료 처리했다.
- 검증 근거: `cd services/core && go test ./internal/gitosync`, `cd services/core && go test ./...`, `git diff --check` 통과.
- `canonical-id`, `plane-missing-create`, `slug-reconcile`은 큰 작업으로 분류하고 split plan을 작성했다: `agent-task/m-plane-origin-authoring-roundtrip-sync/05_canonical_id/PLAN-cloud-G06.md`, `agent-task/m-plane-origin-authoring-roundtrip-sync/06+05_plane_missing_create/PLAN-cloud-G06.md`, `agent-task/m-plane-origin-authoring-roundtrip-sync/07+05,06_slug_reconcile/PLAN-cloud-G05.md`.
- 확인 필요: 없음 - 확인 필요: 없음

View file

@ -18,11 +18,13 @@
## 문제 / 비목표 ## 문제 / 비목표
- 문제: Plane `Backlog + AGENT assignee`에서 시작한 Milestone authoring은 사용자 prompt, IOP workspace 실행, git push, Gito webhook, provider identity, Plane projection을 한 사이클로 묶기 때문에 source of truth와 terminal 조건이 분산될 수 있다. - 문제: Plane `Backlog + AGENT assignee`에서 시작한 Milestone authoring은 사용자 prompt, IOP workspace 실행, git push, Gito webhook, provider identity, Plane projection을 한 사이클로 묶기 때문에 source of truth와 terminal 조건이 분산될 수 있다. 또한 Git/Plane 동기화 identity가 path와 provider work item id에 나뉘면 phase/archive 이동 또는 Plane item 생성 전 상태에서 같은 Milestone 판별이 깨질 수 있으므로, Milestone 파일명 slug 하나를 canonical id로 삼아 active roadmap 전체를 reconcile해야 한다.
- 비목표: - 비목표:
- Plane `Todo -> In Progress` 이후 실행 lifecycle은 다루지 않는다. - Plane `Todo -> In Progress` 이후 실행 lifecycle은 다루지 않는다.
- Milestone Task를 Plane 하위 티켓으로 생성하거나 plan/code-review 루프와 연결하지 않는다. - Milestone Task를 Plane 하위 티켓으로 생성하거나 plan/code-review 루프와 연결하지 않는다.
- Agent-origin Milestone 생성 뒤 Plane 티켓을 만드는 역방향 생성 sync는 다루지 않는다. - Plane 외 provider(Jira 등)에 active Milestone missing-create를 적용하지 않는다.
- archive Milestone을 Plane과 계속 실시간 동기화하는 작업은 다루지 않는다.
- Milestone 파일명 slug rename/reuse를 자동 추론해 병합하는 작업은 다루지 않는다.
- IOP 내부 모델 라우팅, queue 구현, RAG, output validation은 다루지 않는다. - IOP 내부 모델 라우팅, queue 구현, RAG, output validation은 다루지 않는다.
- 외부 agent 제어 표면 또는 MCP tool policy는 확정하지 않는다. - 외부 agent 제어 표면 또는 MCP tool policy는 확정하지 않는다.
@ -30,9 +32,10 @@
| 영역 | 기준 | 메모 | | 영역 | 기준 | 메모 |
|------|------|------| |------|------|------|
| Roadmap | `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-origin-authoring-roundtrip-sync.md` | Milestone 목표, 기능 Task, 범위 제외, 완료 리뷰의 기준이다. | | Roadmap | `agent-roadmap/phase/*/milestones/<milestone-id>.md` | 파일명 slug `<milestone-id>`가 Git/Plane 동기화의 단일 canonical Milestone id다. path는 active/archive 위치 속성이다. |
| Code | `services/core/internal/authoring/`, `services/core/internal/scheduler/`, `services/core/internal/gitosync/`, `services/core/internal/roadmapsync/`, `services/core/internal/roadmapsyncpipeline/`, `services/core/internal/projectsync/`, `services/core/internal/workitempipeline/` | authoring input, state metadata, Gito scan, identity match, Plane projection, slot finalization의 구현 기준이다. | | Code | `services/core/internal/authoring/`, `services/core/internal/scheduler/`, `services/core/internal/gitosync/`, `services/core/internal/roadmapsync/`, `services/core/internal/roadmapsyncpipeline/`, `services/core/internal/projectsync/`, `services/core/internal/workitempipeline/` | authoring input, state metadata, Gito scan, active Milestone index, slug identity match, Plane creation/projection, slot finalization의 구현 기준이다. |
| External Provider | Plane, IOP OpenAI-compatible Responses, Forgejo, Gito | Plane work item id/state, IOP response lifecycle, remote `develop` revision, signed Gito delivery가 외부 관찰 기준이다. | | Ledger | `roadmap_sync_identities`, `roadmap_sync_steps` | `milestone_id`를 1차 키로 두고 path, provider work item id, revision, step completion은 속성/foreign key로 기록한다. |
| External Provider | Plane, IOP OpenAI-compatible Responses, Forgejo, Gito | Plane work item id/state, IOP response lifecycle, remote `develop` revision, signed Gito delivery가 외부 관찰 기준이다. Plane work item id는 canonical id가 아니라 provider foreign key다. |
| User Decision | 없음 | 현재 Milestone 범위와 실패 처리 기준은 기존 roadmap/domain rule 표준선으로 확정 가능하다. | | User Decision | 없음 | 현재 Milestone 범위와 실패 처리 기준은 기존 roadmap/domain rule 표준선으로 확정 가능하다. |
## State Machine ## State Machine
@ -45,11 +48,14 @@
| `authoring_generated` | authoring 결과가 workspace checkout에 반영된다. | `push_requested`, `authoring_failed` | model response, workspace dirty state | | `authoring_generated` | authoring 결과가 workspace checkout에 반영된다. | `push_requested`, `authoring_failed` | model response, workspace dirty state |
| `push_requested` | Core/runtime이 push-only workspace agent 요청을 보낸다. | `develop_pushed`, `push_failed`, `authoring_waiting` | push-only task metadata, remote `develop` verification | | `push_requested` | Core/runtime이 push-only workspace agent 요청을 보낸다. | `develop_pushed`, `push_failed`, `authoring_waiting` | push-only task metadata, remote `develop` verification |
| `develop_pushed` | remote `develop` revision이 변경된다. | `gito_delivered`, `scan_waiting` | Forgejo revision, Gito delivery | | `develop_pushed` | remote `develop` revision이 변경된다. | `gito_delivered`, `scan_waiting` | Forgejo revision, Gito delivery |
| `gito_delivered` | Gito signed HTTP webhook이 Core에 도착한다. | `develop_scan_enqueued`, `delivery_duplicate`, `delivery_failed` | signed delivery 202, Core gito webhook log | | `gito_delivered` | Gito signed HTTP webhook이 Core에 도착한다. | `active_reconcile_requested`, `delivery_duplicate`, `delivery_failed` | signed delivery 202, Core gito webhook log |
| `develop_scan_enqueued` | Core가 local develop checkout을 fetch/scan하고 creation sync job을 enqueue한다. | `develop_matched`, `identity_missing`, `identity_mismatch`, `milestone_path_missing` | `gitosync` scan, `roadmap_sync_identities`, job enqueue | | `active_reconcile_requested` | Core가 local develop checkout을 fetch한다. | `active_milestone_indexed`, `scan_waiting`, `scan_failed` | `gitosync` fetch/revision verification |
| `develop_matched` | pushed Milestone path와 provider identity가 원래 Plane 티켓과 일치한다. | `plane_projected`, `projection_failed` | `roadmap_sync_steps`, provider identity ledger | | `active_milestone_indexed` | Core가 `agent-roadmap/phase/*/milestones/*.md`를 스캔하고 archive를 제외한다. | `milestone_id_matched`, `plane_missing_detected`, `slug_conflict` | active Milestone index, filename slug |
| `plane_projected` | Plane 본문/제목/comment/status projection이 완료된다. | `slot_available` | Plane API/UI, `roadmap_sync_steps` | | `plane_missing_detected` | active Milestone slug가 ledger와 Plane에서 발견되지 않는다. | `plane_item_created`, `creation_failed` | `roadmap_sync_identities`, Plane create API |
| `slot_available` | 성공 terminal 상태를 기록한다. | 없음 | workspace slot state `available`, task metadata | | `plane_item_created` | Plane work item 생성이 성공한다. | `milestone_id_matched` | Plane work item id, slug ledger |
| `milestone_id_matched` | active Milestone slug가 ledger 또는 생성된 Plane item과 연결된다. | `plane_projected`, `projection_failed` | `roadmap_sync_steps`, slug identity ledger |
| `plane_projected` | Plane 본문/제목/comment/status projection이 완료된다. | `slot_available`, `reconcile_complete` | Plane API/UI, `roadmap_sync_steps` |
| `slot_available` | Plane-origin authoring task의 성공 terminal 상태를 기록한다. | 없음 | workspace slot state `available`, task metadata |
| `authoring_failed` | timeout, queue timeout, bridge failure, identity failure 등 terminal failure가 확정된다. | `slot_error`, `slot_dirty` | failure category, slot finalizer | | `authoring_failed` | timeout, queue timeout, bridge failure, identity failure 등 terminal failure가 확정된다. | `slot_error`, `slot_dirty` | failure category, slot finalizer |
| `slot_dirty` | 수동 복구가 필요한 git dirty 또는 push failure 상태다. | 없음 | slot state `dirty`, task metadata | | `slot_dirty` | 수동 복구가 필요한 git dirty 또는 push failure 상태다. | 없음 | slot state `dirty`, task metadata |
| `slot_error` | bridge/provider 오류 또는 복구 불가 failure 상태다. | 없음 | slot state `error`, task metadata | | `slot_error` | bridge/provider 오류 또는 복구 불가 failure 상태다. | 없음 | slot state `error`, task metadata |
@ -58,6 +64,7 @@
- 계약 원문: 없음 - 계약 원문: 없음
- 입력: - 입력:
- Active roadmap Milestone: repo-relative path, filename slug `milestone_id`, Markdown body, status, archive 여부.
- Plane work item: title, description/comment body, assignee, state, provider work item id, external URL. - Plane work item: title, description/comment body, assignee, state, provider work item id, external URL.
- Core task external ref: provider, external id, external metadata, payload `work_item`. - Core task external ref: provider, external id, external metadata, payload `work_item`.
- checkout metadata: slot id, slot path, source branch, project sync context. - checkout metadata: slot id, slot path, source branch, project sync context.
@ -65,14 +72,18 @@
- Gito webhook: signed push delivery, repository/ref/revision metadata. - Gito webhook: signed push delivery, repository/ref/revision metadata.
- 출력: - 출력:
- workspace changes on local checkout and remote `develop` commit. - workspace changes on local checkout and remote `develop` commit.
- `roadmap_sync_identities` and `roadmap_sync_steps` records for identity, revision, projection, retry/idempotency state. - `roadmap_sync_identities` and `roadmap_sync_steps` records keyed by `milestone_id`, with path, provider work item id, revision, projection, retry/idempotency state as attributes.
- Plane work item creation for active Milestones that do not have a mapped Plane item.
- Plane original comment preservation, body/title projection, `Todo` state move. - Plane original comment preservation, body/title projection, `Todo` state move.
- task metadata and workspace slot terminal state. - task metadata and workspace slot terminal state.
- 금지: - 금지:
- `roadmap_milestone_path`나 Plane work item id를 canonical Milestone identity로 사용하지 않는다.
- `agent-roadmap/archive/**` 문서를 active sync 생성/갱신 대상으로 삼지 않는다.
- 같은 filename slug를 자동으로 두 Milestone에 병합하거나, rename/reuse를 추정하지 않는다.
- Plane/provider identity나 sync 계약 문구를 기본 authoring 사용자 prompt에 섞지 않는다. - Plane/provider identity나 sync 계약 문구를 기본 authoring 사용자 prompt에 섞지 않는다.
- 첫 authoring 응답 성공만으로 Plane projection 또는 task completion을 단정하지 않는다. - 첫 authoring 응답 성공만으로 Plane projection 또는 task completion을 단정하지 않는다.
- Gito webhook 202 또는 remote commit만으로 full-cycle 완료를 단정하지 않는다. - Gito webhook 202 또는 remote commit만으로 full-cycle 완료를 단정하지 않는다.
- identity missing, identity mismatch, milestone path missing, push failure를 Plane `Todo` projection으로 넘기지 않는다. - identity missing, identity mismatch, milestone id conflict, push failure를 Plane `Todo` projection으로 넘기지 않는다.
- tracked docs, roadmap, task logs, 최종 응답에 secret 원문을 기록하지 않는다. - tracked docs, roadmap, task logs, 최종 응답에 secret 원문을 기록하지 않는다.
## Acceptance Scenarios ## Acceptance Scenarios
@ -87,13 +98,17 @@
| S06 | `identity-write` | generated Milestone에 provider identity block이 없다. | Gito callback 이후 scan/match가 실행된다. | Core/runtime 주입 또는 sync ledger로 match 가능 상태가 되거나 명시 failure로 수렴한다. | | S06 | `identity-write` | generated Milestone에 provider identity block이 없다. | Gito callback 이후 scan/match가 실행된다. | Core/runtime 주입 또는 sync ledger로 match 가능 상태가 되거나 명시 failure로 수렴한다. |
| S07 | `develop-match` | remote `develop` revision에 Milestone path 변경 후보가 있다. | Core가 provider identity와 원래 Plane 티켓을 비교한다. | path와 identity가 모두 일치할 때만 Plane projection을 실행한다. | | S07 | `develop-match` | remote `develop` revision에 Milestone path 변경 후보가 있다. | Core가 provider identity와 원래 Plane 티켓을 비교한다. | path와 identity가 모두 일치할 때만 Plane projection을 실행한다. |
| S08 | `idempotency-retry` | 같은 Plane 티켓, Milestone path, Gito revision이 재처리된다. | creation sync가 재실행된다. | 중복 Milestone/Plane mutation 없이 남은 단계만 재시도하고 충돌은 중단한다. | | S08 | `idempotency-retry` | 같은 Plane 티켓, Milestone path, Gito revision이 재처리된다. | creation sync가 재실행된다. | 중복 Milestone/Plane mutation 없이 남은 단계만 재시도하고 충돌은 중단한다. |
| S09 | `gito-delivery` | Forgejo push가 Gito subscription 대상 branch에 발생한다. | Gito가 Core signed HTTP webhook을 보낸다. | Core는 delivery를 202로 받고 duplicate 처리를 secret 없이 확인할 수 있다. | | S09 | `canonical-id` | active Milestone 파일 경로가 `agent-roadmap/phase/<phase>/milestones/<milestone-id>.md`다. | Core가 roadmap sync identity를 만든다. | `<milestone-id>` filename slug 하나가 canonical id가 되고 path/provider work item id는 속성으로 기록된다. |
| S10 | `scan-enqueue` | Gito wakeup 후 local develop checkout이 fetch/scan된다. | provider identity가 있는 Milestone 변경을 발견한다. | 정확히 하나의 `roadmap_creation_sync` job을 enqueue하고 identity 없는 변경은 명시 사유를 남긴다. | | S10 | `active-index` | remote `develop`에 active와 archive Milestone이 함께 있다. | Gito wakeup 후 Core가 Milestone index를 스캔한다. | `agent-roadmap/phase/*/milestones/*.md`만 sync 후보가 되고 `agent-roadmap/archive/**`는 제외되며, archive 문서는 slug lookup 후보로만 남는다. |
| S11 | `plane-todo` | develop match가 완료된 creation sync job이 있다. | Plane projection을 실행한다. | 원문 댓글 보존, 본문 projection, `[milestone-id]` 제목 갱신, `Todo` 이동이 기록된다. | | S11 | `plane-missing-create` | active Milestone slug가 ledger와 Plane에서 발견되지 않는다. | reconcile job이 실행된다. | Plane work item을 생성하고 slug ledger 및 provider identity marker를 기록하며 중복 생성하지 않는다. |
| S12 | `slot-finalize` | success/failure/timeout/push failed/identity missing 결과가 확정된다. | slot finalizer가 task metadata와 slot state를 갱신한다. | 성공은 `available`, 수동 git 복구는 `dirty`, bridge/provider 오류는 `error`로 수렴한다. | | S12 | `slug-reconcile` | 같은 Milestone slug가 같은 revision 또는 재시도에서 다시 처리된다. | reconcile이 identity ledger와 completed step을 확인한다. | 중복 Plane item 없이 남은 단계만 재개하고, slug 충돌/재사용/rename 의심은 명시 failure로 멈춘다. |
| S13 | `remote-baseline` | standard remote runner와 dev services 상태가 stale/dirty일 수 있다. | reset/clean/reclone 또는 rebuild로 테스트 기준을 맞춘다. | dirty runner 상태가 차단 사유가 되지 않고 현재 요청 범위와 같은 검증 환경이 된다. | | S13 | `gito-delivery` | Forgejo push가 Gito subscription 대상 branch에 발생한다. | Gito가 Core signed HTTP webhook을 보낸다. | Core는 delivery를 202로 받고 duplicate 처리를 secret 없이 확인할 수 있다. |
| S14 | `full-cycle` | 새 Plane Backlog 티켓이 AGENT에 assign된다. | Core task, IOP authoring, push-only, Gito callback, sync projection까지 실행한다. | Plane `Todo` projection과 `roadmap_sync_steps` full-cycle evidence가 남는다. | | S14 | `scan-enqueue` | Gito wakeup 후 local develop checkout이 fetch된다. | Core가 active Milestone index를 만들고 slug 단위 reconcile job을 enqueue한다. | 변경 파일 힌트와 무관하게 active Milestone 전체가 reconcile 후보가 되고 archive Milestone은 제외된다. |
| S15 | `regression-doc` | 회귀 원인과 완료 오인 조건이 확인된다. | 운영/테스트 문서를 갱신한다. | identity 누락, IOP queue timeout, push-only 누락, delivery-only 성공을 완료로 오인하지 않는 기준이 문서화된다. | | S15 | `plane-todo` | slug match 또는 Plane 생성이 완료된 reconcile job이 있다. | Plane projection을 실행한다. | 원문 댓글 보존, 본문 projection, `[milestone-id]` 제목 갱신, `Todo` 이동이 기록된다. |
| S16 | `slot-finalize` | success/failure/timeout/push failed/identity missing 결과가 확정된다. | slot finalizer가 task metadata와 slot state를 갱신한다. | 성공은 `available`, 수동 git 복구는 `dirty`, bridge/provider 오류는 `error`로 수렴한다. |
| S17 | `remote-baseline` | standard remote runner와 dev services 상태가 stale/dirty일 수 있다. | reset/clean/reclone 또는 rebuild로 테스트 기준을 맞춘다. | dirty runner 상태가 차단 사유가 되지 않고 현재 요청 범위와 같은 검증 환경이 된다. |
| S18 | `full-cycle` | 새 Plane Backlog 티켓과 Git에만 존재하는 active Milestone이 있다. | Core task, IOP authoring, push-only, Gito callback, active reconcile, Plane projection까지 실행한다. | Plane `Todo` projection과 active Milestone missing-create evidence가 함께 남는다. |
| S19 | `regression-doc` | 회귀 원인과 완료 오인 조건이 확인된다. | 운영/테스트 문서를 갱신한다. | filename slug canonical id, archive 제외, identity 누락, IOP queue timeout, push-only 누락, delivery-only 성공을 완료로 오인하지 않는 기준이 문서화된다. |
## Evidence Map ## Evidence Map
@ -107,13 +122,17 @@
| S06 | gitosync/roadmapsync test or smoke showing identity block absence handled by deterministic injection/ledger or explicit failure | `agent-task/m-plane-origin-authoring-roundtrip-sync/...` | `identity-write`: identity-less generated Milestone does not silently drop | | S06 | gitosync/roadmapsync test or smoke showing identity block absence handled by deterministic injection/ledger or explicit failure | `agent-task/m-plane-origin-authoring-roundtrip-sync/...` | `identity-write`: identity-less generated Milestone does not silently drop |
| S07 | develop match test covering dirty slot, push failure, mismatch, missing path, and successful match | `agent-task/m-plane-origin-authoring-roundtrip-sync/...` | `develop-match`: only path+identity match triggers Plane projection | | S07 | develop match test covering dirty slot, push failure, mismatch, missing path, and successful match | `agent-task/m-plane-origin-authoring-roundtrip-sync/...` | `develop-match`: only path+identity match triggers Plane projection |
| S08 | retry/idempotency tests for repeated Plane ticket, Milestone path, Gito revision, and partial failure recovery | `agent-task/m-plane-origin-authoring-roundtrip-sync/...` | `idempotency-retry`: duplicate mutation prevention and retry/abort distinction recorded | | S08 | retry/idempotency tests for repeated Plane ticket, Milestone path, Gito revision, and partial failure recovery | `agent-task/m-plane-origin-authoring-roundtrip-sync/...` | `idempotency-retry`: duplicate mutation prevention and retry/abort distinction recorded |
| S09 | secret-redacted live evidence for Forgejo -> Gito -> Core signed delivery and duplicate handling | `agent-task/m-plane-origin-authoring-roundtrip-sync/...` | `gito-delivery`: signed delivery 202, Core log, duplicate result recorded without secret | | S09 | storage/parser/matcher tests showing filename slug is canonical and path/provider ids are attributes | `agent-task/m-plane-origin-authoring-roundtrip-sync/...` | `canonical-id`: filename slug identity and path/provider attribute evidence recorded |
| S10 | gitosync/roadmapsyncpipeline test or smoke proving one creation sync job enqueue and identity-missing reason | `agent-task/m-plane-origin-authoring-roundtrip-sync/...` | `scan-enqueue`: scan-to-enqueue and identity-missing evidence recorded | | S10 | active index scan tests covering active Phase Milestones, archive exclusion, and slug lookup candidate behavior | `agent-task/m-plane-origin-authoring-roundtrip-sync/...` | `active-index`: active-only sync candidate evidence recorded |
| S11 | Plane UI/API plus `roadmap_sync_steps` evidence for comment/body/title/Todo projection | `agent-task/m-plane-origin-authoring-roundtrip-sync/...` | `plane-todo`: original comment, body update, title update, Todo move recorded | | S11 | Plane adapter/reconcile tests or smoke proving missing active Milestone creates one Plane item and records ledger/marker | `agent-task/m-plane-origin-authoring-roundtrip-sync/...` | `plane-missing-create`: missing active Milestone creation evidence recorded |
| S12 | scheduler/projectsync finalizer tests or smoke for `available`, `dirty`, `error` terminal mapping | `agent-task/m-plane-origin-authoring-roundtrip-sync/...` | `slot-finalize`: terminal task metadata and slot state evidence recorded | | S12 | retry/idempotency tests for same slug reprocess, partial failure resume, and slug conflict/reuse failure | `agent-task/m-plane-origin-authoring-roundtrip-sync/...` | `slug-reconcile`: duplicate prevention and conflict failure evidence recorded |
| S13 | standard remote runner setup log with branch/revision and clean/reclone/rebuild actions as needed | `agent-task/m-plane-origin-authoring-roundtrip-sync/...` | `remote-baseline`: runner baseline evidence recorded | | S13 | secret-redacted live evidence for Forgejo -> Gito -> Core signed delivery and duplicate handling | `agent-task/m-plane-origin-authoring-roundtrip-sync/...` | `gito-delivery`: signed delivery 202, Core log, duplicate result recorded without secret |
| S14 | `agent-test/live-cycle-smoke.md` run evidence with secret redaction from Plane Backlog to Plane Todo | `agent-task/m-plane-origin-authoring-roundtrip-sync/...` | `full-cycle`: full-cycle live evidence recorded | | S14 | gitosync/roadmapsyncpipeline test or smoke proving active index scan-to-reconcile enqueue and archive exclusion | `agent-task/m-plane-origin-authoring-roundtrip-sync/...` | `scan-enqueue`: active reconcile enqueue evidence recorded |
| S15 | doc diff in `agent-test/live-cycle-smoke.md` or core README covering regression prevention criteria | `agent-task/m-plane-origin-authoring-roundtrip-sync/...` | `regression-doc`: regression criteria documented | | S15 | Plane UI/API plus `roadmap_sync_steps` evidence for comment/body/title/Todo projection keyed by milestone id | `agent-task/m-plane-origin-authoring-roundtrip-sync/...` | `plane-todo`: original comment, body update, title update, Todo move recorded |
| S16 | scheduler/projectsync finalizer tests or smoke for `available`, `dirty`, `error` terminal mapping | `agent-task/m-plane-origin-authoring-roundtrip-sync/...` | `slot-finalize`: terminal task metadata and slot state evidence recorded |
| S17 | standard remote runner setup log with branch/revision and clean/reclone/rebuild actions as needed | `agent-task/m-plane-origin-authoring-roundtrip-sync/...` | `remote-baseline`: runner baseline evidence recorded |
| S18 | `agent-test/live-cycle-smoke.md` run evidence with secret redaction from Plane Backlog to Plane Todo and active missing-create | `agent-task/m-plane-origin-authoring-roundtrip-sync/...` | `full-cycle`: authoring roundtrip and active reconcile evidence recorded |
| S19 | doc diff in `agent-test/live-cycle-smoke.md` or core README covering regression prevention criteria | `agent-task/m-plane-origin-authoring-roundtrip-sync/...` | `regression-doc`: regression criteria documented |
## Cross-repo Dependencies ## Cross-repo Dependencies
@ -132,5 +151,5 @@
## 작업 컨텍스트 ## 작업 컨텍스트
- 표준선: Plane 티켓 본문과 authoring prompt는 사용자 요청 원문 중심으로 유지하고, provider identity, project sync 설정, push 검증, Gito delivery, Plane projection은 Core/runtime의 결정적 책임으로 둔다. - 표준선: Plane 티켓 본문과 authoring prompt는 사용자 요청 원문 중심으로 유지하고, Git/Plane 동기화의 canonical Milestone id는 파일명 slug 하나로 둔다. path와 Plane work item id는 각각 현재 위치와 provider foreign key이며, provider identity, project sync 설정, push 검증, Gito delivery, Plane creation/projection은 Core/runtime의 결정적 책임으로 둔다.
- 후속 SDD: 없음 - 후속 SDD: 없음

View file

@ -0,0 +1,179 @@
<!-- task=m-plane-origin-authoring-roundtrip-sync/05_canonical_id plan=0 tag=REFACTOR -->
# Code Review Reference - REFACTOR
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a selected SDD decision or selected Milestone `구현 잠금 > 결정 필요` item, fill `사용자 리뷰 요청` with linked evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Environment/secret/service blockers, generic scope changes, repeated failures, and evidence gaps that a follow-up agent can close are normal follow-up issues, not user-review blockers by themselves.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record only SDD/Milestone lock decisions in `사용자 리뷰 요청` and stop for code-review.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
> Follow the ownership table at the bottom of this file for which sections you own.
## 개요
date=2026-06-21
task=m-plane-origin-authoring-roundtrip-sync/05_canonical_id, plan=0, tag=REFACTOR
## Roadmap Targets
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-origin-authoring-roundtrip-sync.md`
- Task ids:
- `canonical-id`: `agent-roadmap/phase/*/milestones/<milestone-id>.md`의 `<milestone-id>`를 단일 canonical sync id로 정의하고, `roadmap_sync_identities`/parser/matcher/projection이 path 대신 이 id를 1차 판별값으로 사용한다.
- Completion mode: check-on-pass
## Spec Targets
- SDD: `agent-roadmap/sdd/agent-ops-mcp-control-plane/plane-origin-authoring-roundtrip-sync/SDD.md`
- Acceptance scenarios:
- `S09`: task=`canonical-id`; evidence=`storage/parser/matcher tests showing filename slug is canonical and path/provider ids are attributes`
- Completion mode: spec-check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-cloud-G06.md` -> `code_review_cloud_G06_N.log`, `PLAN-cloud-G06.md` -> `plan_cloud_G06_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-plane-origin-authoring-roundtrip-sync/05_canonical_id/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
4. PASS이고 task group이 `m-plane-origin-authoring-roundtrip-sync`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REFACTOR-1] Canonical Milestone ID Foundation | [x] |
| [REFACTOR-2] Storage Key Migration And Pipeline Lookup | [x] |
## 구현 체크리스트
- [x] `roadmapsync.Identity`에 filename slug canonical id 정규화 경로를 추가하고 path/provider work item id는 속성으로 비교되도록 matcher/reconcile tests를 갱신한다.
- [x] `roadmap_sync_identities` migration/query/generated/storage를 canonical id lookup/upsert 중심으로 바꾸고 path 변경과 provider work item id 변경 테스트를 추가한다.
- [x] `roadmapsyncpipeline`이 provider work item lookup 전에 canonical id lookup/upsert를 사용하도록 조정하고 existing row 변환을 갱신한다.
- [x] `cd services/core && sqlc generate`를 실행해 generated DB code를 갱신한다. `sqlc`가 없으면 `command -v sqlc` 결과를 기록하고 구현을 차단한다. (로컬에 go install로 sqlc v1.26.0을 설치하여 실행)
- [x] `cd services/core && go test ./internal/roadmapsync ./internal/storage ./internal/roadmapsyncpipeline`와 `cd services/core && go test ./...`를 통과시킨다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G06_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_cloud_G06_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/m-plane-origin-authoring-roundtrip-sync/05_canonical_id/`를 `agent-task/archive/YYYY/MM/m-plane-origin-authoring-roundtrip-sync/05_canonical_id/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-plane-origin-authoring-roundtrip-sync`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-plane-origin-authoring-roundtrip-sync/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G06.md`와 `CODE_REVIEW-cloud-G06.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
- [ ] USER_REVIEW가 연결된 SDD/Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
- `TestSyncCreationRetrySkipsCompletedSteps` 테스트 케이스에서 trigger revision 정보가 store.identity와 일치하지 않아 conditional update(upsert)가 트리거되던 이슈가 있어, 테스트 셋업에 revisions 값을 채워주도록 수정했습니다.
## 주요 설계 결정
- `roadmapsync.Identity.MilestoneID()` helper를 추가하여 `RoadmapItemID`가 비어있는 경우 `RoadmapMilestonePath`의 파일명 slug를 추출해 canonical id로 사용하도록 하였습니다.
- `loadOrUpsertIdentityAndSteps` 함수에서, canonical ID lookup이 성공한 경우 trigger 정보(path, provider, revisions 등)에 변경이 발생했을 때만 `UpsertRoadmapSyncIdentity`를 호출해 불필요한 write를 방지하고, update가 필요한 속성만 갱신되도록 개선하였습니다.
## 사용자 리뷰 요청
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- filename slug가 canonical key이고 path/provider work item id가 속성으로만 쓰이는지 확인한다.
- SQL migration/query/generated code가 서로 일치하는지 확인한다.
- provider work item 재생성이나 phase 이동이 conflict가 아니라 same slug update로 수렴하는지 확인한다.
## 검증 결과
### REFACTOR-1 중간 검증
```text
$ cd services/core && go test ./internal/roadmapsync
ok github.com/nomadcode/nomadcode-core/internal/roadmapsync 0.003s
```
### REFACTOR-2 중간 검증
```text
$ cd services/core && /config/go/bin/sqlc generate && go test ./internal/storage ./internal/roadmapsyncpipeline
ok github.com/nomadcode/nomadcode-core/internal/storage (cached)
ok github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline 0.003s
```
### 최종 검증
```text
$ command -v /config/go/bin/sqlc
/config/go/bin/sqlc
$ cd services/core && /config/go/bin/sqlc generate
$ cd services/core && go test ./internal/roadmapsync ./internal/storage ./internal/roadmapsyncpipeline
ok github.com/nomadcode/nomadcode-core/internal/roadmapsync 0.003s
ok github.com/nomadcode/nomadcode-core/internal/storage (cached)
ok github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline 0.003s
$ cd services/core && go test ./...
ok github.com/nomadcode/nomadcode-core/cmd/plane-smoke (cached)
ok github.com/nomadcode/nomadcode-core/cmd/server 0.009s
ok github.com/nomadcode/nomadcode-core/internal/adapters/a2a (cached)
ok github.com/nomadcode/nomadcode-core/internal/adapters/jira (cached)
ok github.com/nomadcode/nomadcode-core/internal/adapters/mattermost (cached)
ok github.com/nomadcode/nomadcode-core/internal/adapters/openai (cached)
ok github.com/nomadcode/nomadcode-core/internal/adapters/plane (cached)
? github.com/nomadcode/nomadcode-core/internal/agent [no test files]
ok github.com/nomadcode/nomadcode-core/internal/authoring 0.294s
ok github.com/nomadcode/nomadcode-core/internal/config (cached)
? github.com/nomadcode/nomadcode-core/internal/db [no test files]
ok github.com/nomadcode/nomadcode-core/internal/gitoevents (cached)
ok github.com/nomadcode/nomadcode-core/internal/gitosync 1.461s
ok github.com/nomadcode/nomadcode-core/internal/http 0.017s
? github.com/nomadcode/nomadcode-core/internal/model [no test files]
ok github.com/nomadcode/nomadcode-core/internal/notification (cached)
ok github.com/nomadcode/nomadcode-core/internal/projectsync (cached)
ok github.com/nomadcode/nomadcode-core/internal/protosocket 0.032s
ok github.com/nomadcode/nomadcode-core/internal/roadmapsync (cached)
ok github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline (cached)
ok github.com/nomadcode/nomadcode-core/internal/scheduler 2.040s
ok github.com/nomadcode/nomadcode-core/internal/storage (cached)
ok github.com/nomadcode/nomadcode-core/internal/workflow 0.004s
ok github.com/nomadcode/nomadcode-core/internal/workitem (cached)
ok github.com/nomadcode/nomadcode-core/internal/workitempipeline (cached)
```
## 코드리뷰 결과
- 종합 판정: FAIL
- 차원별 평가:
- correctness: Fail
- completeness: Fail
- test coverage: Fail
- API contract: Pass
- code quality: Warn
- plan deviation: Warn
- verification trust: Warn
- spec conformance: Fail
- 발견된 문제:
- Required: `services/core/internal/roadmapsync/develop_match.go:93`에서 matched scanned identity의 provider/work item 속성을 버리고 `expected` identity의 path만 교체해 반환합니다. 같은 filename slug가 새 Plane work item id를 속성으로 갖는 경우에도 `ProjectionCandidate.Identity.WorkItemID`는 기존 expected 값으로 남아, 이후 `upsertParams`가 DB row의 provider work item id를 새 값으로 갱신할 수 없습니다. `matchedScanned.Identity.Normalize()` 결과를 기준으로 candidate identity를 만들고, 누락된 tenant/project 등 필요한 기존 속성만 명시적으로 보존한 뒤 `TestMatchDevelopMilestoneReadyWithWorkItemIDDifference`가 반환 identity의 `WorkItemID` 갱신까지 검증하게 고치세요.
- Required: `services/core/internal/roadmapsyncpipeline/service.go:210`-`218`이 기존 canonical row를 찾은 뒤 provider/roadmap revision 차이까지 즉시 upsert하고, 그 갱신된 row를 `ReconcileCreationCycle`에 넘깁니다. 이 순서에서는 기존 row의 `ProviderRevision`/`RoadmapRevision` mismatch가 먼저 덮여 `ActionConflict` guard가 무력화되고, 다른 develop/provider revision을 같은 retry로 재개할 수 있습니다. 기존 row로 먼저 reconcile conflict를 판단하고, 허용된 path/work item 속성 갱신은 conflict가 아닌 경우에만 수행하도록 순서를 분리하며 pipeline 테스트에 revision mismatch가 `SyncActionConflict`이고 upsert/provider call/ledger mark가 없음을 추가하세요.
- Suggested: `services/core/internal/db/db.go:3` 등 생성 파일 7개가 기존 `sqlc v1.31.1` header에서 `/config/go/bin/sqlc v1.26.0`으로 내려갔습니다. 실제 query 변경과 무관한 generated churn이어서 후속에서는 프로젝트 기준 sqlc 버전으로 재생성하거나, 불가하면 이유와 영향 범위를 명시하고 불필요한 생성 파일 흔들림을 제거하세요.
- 다음 단계: user-review gate는 트리거하지 않는다. Required 문제를 좁게 수정하는 follow-up `PLAN-cloud-G06.md`와 `CODE_REVIEW-cloud-G06.md`를 작성한다.

View file

@ -0,0 +1,194 @@
<!-- task=m-plane-origin-authoring-roundtrip-sync/05_canonical_id plan=1 tag=REVIEW_REFACTOR -->
# Code Review Reference - REVIEW_REFACTOR
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a selected SDD decision or selected Milestone `구현 잠금 > 결정 필요` item, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Environment/secret/service setup, generic scope conflicts, loop exhaustion, and evidence gaps that a follow-up agent can close are normal follow-up issues, not user-review blockers by themselves.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record only the linked SDD/Milestone lock decision in `사용자 리뷰 요청` and stop for code-review.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
> Follow the ownership table at the bottom of this file for which sections you own.
## 개요
date=2026-06-21
task=m-plane-origin-authoring-roundtrip-sync/05_canonical_id, plan=1, tag=REVIEW_REFACTOR
## Roadmap Targets
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-origin-authoring-roundtrip-sync.md`
- Task ids:
- `canonical-id`: `agent-roadmap/phase/*/milestones/<milestone-id>.md`의 `<milestone-id>`를 단일 canonical sync id로 정의하고, `roadmap_sync_identities`/parser/matcher/projection이 path 대신 이 id를 1차 판별값으로 사용한다.
- Completion mode: check-on-pass
## Spec Targets
- SDD: `agent-roadmap/sdd/agent-ops-mcp-control-plane/plane-origin-authoring-roundtrip-sync/SDD.md`
- Acceptance scenarios:
- `S09`: task=`canonical-id`; evidence=`storage/parser/matcher tests showing filename slug is canonical and path/provider ids are attributes`
- Completion mode: spec-check-on-pass
## Archive Evidence Snapshot
- Archived plan: `agent-task/m-plane-origin-authoring-roundtrip-sync/05_canonical_id/plan_cloud_G06_0.log`
- Archived review: `agent-task/m-plane-origin-authoring-roundtrip-sync/05_canonical_id/code_review_cloud_G06_0.log`
- Verdict: FAIL
- Required issues:
- `services/core/internal/roadmapsync/develop_match.go:93` returns `expected` with only path replaced, so a scanned same-slug identity with a changed provider work item id is not propagated to storage.
- `services/core/internal/roadmapsyncpipeline/service.go:210`-`218` upserts an existing canonical row before `ReconcileCreationCycle`, so stored provider/roadmap revision mismatches can be overwritten before conflict detection.
- Suggested issue:
- `services/core/internal/db/*.go` generated headers were downgraded from `sqlc v1.31.1` to `sqlc v1.26.0` even where query output did not otherwise change.
- Verification evidence from failed loop:
- `cd services/core && go test -count=1 ./internal/roadmapsync ./internal/storage ./internal/roadmapsyncpipeline` passed.
- `cd services/core && go test ./...` passed.
- `git diff --check` passed.
- Allowed archive reread:
- The next implementer may read only the two archived files listed above if exact prior review wording is needed. Do not search `agent-task/archive/**`.
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-cloud-G06.md` -> `code_review_cloud_G06_N.log`, `PLAN-cloud-G06.md` -> `plan_cloud_G06_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-plane-origin-authoring-roundtrip-sync/05_canonical_id/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
4. PASS이고 task group이 `m-plane-origin-authoring-roundtrip-sync`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_REFACTOR-1] Scanned Provider Attribute Propagation | [x] |
| [REVIEW_REFACTOR-2] Reconcile Before Existing Row Mutation | [x] |
## 구현 체크리스트
- [x] `MatchDevelopMilestone`가 same slug로 matched된 scanned identity의 provider/work item 속성을 candidate identity에 반영하고, 누락된 기존 속성만 명시적으로 보존하도록 수정한다.
- [x] `SyncCreation` identity load/upsert 순서를 조정해 기존 canonical row의 revision mismatch를 upsert 전에 conflict로 판정하고, 이를 검증하는 pipeline 테스트를 추가한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G06_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_cloud_G06_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [x] PASS이면 active task 디렉터리 `agent-task/m-plane-origin-authoring-roundtrip-sync/05_canonical_id/`를 `agent-task/archive/YYYY/MM/m-plane-origin-authoring-roundtrip-sync/05_canonical_id/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [x] PASS이고 task group이 `m-plane-origin-authoring-roundtrip-sync`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-plane-origin-authoring-roundtrip-sync/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G06.md`와 `CODE_REVIEW-cloud-G06.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
- [ ] USER_REVIEW가 연결된 SDD/Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
- `loadExistingIdentityAndSteps` 로 쪼개어 non-mutating 조회를 설계할 때, Go function 리턴 개수에 맞춰 end-of-function의 리턴 값을 명확하게 `(row, completed, true, nil)` 과 `(..., false, nil)` 등으로 매핑되도록 보완했습니다.
## 주요 설계 결정
- **Scanned 속성 전파**: `MatchDevelopMilestone`에서 scanned milestone과 매칭에 성공했을 때, `scanned.Identity`를 base candidate로 삼고 path 및 work item 속성들을 갱신 전파하도록 했습니다. 다만 `expected`가 기존에 갖고 있던 `Tenant`, `Project`, `ParentWorkItemID` 등의 context 정보는 scanned에 비어있을 때 명시적으로 보존되도록 병합 로직을 구현했습니다.
- **Reconcile과 Mutation 분리**: `SyncCreation`에서 로우를 갱신하기 전에 `loadExistingIdentityAndSteps`로 기존 DB 로우 정보만 쿼리해 `ReconcileCreationCycle`을 실행하게 함으로써, revision mismatch 등 충돌 상황이 upsert로 덮여 Conflict Guard가 우회되던 로직을 방지했습니다. 이후 충돌이 없는 `ActionResume` 일 때만 조건부로 속성을 upsert 하게 개선했습니다.
- **sqlc Churn 방지**: 프로젝트 표준인 `sqlc v1.31.1` 버전을 로컬에 설치하여 header의 version-only churn diff를 전부 린트 오류 없이 정리했습니다.
## 사용자 리뷰 요청
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- 같은 filename slug에서 scanned provider work item id가 바뀌면 candidate/storage attribute도 새 값으로 갱신되는지 확인한다.
- existing canonical row의 revision mismatch가 upsert 전에 conflict로 판정되어 provider call/ledger mark가 발생하지 않는지 확인한다.
- generated DB 파일의 version-only churn이 불필요하게 남아 있지 않은지 확인한다.
## 검증 결과
### REVIEW_REFACTOR-1 중간 검증
```text
$ cd services/core && go test -count=1 ./internal/roadmapsync
ok github.com/nomadcode/nomadcode-core/internal/roadmapsync 0.003s
```
### REVIEW_REFACTOR-2 중간 검증
```text
$ cd services/core && go test -count=1 ./internal/roadmapsyncpipeline
ok github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline 0.003s
```
### 최종 검증
```text
$ cd services/core && go test -count=1 ./internal/roadmapsync ./internal/storage ./internal/roadmapsyncpipeline
ok github.com/nomadcode/nomadcode-core/internal/roadmapsync 0.013s
ok github.com/nomadcode/nomadcode-core/internal/storage 0.007s
ok github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline 0.013s
$ cd services/core && go test ./...
ok github.com/nomadcode/nomadcode-core/cmd/plane-smoke (cached)
ok github.com/nomadcode/nomadcode-core/cmd/server (cached)
ok github.com/nomadcode/nomadcode-core/internal/adapters/a2a (cached)
ok github.com/nomadcode/nomadcode-core/internal/adapters/jira (cached)
ok github.com/nomadcode/nomadcode-core/internal/adapters/mattermost (cached)
ok github.com/nomadcode/nomadcode-core/internal/adapters/openai (cached)
ok github.com/nomadcode/nomadcode-core/internal/adapters/plane (cached)
? github.com/nomadcode/nomadcode-core/internal/agent [no test files]
ok github.com/nomadcode/nomadcode-core/internal/authoring (cached)
ok github.com/nomadcode/nomadcode-core/internal/config (cached)
? github.com/nomadcode/nomadcode-core/internal/db [no test files]
ok github.com/nomadcode/nomadcode-core/internal/gitoevents (cached)
ok github.com/nomadcode/nomadcode-core/internal/gitosync 1.419s
ok github.com/nomadcode/nomadcode-core/internal/http (cached)
? github.com/nomadcode/nomadcode-core/internal/model [no test files]
ok github.com/nomadcode/nomadcode-core/internal/notification (cached)
ok github.com/nomadcode/nomadcode-core/internal/projectsync (cached)
ok github.com/nomadcode/nomadcode-core/internal/protosocket (cached)
ok github.com/nomadcode/nomadcode-core/internal/roadmapsync 0.004s
ok github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline 0.004s
ok github.com/nomadcode/nomadcode-core/internal/scheduler (cached)
ok github.com/nomadcode/nomadcode-core/internal/storage (cached)
ok github.com/nomadcode/nomadcode-core/internal/workflow (cached)
ok github.com/nomadcode/nomadcode-core/internal/workitem (cached)
ok github.com/nomadcode/nomadcode-core/internal/workitempipeline (cached)
$ git diff --check
(no output, exit code 0)
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.
## 코드리뷰 결과
- 종합 판정: PASS
- 차원별 평가:
- correctness: Pass
- completeness: Pass
- test coverage: Pass
- API contract: Pass
- code quality: Pass
- plan deviation: Pass
- verification trust: Pass
- spec conformance: Pass
- 발견된 문제: 없음
- 다음 단계: PASS 경로로 `complete.log`를 작성하고 task 디렉터리를 archive로 이동한다.

View file

@ -0,0 +1,51 @@
# Complete - m-plane-origin-authoring-roundtrip-sync/05_canonical_id
## 완료 일시
2026-06-21
## 요약
canonical-id foundation review loop completed after 2 reviews; final verdict PASS.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_cloud_G06_0.log` | `code_review_cloud_G06_0.log` | FAIL | scanned provider attributes were not propagated and existing-row revision mismatch could be overwritten before reconcile |
| `plan_cloud_G06_1.log` | `code_review_cloud_G06_1.log` | PASS | follow-up fixed scanned attribute propagation, reconcile-before-upsert ordering, and sqlc version-only churn |
## 구현/정리 내용
- `MatchDevelopMilestone` now returns the matched scanned identity as the candidate while preserving missing tenant/project context from expected identity.
- `SyncCreation` now reconciles against the original existing identity row before any upsert, so revision mismatches conflict before mutation.
- Pipeline tests cover revision mismatch no-mutation behavior and allowed attribute update behavior.
- Review-only cleanup updated stale non-behavioral comments describing the old path/work-item match key.
## 최종 검증
- `cd services/core && go test -count=1 ./internal/roadmapsync ./internal/storage ./internal/roadmapsyncpipeline` - PASS; `roadmapsync`, `storage`, and `roadmapsyncpipeline` passed without cache.
- `cd services/core && go test ./...` - PASS; all core packages passed.
- `git diff --check` - PASS; no output.
## Roadmap Completion
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-origin-authoring-roundtrip-sync.md`
- Completed task ids:
- `canonical-id`: PASS; evidence=`agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/05_canonical_id/plan_cloud_G06_1.log`, `agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/05_canonical_id/code_review_cloud_G06_1.log`; verification=`cd services/core && go test -count=1 ./internal/roadmapsync ./internal/storage ./internal/roadmapsyncpipeline`, `cd services/core && go test ./...`, `git diff --check`
- Not completed task ids: 없음
## Spec Completion
- SDD: `agent-roadmap/sdd/agent-ops-mcp-control-plane/plane-origin-authoring-roundtrip-sync/SDD.md`
- Completed scenario ids:
- `S09`: PASS; task=`canonical-id`; evidence=`agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/05_canonical_id/plan_cloud_G06_1.log`, `agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/05_canonical_id/code_review_cloud_G06_1.log`; verification=`cd services/core && go test -count=1 ./internal/roadmapsync ./internal/storage ./internal/roadmapsyncpipeline`, `cd services/core && go test ./...`, `git diff --check`
- Not completed scenario ids: 없음
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -0,0 +1,196 @@
<!-- task=m-plane-origin-authoring-roundtrip-sync/05_canonical_id plan=0 tag=REFACTOR -->
# Plan - REFACTOR
## 이 파일을 읽는 구현 에이전트에게
구현 완료의 마지막 단계는 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우는 것이다. 검증을 실행하고 실제 stdout/stderr를 붙인 뒤 active 파일은 그대로 두고 리뷰 준비를 보고한다. 최종 판정, log rename, `complete.log`, archive 이동은 code-review 전용이다. 선택된 SDD 결정 또는 Milestone `구현 잠금 > 결정 필요` 항목이 막을 때만 review stub의 `사용자 리뷰 요청`을 채우고 멈춘다. 직접 사용자에게 묻거나 `USER_REVIEW.md`를 만들지 않는다. 환경/secret/service 준비, 일반 범위 조정, 검증 공백은 사용자 리뷰가 아니라 검증 결과나 후속 plan으로 남긴다.
## 배경
현재 sync identity는 `roadmap_milestone_path`와 Plane work item id를 match key로 삼아 phase 이동, archive 후보 이동, provider work item 재생성 시 같은 Milestone을 잃는다. SDD S09는 filename slug를 canonical id로 두고 path/provider id를 속성으로 기록하라고 요구한다. 이 작업은 DB/query/storage/domain foundation만 바꾸고 Plane missing-create 실행은 후속 06에서 다룬다.
## 사용자 리뷰 요청 흐름
구현 중 사용자 판단이 필요한 경우는 선택된 SDD 결정 또는 선택된 Milestone 잠금 결정뿐이다. 해당 경우 review stub의 `사용자 리뷰 요청` 섹션에 연결 대상, 차단 근거, 실행한 명령, 재개 조건을 기록하고 멈춘다. 직접 채팅 질문, 선택지 제시, `request_user_input`, `USER_REVIEW.md` 생성은 금지다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-origin-authoring-roundtrip-sync.md`
- Task ids:
- `canonical-id`: `agent-roadmap/phase/*/milestones/<milestone-id>.md`의 `<milestone-id>`를 단일 canonical sync id로 정의하고, `roadmap_sync_identities`/parser/matcher/projection이 path 대신 이 id를 1차 판별값으로 사용한다.
- Completion mode: check-on-pass
## Spec Targets
- SDD: `agent-roadmap/sdd/agent-ops-mcp-control-plane/plane-origin-authoring-roundtrip-sync/SDD.md`
- Acceptance scenarios:
- `S09`: task=`canonical-id`; evidence=`storage/parser/matcher tests showing filename slug is canonical and path/provider ids are attributes`
- Completion mode: spec-check-on-pass
## 분석 결과
### 읽은 파일
- `agent-test/local/rules.md`
- `agent-test/local/core-smoke.md`
- `agent-test/local/workspace-ops-smoke.md`
- `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-origin-authoring-roundtrip-sync.md`
- `agent-roadmap/sdd/agent-ops-mcp-control-plane/plane-origin-authoring-roundtrip-sync/SDD.md`
- `services/core/internal/roadmapsync/identity.go`
- `services/core/internal/roadmapsync/identity_test.go`
- `services/core/internal/roadmapsync/milestone_parser.go`
- `services/core/internal/roadmapsync/milestone_parser_test.go`
- `services/core/internal/roadmapsync/develop_match.go`
- `services/core/internal/roadmapsync/develop_match_test.go`
- `services/core/internal/roadmapsync/retry.go`
- `services/core/internal/roadmapsync/retry_test.go`
- `services/core/internal/roadmapsync/plane_format.go`
- `services/core/internal/roadmapsync/plane_format_test.go`
- `services/core/internal/storage/roadmap_sync_identities.go`
- `services/core/internal/storage/roadmap_sync_identities_test.go`
- `services/core/internal/roadmapsyncpipeline/service.go`
- `services/core/internal/roadmapsyncpipeline/service_test.go`
- `services/core/queries/roadmap_sync_identities.sql`
- `services/core/migrations/00006_create_roadmap_sync_identities.sql`
- `services/core/internal/db/roadmap_sync_identities.sql.go`
- `services/core/internal/db/models.go`
- `services/core/sqlc.yaml`
### 테스트 환경 규칙
test_env=`local`. `agent-test/local/rules.md`와 `core-smoke`, `workspace-ops-smoke`를 읽었다. core 변경 최소 검증은 `cd services/core && go test ./...`다. SQLC schema/query 변경은 generated code 갱신이 필요하므로 `cd services/core && sqlc generate`를 사용한다. 현재 대화 workspace에서는 `command -v sqlc` 출력이 없었고 `go`는 `/config/.local/bin/go`에 있다. standard remote runner에서 `sqlc`가 없으면 `command -v sqlc` 출력과 함께 차단으로 기록한다.
### 테스트 커버리지 공백
- filename slug canonical id: 현재 없음. `identity`, `milestone_parser`, `storage`, `roadmapsyncpipeline` 테스트를 추가해야 한다.
- path/provider work item id 변경을 속성으로 갱신하는 DB upsert: 현재 path/work item key 중심 테스트만 있다. 신규 SQL/storage 테스트가 필요하다.
- phase 이동 후보: 현재 `Matches`/`MatchesStrict`가 path를 비교하므로 테스트가 실패해야 한다.
### 심볼 참조
- 변경 후보: `Identity.Normalize`, `Identity.Matches`, `Identity.MatchesStrict`, `GetRoadmapSyncIdentityByMilestonePath`, `UpsertRoadmapSyncIdentityByWorkItem`, `upsertParams`, `existingFromRow`.
- `roadmap_milestone_path` 참조는 `services/core/internal/db`, `services/core/internal/storage`, `services/core/internal/roadmapsync`, `services/core/internal/roadmapsyncpipeline`, `services/core/internal/gitosync`, `services/core/internal/scheduler`에 있다. 이 plan은 identity foundation에 필요한 storage/domain/pipeline 참조만 수정한다.
### 분할 판단
split decision policy를 적용했다. 공유 task group은 `m-plane-origin-authoring-roundtrip-sync`다. 이 subtask는 `05_canonical_id`로 선행 의존성이 없다. 후속 `06+05_plane_missing_create`와 `07+05,06_slug_reconcile`은 이 identity foundation의 generated query와 storage API가 있어야 구현 가능하다. DB/schema, domain matching, pipeline call-site가 한 묶음으로 리뷰되어야 하므로 단일 subtask 안에서 닫는다.
### 범위 결정 근거
이 subtask는 canonical id foundation만 다룬다. Plane work item 생성, identity-less active doc enqueue, live smoke, slot finalizer는 제외한다. `services/core/internal/gitosync/scanner.go`의 active-index 직접 변경은 별도 완료 후보이며 여기서 다시 확장하지 않는다.
### 빌드 등급
`cloud-G06`: DB migration/query/generated code와 storage/domain/pipeline semantics가 함께 바뀌며, path/provider id를 속성으로 낮추는 cross-module behavior 변경이다.
## 구현 체크리스트
- [ ] `roadmapsync.Identity`에 filename slug canonical id 정규화 경로를 추가하고 path/provider work item id는 속성으로 비교되도록 matcher/reconcile tests를 갱신한다.
- [ ] `roadmap_sync_identities` migration/query/generated/storage를 canonical id lookup/upsert 중심으로 바꾸고 path 변경과 provider work item id 변경 테스트를 추가한다.
- [ ] `roadmapsyncpipeline`이 provider work item lookup 전에 canonical id lookup/upsert를 사용하도록 조정하고 existing row 변환을 갱신한다.
- [ ] `cd services/core && sqlc generate`를 실행해 generated DB code를 갱신한다. `sqlc`가 없으면 `command -v sqlc` 결과를 기록하고 구현을 차단한다.
- [ ] `cd services/core && go test ./internal/roadmapsync ./internal/storage ./internal/roadmapsyncpipeline`와 `cd services/core && go test ./...`를 통과시킨다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [REFACTOR-1] Canonical Milestone ID Foundation
문제: [identity.go](/config/workspace/nomadcode/services/core/internal/roadmapsync/identity.go:36)는 minimal key를 provider + work item id + Milestone path로 설명하고, [identity.go](/config/workspace/nomadcode/services/core/internal/roadmapsync/identity.go:77)는 path/provider/work item을 필수로 요구한다. [identity.go](/config/workspace/nomadcode/services/core/internal/roadmapsync/identity.go:100)와 [identity.go](/config/workspace/nomadcode/services/core/internal/roadmapsync/identity.go:122)는 path를 match key로 비교한다.
해결 방법: `MilestoneID` 또는 `RoadmapItemID` canonical helper를 추가해 `agent-roadmap/phase/*/milestones/<slug>.md`의 `<slug>`를 정규화한다. Milestone shape의 identity는 canonical id를 필수로 하고, path/provider/work item id는 저장 속성으로 둔다. 기존 `RoadmapItemID`는 Milestone shape에서 slug를 담는 field로 채우고, naming comment를 명확히 한다.
```go
// before identity.go:77
if normalized.RoadmapMilestonePath == "" ||
normalized.Provider == "" ||
normalized.WorkItemID == "" {
return Identity{}, ErrInvalidIdentity
}
```
```go
// after
if normalized.MilestoneID() == "" || normalized.Provider == "" {
return Identity{}, ErrInvalidIdentity
}
```
수정 파일 및 체크리스트:
- [ ] `services/core/internal/roadmapsync/identity.go`
- [ ] `services/core/internal/roadmapsync/identity_test.go`
- [ ] `services/core/internal/roadmapsync/milestone_parser.go`
- [ ] `services/core/internal/roadmapsync/milestone_parser_test.go`
- [ ] `services/core/internal/roadmapsync/develop_match.go`
- [ ] `services/core/internal/roadmapsync/develop_match_test.go`
- [ ] `services/core/internal/roadmapsync/retry.go`
- [ ] `services/core/internal/roadmapsync/retry_test.go`
테스트 작성: 작성한다. `TestIdentityMatchesByMilestoneIDAcrossPhaseMove`, `TestIdentityMatchesStrictAllowsProviderWorkItemAttributeChange`, parser가 path에서 slug를 채우는 테스트를 추가한다.
중간 검증: `cd services/core && go test ./internal/roadmapsync` 통과.
### [REFACTOR-2] Storage Key Migration And Pipeline Lookup
문제: [00006_create_roadmap_sync_identities.sql](/config/workspace/nomadcode/services/core/migrations/00006_create_roadmap_sync_identities.sql:23)는 provider work item unique index를, [00006_create_roadmap_sync_identities.sql](/config/workspace/nomadcode/services/core/migrations/00006_create_roadmap_sync_identities.sql:27)는 path unique index를 key로 둔다. [roadmap_sync_identities.sql](/config/workspace/nomadcode/services/core/queries/roadmap_sync_identities.sql:16)는 provider work item conflict로 upsert하고 [roadmap_sync_identities.sql](/config/workspace/nomadcode/services/core/queries/roadmap_sync_identities.sql:24)는 path가 같을 때만 update한다. [service.go](/config/workspace/nomadcode/services/core/internal/roadmapsyncpipeline/service.go:179)는 work item으로 먼저 lookup한다.
해결 방법: 새 migration을 추가해 기존 rows의 `roadmap_item_id`를 path basename으로 backfill하고 Milestone shape canonical id unique index를 추가한다. query는 `GetRoadmapSyncIdentityByMilestoneID`와 `UpsertRoadmapSyncIdentityByMilestoneID`를 중심으로 바꾸고, provider work item unique index는 foreign key 중복 방지용으로 유지한다. pipeline은 canonical id로 identity row를 찾고, 같은 slug 재처리에서 path/provider work item id/revision을 갱신한다.
```sql
-- before roadmap_sync_identities.sql:16
ON CONFLICT (provider, tenant, project, work_item_id)
...
WHERE roadmap_sync_identities.roadmap_milestone_path = EXCLUDED.roadmap_milestone_path
```
```sql
-- after
ON CONFLICT (shape, roadmap_item_id) WHERE shape = 'milestone' AND roadmap_item_id <> ''
DO UPDATE SET roadmap_milestone_path = EXCLUDED.roadmap_milestone_path, ...
```
수정 파일 및 체크리스트:
- [ ] `services/core/migrations/00009_add_roadmap_sync_milestone_id.sql`
- [ ] `services/core/queries/roadmap_sync_identities.sql`
- [ ] `services/core/internal/db/roadmap_sync_identities.sql.go`
- [ ] `services/core/internal/db/models.go`
- [ ] `services/core/internal/storage/roadmap_sync_identities.go`
- [ ] `services/core/internal/storage/roadmap_sync_identities_test.go`
- [ ] `services/core/internal/roadmapsyncpipeline/service.go`
- [ ] `services/core/internal/roadmapsyncpipeline/service_test.go`
테스트 작성: 작성한다. storage mock tests에서 same slug + moved path, same slug + changed work item, different slug + reused work item conflict를 검증한다. pipeline tests에서 canonical lookup이 work item lookup보다 먼저 쓰이는지 확인한다.
중간 검증: `cd services/core && sqlc generate && go test ./internal/storage ./internal/roadmapsyncpipeline`.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `services/core/internal/roadmapsync/identity.go` | REFACTOR-1 |
| `services/core/internal/roadmapsync/identity_test.go` | REFACTOR-1 |
| `services/core/internal/roadmapsync/milestone_parser.go` | REFACTOR-1 |
| `services/core/internal/roadmapsync/milestone_parser_test.go` | REFACTOR-1 |
| `services/core/internal/roadmapsync/develop_match.go` | REFACTOR-1 |
| `services/core/internal/roadmapsync/develop_match_test.go` | REFACTOR-1 |
| `services/core/internal/roadmapsync/retry.go` | REFACTOR-1 |
| `services/core/internal/roadmapsync/retry_test.go` | REFACTOR-1 |
| `services/core/migrations/00009_add_roadmap_sync_milestone_id.sql` | REFACTOR-2 |
| `services/core/queries/roadmap_sync_identities.sql` | REFACTOR-2 |
| `services/core/internal/db/roadmap_sync_identities.sql.go` | REFACTOR-2 |
| `services/core/internal/db/models.go` | REFACTOR-2 |
| `services/core/internal/storage/roadmap_sync_identities.go` | REFACTOR-2 |
| `services/core/internal/storage/roadmap_sync_identities_test.go` | REFACTOR-2 |
| `services/core/internal/roadmapsyncpipeline/service.go` | REFACTOR-2 |
| `services/core/internal/roadmapsyncpipeline/service_test.go` | REFACTOR-2 |
## 최종 검증
`command -v sqlc`
`cd services/core && sqlc generate`
`cd services/core && go test ./internal/roadmapsync ./internal/storage ./internal/roadmapsyncpipeline`
`cd services/core && go test ./...`
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,101 @@
<!-- task=m-plane-origin-authoring-roundtrip-sync/05_canonical_id plan=1 tag=REVIEW_REFACTOR -->
# Plan - REVIEW_REFACTOR
## 이 파일을 읽는 구현 에이전트에게
구현 완료의 마지막 단계는 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우는 것이다. 검증을 실행하고 실제 stdout/stderr를 붙인 뒤 active 파일은 그대로 두고 리뷰 준비를 보고한다. 최종 판정, log rename, `complete.log`, archive 이동은 code-review 전용이다. 선택된 SDD 결정 또는 Milestone `구현 잠금 > 결정 필요` 항목이 막을 때만 review stub의 `사용자 리뷰 요청` 섹션에 연결 대상, 차단 근거, 실행한 명령, 재개 조건을 기록하고 멈춘다. 직접 채팅 질문, 선택지 제시, `request_user_input`, `USER_REVIEW.md` 생성은 금지다. 환경/secret/service 준비, 일반 범위 조정, 검증 공백은 사용자 리뷰가 아니라 검증 결과나 후속 plan으로 남긴다.
## 배경
이 follow-up은 첫 리뷰 FAIL에서 발견된 canonical id foundation 결함만 수정한다. S09의 핵심은 filename slug가 canonical id이고 path/provider work item id는 갱신 가능한 속성이라는 점이다. 현재 구현은 changed provider work item id를 candidate identity에 반영하지 못하고, canonical row를 찾은 뒤 revision mismatch를 reconcile 전에 upsert로 덮을 수 있다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-origin-authoring-roundtrip-sync.md`
- Task ids:
- `canonical-id`: `agent-roadmap/phase/*/milestones/<milestone-id>.md`의 `<milestone-id>`를 단일 canonical sync id로 정의하고, `roadmap_sync_identities`/parser/matcher/projection이 path 대신 이 id를 1차 판별값으로 사용한다.
- Completion mode: check-on-pass
## Spec Targets
- SDD: `agent-roadmap/sdd/agent-ops-mcp-control-plane/plane-origin-authoring-roundtrip-sync/SDD.md`
- Acceptance scenarios:
- `S09`: task=`canonical-id`; evidence=`storage/parser/matcher tests showing filename slug is canonical and path/provider ids are attributes`
- Completion mode: spec-check-on-pass
## Archive Evidence Snapshot
- Archived plan: `agent-task/m-plane-origin-authoring-roundtrip-sync/05_canonical_id/plan_cloud_G06_0.log`
- Archived review: `agent-task/m-plane-origin-authoring-roundtrip-sync/05_canonical_id/code_review_cloud_G06_0.log`
- Verdict: FAIL
- Required issues:
- `services/core/internal/roadmapsync/develop_match.go:93` returns `expected` with only path replaced, so a scanned same-slug identity with a changed provider work item id is not propagated to storage.
- `services/core/internal/roadmapsyncpipeline/service.go:210`-`218` upserts an existing canonical row before `ReconcileCreationCycle`, so stored provider/roadmap revision mismatches can be overwritten before conflict detection.
- Suggested issue:
- `services/core/internal/db/*.go` generated headers were downgraded from `sqlc v1.31.1` to `sqlc v1.26.0` even where query output did not otherwise change.
- Verification evidence from failed loop:
- `cd services/core && go test -count=1 ./internal/roadmapsync ./internal/storage ./internal/roadmapsyncpipeline` passed.
- `cd services/core && go test ./...` passed.
- `git diff --check` passed.
- Allowed archive reread:
- The next implementer may read only the two archived files listed above if exact prior review wording is needed. Do not search `agent-task/archive/**`.
## 범위 결정 근거
후속 범위는 canonical-id task의 Required 결함 2건으로 제한한다. Plane missing-create 실행, active index, live smoke, slot finalizer는 이 follow-up 범위가 아니다. SQLC header churn은 불필요한 artifact drift이므로, SQL/query를 다시 바꾸지 않는다면 관련 없는 generated version-only diffs를 제거한다.
## 구현 체크리스트
- [ ] `MatchDevelopMilestone`가 same slug로 matched된 scanned identity의 provider/work item 속성을 candidate identity에 반영하고, 누락된 기존 속성만 명시적으로 보존하도록 수정한다.
- [ ] `SyncCreation` identity load/upsert 순서를 조정해 기존 canonical row의 revision mismatch를 upsert 전에 conflict로 판정하고, 이를 검증하는 pipeline 테스트를 추가한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [REVIEW_REFACTOR-1] Scanned Provider Attribute Propagation
문제: `services/core/internal/roadmapsync/develop_match.go`는 `expected.Matches(smIdentity)`로 same slug를 찾지만, 반환 identity는 `updatedExpected := expected`에 path만 교체한다. 따라서 scanned Milestone provider identity block에 새 `work item id`가 있어도 pipeline/storage로 전달되지 않는다.
해결 방법: loop 안에서 normalized scanned identity를 보존하고, candidate identity는 scanned identity를 기준으로 만든다. 단, scanned identity가 tenant/project/parent 등 선택 속성을 비워 둔 경우 기존 expected 값을 유지할지 명시적으로 결정해 테스트로 고정한다. provider가 다르면 기존처럼 mismatch여야 한다.
수정 파일 및 체크리스트:
- [ ] `services/core/internal/roadmapsync/develop_match.go`
- [ ] `services/core/internal/roadmapsync/develop_match_test.go`
테스트 작성: 작성한다. `TestMatchDevelopMilestoneReadyWithWorkItemIDDifference`가 `got.Identity.WorkItemID == "NOMAD-99"`까지 검증하도록 강화하고, scanned identity가 tenant/project를 비웠을 때 기대 동작을 테스트로 고정한다.
중간 검증: `cd services/core && go test -count=1 ./internal/roadmapsync`
### [REVIEW_REFACTOR-2] Reconcile Before Existing Row Mutation
문제: `services/core/internal/roadmapsyncpipeline/service.go`가 existing canonical row를 찾은 뒤 path/work item/revision 차이를 모두 upsert하고 나서 `ReconcileCreationCycle`을 호출한다. 이 때문에 기존 row의 revision mismatch가 사라진 상태로 reconcile되어 conflict guard가 동작하지 않을 수 있다.
해결 방법: existing row 조회와 completed step 조회를 먼저 수행하고, 기존 row 그대로 `ReconcileCreationCycle`을 실행한다. `ActionConflict`, `ActionSkipSelfMutation`, `ActionComplete`에서는 provider call과 mutation을 하지 않는다. `ActionResume`으로 판정된 뒤에만 허용된 path/work item/revision 속성 갱신이 필요하면 upsert하고, 그 row id로 나머지 step을 실행한다. 신규 row 또는 provider work item fallback row의 canonical id backfill은 기존 conflict guard를 우회하지 않게 분리한다.
수정 파일 및 체크리스트:
- [ ] `services/core/internal/roadmapsyncpipeline/service.go`
- [ ] `services/core/internal/roadmapsyncpipeline/service_test.go`
- [ ] `services/core/internal/db/*.go` version-only churn cleanup, 필요한 경우만
테스트 작성: 작성한다. existing canonical row가 `ProviderRevision` 또는 `RoadmapRevision`을 다르게 갖는 경우 `SyncCreation`이 `SyncActionConflict`를 반환하고 `store.upserted == false`, provider calls 없음, ledger marks 없음임을 검증한다. path 또는 provider work item id만 바뀌고 revision이 같은 경우에는 allowed attribute update가 수행되는 테스트도 추가한다.
중간 검증: `cd services/core && go test -count=1 ./internal/roadmapsyncpipeline`
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `services/core/internal/roadmapsync/develop_match.go` | REVIEW_REFACTOR-1 |
| `services/core/internal/roadmapsync/develop_match_test.go` | REVIEW_REFACTOR-1 |
| `services/core/internal/roadmapsyncpipeline/service.go` | REVIEW_REFACTOR-2 |
| `services/core/internal/roadmapsyncpipeline/service_test.go` | REVIEW_REFACTOR-2 |
| `services/core/internal/db/*.go` | REVIEW_REFACTOR-2, version-only churn cleanup only if needed |
## 최종 검증
`cd services/core && go test -count=1 ./internal/roadmapsync ./internal/storage ./internal/roadmapsyncpipeline`
`cd services/core && go test ./...`
`git diff --check`
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,171 @@
<!-- task=m-plane-origin-authoring-roundtrip-sync/06+05_plane_missing_create plan=0 tag=API -->
# Code Review Reference - API
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a selected SDD decision or selected Milestone `구현 잠금 > 결정 필요` item, fill `사용자 리뷰 요청` with linked evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`.
## 개요
date=2026-06-21
task=m-plane-origin-authoring-roundtrip-sync/06+05_plane_missing_create, plan=0, tag=API
## Roadmap Targets
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-origin-authoring-roundtrip-sync.md`
- Task ids:
- `plane-missing-create`: active Milestone slug가 ledger와 Plane에서 발견되지 않으면 Plane work item을 생성하고, 생성된 Plane work item id를 slug ledger와 Milestone provider identity block 또는 동등한 sync marker에 기록한다.
- Completion mode: check-on-pass
## Spec Targets
- SDD: `agent-roadmap/sdd/agent-ops-mcp-control-plane/plane-origin-authoring-roundtrip-sync/SDD.md`
- Acceptance scenarios:
- `S11`: task=`plane-missing-create`; evidence=`Plane adapter/reconcile tests or smoke proving missing active Milestone creates one Plane item and records ledger/marker`
- Completion mode: spec-check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
1. 판정을 append한다.
2. `CODE_REVIEW-cloud-G06.md` -> `code_review_cloud_G06_N.log`, `PLAN-cloud-G06.md` -> `plan_cloud_G06_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 archive로 이동한다.
4. PASS이고 task group이 `m-plane-origin-authoring-roundtrip-sync`이면 완료 이벤트 메타데이터를 보고하고 roadmap은 직접 수정하지 않는다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [API-1] Preserve Missing Active Milestone Candidates | [x] |
| [API-2] Create Plane Work Item And Record Slug Ledger | [x] |
## 구현 체크리스트
- [x] 선행 `05_canonical_id` complete evidence를 확인하고 없으면 구현하지 않는다.
- [x] active scanner/bridge 결과가 provider identity 없는 active Milestone Markdown을 missing-create 후보로 보존하도록 타입과 tests를 조정한다.
- [x] `roadmapsyncpipeline`에 Plane missing-create path를 추가해 canonical id lookup miss에서 `workitem.Creator.CreateWorkItem`을 한 번 호출하고 ledger/provider marker를 기록한다.
- [x] Plane create 결과의 provider work item id가 slug identity row에 저장되고 title이 `[milestone-id] ...` 형태인지 tests로 검증한다.
- [x] `cd services/core && go test ./internal/gitosync ./internal/roadmapsyncpipeline ./internal/adapters/plane ./internal/workitem`와 `cd services/core && go test ./...`를 통과시킨다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G06_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_cloud_G06_M.log`로 아카이브한다.
- [ ] PASS이면 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리를 archive로 이동한다.
- [ ] PASS이고 task group이 `m-plane-origin-authoring-roundtrip-sync`이면 런타임 완료 이벤트 메타데이터를 보고하고 roadmap 수정은 하지 않는다.
- [x] WARN/FAIL이면 후속 plan 또는 USER_REVIEW gate를 처리한다.
## 계획 대비 변경 사항
- `PLANE_PROJECT_ID` 환경 변수(비밀 제외 로컬 환경 변수)를 로드할 수 있도록 `config.go` 설정 구조체에 `PlaneProjectID` 필드를 추가했습니다.
- `bridge.go` 의 `NewBridge` 가 missing-create 시 Plane `Tenant` 와 `Project` 를 인식할 수 있도록 `BridgeConfig` 에도 `Tenant`, `Project` 를 추가하여 `cmd/server/main.go` 가 구동될 때 주입하도록 했습니다.
## 주요 설계 결정
- `SyncCreation` 시작 시점에 `expected.MilestoneID()` 로 DB의 `roadmap_sync_identities` 테이블을 우선 조회합니다.
- 만약 이미 DB에 매핑이 존재한다면, provider identity가 없어도 DB에 저장된 실제 provider 정보로 `in.Expected` 및 `in.Ref` 를 재구성하여 기존의 projection 및 Reconcile 로직을 자연스럽게 타도록 구현했습니다.
- 만약 DB에 매핑이 없고 provider identity 정보도 없는 경우("canonical id lookup miss" + "missing provider identity"), `s.provider` 를 `workitem.Creator` 로 타입 단언하여 `CreateWorkItem` 을 한 번 수행하고, 생성된 work item ref와 slug mapping을 디비에 `UpsertRoadmapSyncIdentity` 를 호출해 저장(ledger-only marker)하고 `StepDevelopMatched` 를 완성한 뒤 조기 리턴(same call 중복 실행 차단)합니다.
## 사용자 리뷰 요청
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- identity 없는 active Milestone이 조용히 drop되지 않는지 확인한다.
- Plane create가 중복 없이 한 번만 호출되고 created work item id가 slug ledger에 남는지 확인한다.
- live credential 없이 unit/fake tests로 provider create behavior가 검증되는지 확인한다.
## 검증 결과
### API-1 중간 검증
```text
$ cd services/core && go test -count=1 ./internal/gitosync
ok github.com/nomadcode/nomadcode-core/internal/gitosync 1.421s
```
### API-2 중간 검증
```text
$ cd services/core && go test -count=1 ./internal/roadmapsyncpipeline ./internal/adapters/plane ./internal/workitem
ok github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline 0.003s
ok github.com/nomadcode/nomadcode-core/internal/adapters/plane 0.014s
ok github.com/nomadcode/nomadcode-core/internal/workitem 0.004s
```
### 최종 검증
```text
$ test -f agent-task/m-plane-origin-authoring-roundtrip-sync/05_canonical_id/complete.log || find agent-task/archive -path '*/m-plane-origin-authoring-roundtrip-sync/05_canonical_id/complete.log' -print -quit
agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/05_canonical_id/complete.log
$ cd services/core && go test -count=1 ./internal/gitosync ./internal/roadmapsyncpipeline ./internal/adapters/plane ./internal/workitem
ok github.com/nomadcode/nomadcode-core/internal/gitosync 1.497s
ok github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline 0.004s
ok github.com/nomadcode/nomadcode-core/internal/adapters/plane 0.015s
ok github.com/nomadcode/nomadcode-core/internal/workitem 0.003s
$ cd services/core && go test -count=1 ./...
ok github.com/nomadcode/nomadcode-core/cmd/plane-smoke 0.004s
ok github.com/nomadcode/nomadcode-core/cmd/server 0.007s
ok github.com/nomadcode/nomadcode-core/internal/adapters/a2a 0.013s
ok github.com/nomadcode/nomadcode-core/internal/adapters/jira 0.011s
ok github.com/nomadcode/nomadcode-core/internal/adapters/mattermost 0.009s
ok github.com/nomadcode/nomadcode-core/internal/adapters/openai 0.011s
ok github.com/nomadcode/nomadcode-core/internal/adapters/plane 0.010s
? github.com/nomadcode/nomadcode-core/internal/agent [no test files]
ok github.com/nomadcode/nomadcode-core/internal/authoring 0.305s
ok github.com/nomadcode/nomadcode-core/internal/config 0.006s
? github.com/nomadcode/nomadcode-core/internal/db [no test files]
ok github.com/nomadcode/nomadcode-core/internal/gitoevents 0.009s
ok github.com/nomadcode/nomadcode-core/internal/gitosync 1.443s
ok github.com/nomadcode/nomadcode-core/internal/http 0.021s
? github.com/nomadcode/nomadcode-core/internal/model [no test files]
ok github.com/nomadcode/nomadcode-core/internal/notification 0.005s
ok github.com/nomadcode/nomadcode-core/internal/projectsync 0.008s
ok github.com/nomadcode/nomadcode-core/internal/protosocket 0.014s
ok github.com/nomadcode/nomadcode-core/internal/roadmapsync 0.005s
ok github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline 0.005s
ok github.com/nomadcode/nomadcode-core/internal/scheduler 2.039s
ok github.com/nomadcode/nomadcode-core/internal/storage 0.004s
ok github.com/nomadcode/nomadcode-core/internal/workflow 0.005s
ok github.com/nomadcode/nomadcode-core/internal/workitem 0.005s
ok github.com/nomadcode/nomadcode-core/internal/workitempipeline 0.006s
```
## 코드리뷰 결과
- 종합 판정: FAIL
- 차원별 평가:
- Correctness: Fail
- Completeness: Fail
- Test coverage: Fail
- API contract: Fail
- Code quality: Pass
- Plan deviation: Warn
- Verification trust: Warn
- Spec conformance: Fail
- 발견된 문제:
- Required: [service.go](/config/workspace/nomadcode/services/core/internal/roadmapsyncpipeline/service.go:142)의 missing-create branch는 `Expected.Provider == ""`일 때만 `CreateWorkItem`을 호출하지만, 실제 bridge missing-create enqueue는 [bridge.go](/config/workspace/nomadcode/services/core/internal/gitosync/bridge.go:145)에서 `Expected.Provider`를 `plane`으로 채운다. 이 실제 job은 create branch를 건너뛰고 [develop_match.go](/config/workspace/nomadcode/services/core/internal/roadmapsync/develop_match.go:80)의 `not_ready` 경로로 빠져 Plane item을 만들지 못하므로 S11 `plane-missing-create`가 충족되지 않는다. fix: bridge와 pipeline의 계약을 하나로 맞춰라. 예를 들어 pipeline은 existing row가 없고 `Expected.WorkItemID`/`Ref.ID`가 비어 있는 active milestone input을 missing-create로 판정하되 provider/tenant/project를 검증하고, bridge가 실제로 생성하는 args를 그대로 `SyncCreation`에 넣는 테스트를 추가한다.
- Required: [service_test.go](/config/workspace/nomadcode/services/core/internal/roadmapsyncpipeline/service_test.go:421)의 missing-create 테스트는 provider를 비운 synthetic input만 검증한다. 하지만 [bridge_test.go](/config/workspace/nomadcode/services/core/internal/gitosync/bridge_test.go:344)는 bridge가 provider=`plane`을 넣는 것을 별도로 고정한다. 두 테스트가 서로 다른 계약을 검증해 실제 bridge -> scheduler -> pipeline 경로의 회귀를 놓쳤다. fix: bridge-produced missing-create `RoadmapCreationSyncJobArgs` 또는 같은 shape의 `SyncCreationInput`으로 `CreateWorkItem`이 정확히 한 번 호출되고 ledger에 created work item id가 저장되는 통합 단위 테스트를 추가한다.
- 다음 단계:
- FAIL: active plan/review를 archive한 뒤 위 Required 이슈만 다루는 후속 `PLAN-local-G05.md`와 `CODE_REVIEW-local-G05.md`를 작성한다.

View file

@ -0,0 +1,229 @@
<!-- task=m-plane-origin-authoring-roundtrip-sync/06+05_plane_missing_create plan=1 tag=REVIEW_API -->
# Code Review Reference - REVIEW_API
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a selected SDD decision or selected Milestone `구현 잠금 > 결정 필요` item, fill `사용자 리뷰 요청` with linked evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Environment/secret/service setup, generic scope conflicts, loop exhaustion, and evidence gaps that a follow-up agent can close are normal follow-up issues, not user-review blockers by themselves.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record only the linked SDD/Milestone lock decision in `사용자 리뷰 요청` and stop for code-review.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
> Follow the ownership table at the bottom of this file for which sections you own.
## 개요
date=2026-06-21
task=m-plane-origin-authoring-roundtrip-sync/06+05_plane_missing_create, plan=1, tag=REVIEW_API
## Roadmap Targets
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-origin-authoring-roundtrip-sync.md`
- Task ids:
- `plane-missing-create`: active Milestone slug가 ledger와 Plane에서 발견되지 않으면 Plane work item을 생성하고, 생성된 Plane work item id를 slug ledger와 Milestone provider identity block 또는 동등한 sync marker에 기록한다.
- Completion mode: check-on-pass
## Spec Targets
- SDD: `agent-roadmap/sdd/agent-ops-mcp-control-plane/plane-origin-authoring-roundtrip-sync/SDD.md`
- Acceptance scenarios:
- `S11`: task=`plane-missing-create`; evidence=`Plane adapter/reconcile tests or smoke proving missing active Milestone creates one Plane item and records ledger/marker`
- Completion mode: spec-check-on-pass
## Archive Evidence Snapshot
- Archived plan: `agent-task/m-plane-origin-authoring-roundtrip-sync/06+05_plane_missing_create/plan_cloud_G06_0.log`
- Archived review: `agent-task/m-plane-origin-authoring-roundtrip-sync/06+05_plane_missing_create/code_review_cloud_G06_0.log`
- Verdict: FAIL
- Required summary:
- `services/core/internal/roadmapsyncpipeline/service.go:142` only enters missing-create when `Expected.Provider == ""`, while `services/core/internal/gitosync/bridge.go:145` sets `Expected.Provider` to `plane` for real identity-less active Milestone jobs.
- `services/core/internal/roadmapsyncpipeline/service_test.go:421` uses a provider-empty synthetic input, while `services/core/internal/gitosync/bridge_test.go:344` separately asserts provider=`plane`, leaving the actual bridge-produced pipeline contract untested.
- Affected files:
- `services/core/internal/roadmapsyncpipeline/service.go`
- `services/core/internal/roadmapsyncpipeline/service_test.go`
- `services/core/internal/gitosync/bridge.go`
- `services/core/internal/gitosync/bridge_test.go`
- Verification evidence from review:
- `cd services/core && go test -count=1 ./internal/gitosync ./internal/roadmapsyncpipeline` passed, demonstrating the current tests do not catch the contract mismatch.
- `cd services/core && go test -count=1 ./...` passed.
- `git diff --check` passed.
- Roadmap/spec carryover:
- Roadmap Task `plane-missing-create` remains incomplete until this follow-up proves actual missing active Milestone input creates one Plane item and records the created id in the slug ledger.
- SDD Acceptance Scenario `S11` remains incomplete for the same reason.
- Narrow archive reread allowed:
- `agent-task/m-plane-origin-authoring-roundtrip-sync/06+05_plane_missing_create/plan_cloud_G06_0.log`
- `agent-task/m-plane-origin-authoring-roundtrip-sync/06+05_plane_missing_create/code_review_cloud_G06_0.log`
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-local-G05.md` -> `code_review_local_G05_N.log`, `PLAN-local-G05.md` -> `plan_local_G05_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 archive로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
4. PASS이고 task group이 `m-plane-origin-authoring-roundtrip-sync`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_API-1] Align Missing-Create Contract | [x] |
## 구현 체크리스트
- [x] bridge-produced missing-create input과 pipeline create predicate를 맞춰, existing slug row가 없고 provider work item id가 비어 있는 active Milestone input이 `CreateWorkItem`을 호출하도록 수정했다.
- [x] provider/tenant/project가 missing-create에 필요한 값인지 검증하고, work item id가 이미 있거나 existing row가 있으면 중복 create를 하지 않는 기존 경로를 유지한다.
- [x] 실제 bridge missing-create job shape 또는 동등한 `SyncCreationInput`으로 `CreateWorkItem`이 정확히 한 번 호출되고 created work item id와 `develop_matched` ledger가 저장되는 테스트를 추가했다.
- [x] `cd services/core && go test -count=1 ./internal/gitosync ./internal/roadmapsyncpipeline`와 `cd services/core && go test -count=1 ./...`, `git diff --check`를 통과시켰다.
- [x] CODE_REVIEW-*-G05.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채웠다. 이 항목이 완료되었다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G05_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_local_G05_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [x] PASS이면 active task 디렉터리 `agent-task/m-plane-origin-authoring-roundtrip-sync/06+05_plane_missing_create/`를 `agent-task/archive/YYYY/MM/m-plane-origin-authoring-roundtrip-sync/06+05_plane_missing_create/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [x] PASS이고 task group이 `m-plane-origin-authoring-roundtrip-sync`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-plane-origin-authoring-roundtrip-sync/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-{build_lane}-GNN.md`와 `CODE_REVIEW-{review_lane}-GNN.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
- [ ] USER_REVIEW가 연결된 SDD/Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
계획과 정확하게 일치한다. 계획이 제시한 순서대로 구현했다:
1. `service.go`의 missing-create predicate를 `Provider == ""`에서 `WorkItemID == "" && Ref.ID == ""`로 변경
2. `service_test.go`에 bridge-produced shape 검증 테스트 추가 (`TestSyncCreationBridgeProducedMissingCreate`)
3. tenant/project validation 검증 테스트 추가 (`TestSyncCreationMissingCreateRequiresTenantAndProject`)
기존 `TestSyncCreationMissingActiveMilestoneCreatesWorkItemAndRecordsLedger` 테스트는 `Provider == ""`인 케이스를 테스트하므로, 실제 bridge가 enqueue하는 shape (`Provider="plane"`)을 검증하는 새로운 테스트를 추가로 작성했다.
## 주요 설계 결정
1. **Missing-create predicate 변경**: `services/core/internal/roadmapsyncpipeline/service.go:142`의 조건을 `string(in.Expected.Provider) == ""`에서 `in.Expected.WorkItemID == "" && in.Ref.ID == ""`로 변경했다. bridge.go(line 136-166)에서 `HasIdentity == false`일 때 `Provider="plane"`, `Tenant`, `Project`를 설정하므로 `Provider`가 항상 "plane"이다. `WorkItemID`와 `Ref.ID`가 모두 비어 있을 때만 missing-create로 판정한다.
2. **Provider fallback**: `Expected.Provider`가 비어 있으면 "plane"을 사용한다. bridge.go(line 138-141)에서 `Provider`를 설정하므로 실제 path에서는 "plane"이지만, future extensibility를 위해 다른 provider로 missing-create하는 경우를 고려해 fallback을 유지했다.
3. **Tenant/Project 검증**: missing-create에서 `Tenant` 또는 `Project`가 없으면 `CreateWorkItem`을 호출하기 전에 명시 오류를 반환한다. 외부 API 호출을 방지하고 즉시 실패 원인을 알 수 있게 한다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 SDD 결정 또는 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- 실제 bridge missing-create job shape가 pipeline create branch를 타는지 확인한다.
- provider/tenant/project validation이 create 전에 명확히 실패하는지 확인한다.
- created work item id가 slug ledger에 저장되고 `develop_matched`가 한 번만 mark되는지 확인한다.
- existing row 또는 existing work item id가 있는 입력에서 중복 create가 발생하지 않는지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### REVIEW_API-1 중간 검증
```text
$ cd services/core && go test -count=1 ./internal/gitosync ./internal/roadmapsyncpipeline
ok github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline 0.017s
ok github.com/nomadcode/nomadcode-core/internal/gitosync 1.447s
```
### 최종 검증
```text
$ cd services/core && go test -count=1 ./...
ok github.com/nomadcode/nomadcode-core/cmd/plane-smoke 0.010s
ok github.com/nomadcode/nomadcode-core/cmd/server 0.025s
ok github.com/nomadcode/nomadcode-core/internal/adapters/a2a 0.028s
ok github.com/nomadcode/nomadcode-core/internal/adapters/jira 0.020s
ok github.com/nomadcode/nomadcode-core/internal/adapters/mattermost 0.024s
ok github.com/nomadcode/nomadcode-core/internal/adapters/openai 0.019s
ok github.com/nomadcode/nomadcode-core/internal/adapters/plane 0.022s
? github.com/nomadcode/nomadcode-core/internal/agent [no test files]
ok github.com/nomadcode/nomadcode-core/internal/authoring 0.282s
ok github.com/nomadcode/nomadcode-core/internal/config 0.011s
? github.com/nomadcode/nomadcode-core/internal/db [no test files]
ok github.com/nomadcode/nomadcode-core/internal/gitoevents 0.015s
ok github.com/nomadcode/nomadcode-core/internal/gitosync 1.425s
ok github.com/nomadcode/nomadcode-core/internal/http 0.024s
? github.com/nomadcode/nomadcode-core/internal/model [no test files]
ok github.com/nomadcode/nomadcode-core/internal/notification 0.010s
ok github.com/nomadcode/nomadcode-core/internal/projectsync 0.013s
ok github.com/nomadcode/nomadcode-core/internal/protosocket 0.023s
ok github.com/nomadcode/nomadcode-core/internal/roadmapsync 0.011s
ok github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline 0.009s
ok github.com/nomadcode/nomadcode-core/internal/scheduler 2.038s
ok github.com/nomadcode/nomadcode-core/internal/storage 0.006s
ok github.com/nomadcode/nomadcode-core/internal/workflow 0.005s
ok github.com/nomadcode/nomadcode-core/internal/workitem 0.005s
ok github.com/nomadcode/nomadcode-core/internal/workitempipeline 0.008s
$ git diff --check
(no output - no whitespace errors)
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.
## 섹션 소유권
| 섹션 | 소유자 | 설명 |
|------|--------|------|
| 헤더 주석, 개요(date/task/plan/tag), 리뷰 에이전트 지시 | 스텁 생성 시 고정 | 구현 에이전트가 수정하거나 실행하지 않음 |
| Roadmap Targets | follow-up plan에서 복사해 스텁 생성 시 고정 | 구현 에이전트가 수정하지 않음; PASS 시 code-review가 `complete.log`의 `Roadmap Completion`으로 복사 |
| Spec Targets | follow-up plan에서 복사해 스텁 생성 시 고정 | 구현 에이전트가 수정하지 않음; PASS 시 code-review가 `complete.log`의 `Spec Completion`으로 복사 |
| Archive Evidence Snapshot | follow-up plan에서 복사해 스텁 생성 시 고정 | 구현 에이전트의 기본 이전 루프 컨텍스트; 추가 확인이 필요할 때 여기에 명시된 archive 파일만 좁게 읽음 |
| 구현 항목별 완료 여부 (항목명) | 스텁 생성 시 고정 | `[ ]` -> `[x]` 체크만 구현 에이전트가 수행 |
| 구현 체크리스트 (항목 텍스트/순서) | follow-up plan에서 복사해 스텁 생성 시 고정 | 구현 에이전트가 `[ ]` -> `[x]` 체크만 수행; 마지막 체크박스는 저장 전 필수 |
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section |
| 계획 대비 변경 사항, 주요 설계 결정 | 구현 에이전트가 채움 | placeholder 텍스트를 실제 내용으로 교체 |
| 사용자 리뷰 요청 | 구현 에이전트가 채움 | 선택된 SDD 결정 또는 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채움; 구현 중 직접 질문은 금지; 환경/secret/서비스/검증 공백/일반 범위 조정은 사용자 리뷰 요청으로 쓰지 않음 |
| 리뷰어를 위한 체크포인트 | 스텁 생성 시 고정 | 계획에서 추출한 리뷰 포인트 |
| 검증 결과 (섹션 제목 + 명령) | 스텁 생성 시 고정 | 실행 출력만 구현 에이전트가 채움; 명령 변경은 `계획 대비 변경 사항`에 기록 |
| 코드리뷰 결과 | 리뷰 에이전트가 append | 스텁에 포함하지 않음 |
## 코드리뷰 결과
- 종합 판정: PASS
- 차원별 평가:
- Correctness: Pass
- Completeness: Pass
- Test coverage: Pass
- API contract: Pass
- Code quality: Pass
- Plan deviation: Pass
- Verification trust: Pass
- Spec conformance: Pass
- 발견된 문제: 없음
- 다음 단계:
- PASS: active plan/review를 archive하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.

View file

@ -0,0 +1,50 @@
# Complete - m-plane-origin-authoring-roundtrip-sync/06+05_plane_missing_create
## 완료 일시
2026-06-21
## 요약
plane-missing-create follow-up review loop completed after 2 reviews; final verdict PASS.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_cloud_G06_0.log` | `code_review_cloud_G06_0.log` | FAIL | bridge-produced missing-create job used `Provider=plane` while pipeline only created when provider was empty |
| `plan_local_G05_1.log` | `code_review_local_G05_1.log` | PASS | follow-up aligned missing-create predicate with bridge job shape and added direct fake-provider tests |
## 구현/정리 내용
- `SyncCreation` now treats an active Milestone input with no existing slug row and no provider work item id/ref as a missing-create candidate, while preserving provider/tenant/project validation.
- Pipeline tests now cover the actual bridge-produced missing-create shape with `Provider=plane`, empty work item id/ref, one `CreateWorkItem` call, created id ledger upsert, and `develop_matched` marking.
- Existing non-missing paths remain on the normal match/reconcile/projection path, so existing rows and work item ids do not duplicate-create Plane items.
## 최종 검증
- `cd services/core && go test -count=1 ./internal/gitosync ./internal/roadmapsyncpipeline` - PASS; `gitosync` and `roadmapsyncpipeline` passed without cache.
- `cd services/core && go test -count=1 ./...` - PASS; all core packages passed without cache.
- `git diff --check` - PASS; no output.
## Roadmap Completion
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-origin-authoring-roundtrip-sync.md`
- Completed task ids:
- `plane-missing-create`: PASS; evidence=`agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/06+05_plane_missing_create/plan_local_G05_1.log`, `agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/06+05_plane_missing_create/code_review_local_G05_1.log`; verification=`cd services/core && go test -count=1 ./internal/gitosync ./internal/roadmapsyncpipeline`, `cd services/core && go test -count=1 ./...`, `git diff --check`
- Not completed task ids: 없음
## Spec Completion
- SDD: `agent-roadmap/sdd/agent-ops-mcp-control-plane/plane-origin-authoring-roundtrip-sync/SDD.md`
- Completed scenario ids:
- `S11`: PASS; task=`plane-missing-create`; evidence=`agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/06+05_plane_missing_create/plan_local_G05_1.log`, `agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/06+05_plane_missing_create/code_review_local_G05_1.log`; verification=`cd services/core && go test -count=1 ./internal/gitosync ./internal/roadmapsyncpipeline`, `cd services/core && go test -count=1 ./...`, `git diff --check`
- Not completed scenario ids: 없음
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -0,0 +1,158 @@
<!-- task=m-plane-origin-authoring-roundtrip-sync/06+05_plane_missing_create plan=0 tag=API -->
# Plan - API
## 이 파일을 읽는 구현 에이전트에게
구현 완료의 마지막 단계는 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우는 것이다. 선행 `05_canonical_id`의 `complete.log`가 없으면 구현을 시작하지 말고 검증 결과에 선행 미충족을 기록한다. 선택된 SDD/Milestone 결정이 막을 때만 review stub의 `사용자 리뷰 요청`을 채운다. 직접 사용자에게 묻거나 `USER_REVIEW.md`, `complete.log`, archive 이동을 수행하지 않는다.
## 배경
active Milestone 전체를 스캔해도 현재 bridge는 provider identity가 있는 문서만 Plane Todo projection으로 넘길 수 있다. SDD S11은 ledger와 Plane에서 발견되지 않는 active Milestone slug에 대해 Plane work item을 생성하고 slug ledger/marker를 남기라고 요구한다. 이 작업은 canonical id foundation 위에서 identity-less active doc을 생성 경로로 보내는 provider side-effect orchestration이다.
## 사용자 리뷰 요청 흐름
선택된 SDD 결정 또는 Milestone 잠금 결정만 review stub의 `사용자 리뷰 요청`에 기록한다. 외부 Plane credential 준비나 live service 접근 실패는 사용자 리뷰가 아니라 검증/환경 차단으로 기록한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-origin-authoring-roundtrip-sync.md`
- Task ids:
- `plane-missing-create`: active Milestone slug가 ledger와 Plane에서 발견되지 않으면 Plane work item을 생성하고, 생성된 Plane work item id를 slug ledger와 Milestone provider identity block 또는 동등한 sync marker에 기록한다.
- Completion mode: check-on-pass
## Spec Targets
- SDD: `agent-roadmap/sdd/agent-ops-mcp-control-plane/plane-origin-authoring-roundtrip-sync/SDD.md`
- Acceptance scenarios:
- `S11`: task=`plane-missing-create`; evidence=`Plane adapter/reconcile tests or smoke proving missing active Milestone creates one Plane item and records ledger/marker`
- Completion mode: spec-check-on-pass
## 분석 결과
### 읽은 파일
- `agent-test/local/rules.md`
- `agent-test/local/core-smoke.md`
- `agent-test/local/workspace-ops-smoke.md`
- `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-origin-authoring-roundtrip-sync.md`
- `agent-roadmap/sdd/agent-ops-mcp-control-plane/plane-origin-authoring-roundtrip-sync/SDD.md`
- `services/core/internal/gitosync/scanner.go`
- `services/core/internal/gitosync/scanner_test.go`
- `services/core/internal/gitosync/bridge.go`
- `services/core/internal/gitosync/bridge_test.go`
- `services/core/internal/roadmapsync/identity.go`
- `services/core/internal/roadmapsync/milestone_parser.go`
- `services/core/internal/roadmapsync/plane_format.go`
- `services/core/internal/roadmapsync/plane_projection.go`
- `services/core/internal/roadmapsync/plane_projection_test.go`
- `services/core/internal/roadmapsyncpipeline/service.go`
- `services/core/internal/roadmapsyncpipeline/service_test.go`
- `services/core/internal/storage/roadmap_sync_identities.go`
- `services/core/internal/workitem/provider.go`
- `services/core/internal/workitem/provider_test.go`
- `services/core/internal/adapters/plane/client.go`
- `services/core/internal/adapters/plane/client_test.go`
### 테스트 환경 규칙
test_env=`local`. core 변경 최소 검증은 `cd services/core && go test ./...`다. Plane live credential이 필요한 smoke는 이 plan의 필수 완료 조건이 아니다. provider side effect는 httptest/fake provider unit tests로 검증한다.
### 테스트 커버리지 공백
- identity-less active Milestone이 scanner/bridge에서 생성 후보로 보존되는 테스트가 없다.
- missing slug가 Plane create API를 한 번만 호출하고 ledger를 기록하는 pipeline test가 없다.
- 생성된 Plane work item id를 provider identity marker 또는 ledger에 남기는 테스트가 없다.
### 심볼 참조
- 변경 후보: `ScanOutput`, `ScannedMilestoneDoc`, `Bridge.enqueueDoc`, `workitem.Creator`, `roadmapsyncpipeline.Store`, `roadmapsyncpipeline.Service`.
- `CreateWorkItem`은 Plane adapter에 이미 구현되어 있다. bridge/pipeline은 아직 생성 facet을 사용하지 않는다.
### 분할 판단
split decision policy를 적용했다. 이 subtask는 `06+05_plane_missing_create`이며 predecessor `05_canonical_id`가 필요하다. 현재 active/complete predecessor는 없으므로 구현 시작 전 `agent-task/m-plane-origin-authoring-roundtrip-sync/05_canonical_id/complete.log` 또는 archive의 같은 index complete evidence가 필요하다. 06은 provider create side effect와 enqueue/pipeline orchestration이 함께 리뷰되어야 하므로 단일 subtask로 둔다.
### 범위 결정 근거
이 subtask는 Plane missing-create unit/fake 검증까지만 다룬다. live Plane full-cycle, Todo projection e2e, slot finalizer, slug conflict/reuse failure는 제외한다. canonical id schema 변경은 05 결과를 사용한다.
### 빌드 등급
`cloud-G06`: 외부 provider create side effect, scanner/bridge/pipeline orchestration, ledger update가 함께 변한다.
## 구현 체크리스트
- [ ] 선행 `05_canonical_id` complete evidence를 확인하고 없으면 구현하지 않는다.
- [ ] active scanner/bridge 결과가 provider identity 없는 active Milestone Markdown을 missing-create 후보로 보존하도록 타입과 tests를 조정한다.
- [ ] `roadmapsyncpipeline`에 Plane missing-create path를 추가해 canonical id lookup miss에서 `workitem.Creator.CreateWorkItem`을 한 번 호출하고 ledger/provider marker를 기록한다.
- [ ] Plane create 결과의 provider work item id가 slug identity row에 저장되고 title이 `[milestone-id] ...` 형태인지 tests로 검증한다.
- [ ] `cd services/core && go test ./internal/gitosync ./internal/roadmapsyncpipeline ./internal/adapters/plane ./internal/workitem`와 `cd services/core && go test ./...`를 통과시킨다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 의존 관계 및 구현 순서
`06+05_plane_missing_create`는 디렉터리명 기준으로 predecessor `05_canonical_id`에 의존한다. 구현 전 `05`의 `complete.log`가 active 또는 archive에 있어야 한다. 누락이면 이 plan의 구현을 시작하지 않는다.
### [API-1] Preserve Missing Active Milestone Candidates
문제: [scanner.go](/config/workspace/nomadcode/services/core/internal/gitosync/scanner.go:139)는 `ParseMilestoneIdentity` 실패 시 active path를 `ChangedFiles`에만 남기고 `Docs`에는 싣지 않는다. [bridge.go](/config/workspace/nomadcode/services/core/internal/gitosync/bridge.go:121)는 `out.Docs`만 순회하므로 identity 없는 active Milestone은 생성 후보가 되지 못한다.
해결 방법: active doc 타입에 `MilestoneID`, `Path`, `Markdown`, optional `Identity` 또는 `HasIdentity`를 포함한다. provider identity가 있으면 기존 projection enqueue를 유지하고, 없으면 missing-create enqueue/input으로 넘긴다.
수정 파일 및 체크리스트:
- [ ] `services/core/internal/gitosync/scanner.go`
- [ ] `services/core/internal/gitosync/scanner_test.go`
- [ ] `services/core/internal/gitosync/bridge.go`
- [ ] `services/core/internal/gitosync/bridge_test.go`
테스트 작성: 작성한다. identity-less active Milestone이 `Docs` 또는 `MissingCreateDocs`에 남고 archive candidate는 생성 후보가 아닌 테스트를 추가한다.
중간 검증: `cd services/core && go test ./internal/gitosync`.
### [API-2] Create Plane Work Item And Record Slug Ledger
문제: [service.go](/config/workspace/nomadcode/services/core/internal/roadmapsyncpipeline/service.go:113)는 develop match candidate가 ready일 때만 pipeline을 진행한다. missing active Milestone은 Plane work item id가 없으므로 `Expected` identity와 `Ref`를 만들 수 없다. [client.go](/config/workspace/nomadcode/services/core/internal/adapters/plane/client.go:236)는 `CreateWorkItem`을 제공하지만 pipeline provider interface는 creation facet을 받지 않는다.
해결 방법: canonical id lookup miss + missing provider identity input에서 `FormatPlaneProjection`으로 title/body를 만들고 `workitem.Creator.CreateWorkItem`을 호출한다. 생성 결과 ref를 slug identity row에 upsert하고, provider identity block injection 또는 ledger-only marker 중 05에서 정한 표준선에 맞춰 기록한다. 생성 후에는 same call에서 projection steps를 중복 실행하지 말고 created identity/next action을 명확히 반환한다.
수정 파일 및 체크리스트:
- [ ] `services/core/internal/roadmapsyncpipeline/service.go`
- [ ] `services/core/internal/roadmapsyncpipeline/service_test.go`
- [ ] `services/core/internal/roadmapsync/plane_format.go`
- [ ] `services/core/internal/roadmapsync/plane_format_test.go`
- [ ] `services/core/internal/workitem/provider.go`
- [ ] `services/core/internal/workitem/provider_test.go`
- [ ] `services/core/internal/adapters/plane/client.go`
- [ ] `services/core/internal/adapters/plane/client_test.go`
테스트 작성: 작성한다. fake creator가 한 번 호출되고, title prefix `[m1]`와 body projection이 전달되고, ledger upsert에 created work item id가 저장되는 pipeline test를 추가한다. Plane adapter httptest는 existing `CreateWorkItem` mapping이 유지되는지 보강한다.
중간 검증: `cd services/core && go test ./internal/roadmapsyncpipeline ./internal/adapters/plane ./internal/workitem`.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `services/core/internal/gitosync/scanner.go` | API-1 |
| `services/core/internal/gitosync/scanner_test.go` | API-1 |
| `services/core/internal/gitosync/bridge.go` | API-1 |
| `services/core/internal/gitosync/bridge_test.go` | API-1 |
| `services/core/internal/roadmapsyncpipeline/service.go` | API-2 |
| `services/core/internal/roadmapsyncpipeline/service_test.go` | API-2 |
| `services/core/internal/roadmapsync/plane_format.go` | API-2 |
| `services/core/internal/roadmapsync/plane_format_test.go` | API-2 |
| `services/core/internal/workitem/provider.go` | API-2 |
| `services/core/internal/workitem/provider_test.go` | API-2 |
| `services/core/internal/adapters/plane/client.go` | API-2 |
| `services/core/internal/adapters/plane/client_test.go` | API-2 |
## 최종 검증
`test -f agent-task/m-plane-origin-authoring-roundtrip-sync/05_canonical_id/complete.log || find agent-task/archive -path '*/m-plane-origin-authoring-roundtrip-sync/05_canonical_id/complete.log' -print -quit`
`cd services/core && go test ./internal/gitosync ./internal/roadmapsyncpipeline ./internal/adapters/plane ./internal/workitem`
`cd services/core && go test ./...`
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,120 @@
<!-- task=m-plane-origin-authoring-roundtrip-sync/06+05_plane_missing_create plan=1 tag=REVIEW_API -->
# Plan - REVIEW_API
## 이 파일을 읽는 구현 에이전트에게
구현 완료의 마지막 단계는 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우는 것이다. 선택된 SDD/Milestone 결정이 막을 때만 review stub의 `사용자 리뷰 요청`을 채운다. 직접 사용자에게 묻거나 `USER_REVIEW.md`, `complete.log`, archive 이동을 수행하지 않는다.
## 배경
리뷰 결과, bridge가 identity 없는 active Milestone을 missing-create job으로 enqueue할 때 `Expected.Provider=plane`을 채우지만, pipeline은 `Expected.Provider == ""`인 synthetic input에서만 `CreateWorkItem`을 호출한다. 그 결과 실제 bridge -> scheduler -> pipeline 경로에서는 `MatchDevelopMilestone`의 no scanned identity not-ready 경로로 빠지고 Plane item이 생성되지 않는다.
## 사용자 리뷰 요청 흐름
선택된 SDD 결정 또는 Milestone 잠금 결정만 review stub의 `사용자 리뷰 요청`에 기록한다. 외부 Plane credential 준비나 live service 접근 실패는 사용자 리뷰가 아니라 검증/환경 차단으로 기록한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-origin-authoring-roundtrip-sync.md`
- Task ids:
- `plane-missing-create`: active Milestone slug가 ledger와 Plane에서 발견되지 않으면 Plane work item을 생성하고, 생성된 Plane work item id를 slug ledger와 Milestone provider identity block 또는 동등한 sync marker에 기록한다.
- Completion mode: check-on-pass
## Spec Targets
- SDD: `agent-roadmap/sdd/agent-ops-mcp-control-plane/plane-origin-authoring-roundtrip-sync/SDD.md`
- Acceptance scenarios:
- `S11`: task=`plane-missing-create`; evidence=`Plane adapter/reconcile tests or smoke proving missing active Milestone creates one Plane item and records ledger/marker`
- Completion mode: spec-check-on-pass
## Archive Evidence Snapshot
- Archived plan: `agent-task/m-plane-origin-authoring-roundtrip-sync/06+05_plane_missing_create/plan_cloud_G06_0.log`
- Archived review: `agent-task/m-plane-origin-authoring-roundtrip-sync/06+05_plane_missing_create/code_review_cloud_G06_0.log`
- Verdict: FAIL
- Required summary:
- `services/core/internal/roadmapsyncpipeline/service.go:142` only enters missing-create when `Expected.Provider == ""`, while `services/core/internal/gitosync/bridge.go:145` sets `Expected.Provider` to `plane` for real identity-less active Milestone jobs.
- `services/core/internal/roadmapsyncpipeline/service_test.go:421` uses a provider-empty synthetic input, while `services/core/internal/gitosync/bridge_test.go:344` separately asserts provider=`plane`, leaving the actual bridge-produced pipeline contract untested.
- Affected files:
- `services/core/internal/roadmapsyncpipeline/service.go`
- `services/core/internal/roadmapsyncpipeline/service_test.go`
- `services/core/internal/gitosync/bridge.go`
- `services/core/internal/gitosync/bridge_test.go`
- Verification evidence from review:
- `cd services/core && go test -count=1 ./internal/gitosync ./internal/roadmapsyncpipeline` passed, demonstrating the current tests do not catch the contract mismatch.
- `cd services/core && go test -count=1 ./...` passed.
- `git diff --check` passed.
- Roadmap/spec carryover:
- Roadmap Task `plane-missing-create` remains incomplete until this follow-up proves actual missing active Milestone input creates one Plane item and records the created id in the slug ledger.
- SDD Acceptance Scenario `S11` remains incomplete for the same reason.
- Narrow archive reread allowed:
- `agent-task/m-plane-origin-authoring-roundtrip-sync/06+05_plane_missing_create/plan_cloud_G06_0.log`
- `agent-task/m-plane-origin-authoring-roundtrip-sync/06+05_plane_missing_create/code_review_cloud_G06_0.log`
## 분석 결과
### 리뷰 finding
- 실제 bridge missing-create job shape: `Expected.Provider=plane`, `Expected.Tenant=<workspace slug>`, `Expected.Project=<project id>`, `Expected.WorkItemID=""`, `Ref={}`.
- 현재 pipeline create gate: existing row not found and `Expected.Provider == ""`.
- 현재 matcher behavior: provider가 채워진 expected identity는 `Normalize` 이후 scanned provider identity를 요구하고, identity-less scan에서는 `reasonNoScannedIdentity`로 not-ready가 된다.
### 테스트 공백
기존 pipeline 테스트는 provider-empty input만 만들고, bridge 테스트는 enqueue args만 확인한다. 두 레이어를 잇는 job shape를 pipeline에 넣는 테스트가 없어 S11 핵심 경로가 통과하지 못해도 전체 Go test가 통과한다.
### 범위 결정 근거
이번 follow-up은 06 missing-create의 Required 이슈만 닫는다. 기존 05 canonical-id foundation, 07 slug-reconcile active task, live Plane smoke, broader projection/idempotency 확장은 범위 밖이다.
### 빌드 등급
`local-G05`: 수정 범위가 bridge/pipeline 계약과 단위 테스트로 좁고, 외부 live provider 없이 fake creator/store로 결정적으로 검증할 수 있다.
## 구현 체크리스트
- [ ] bridge-produced missing-create input과 pipeline create predicate를 맞춰, existing slug row가 없고 provider work item id가 비어 있는 active Milestone input이 `CreateWorkItem`을 호출하도록 수정한다.
- [ ] provider/tenant/project가 missing-create에 필요한 값인지 검증하고, work item id가 이미 있거나 existing row가 있으면 중복 create를 하지 않는 기존 경로를 유지한다.
- [ ] 실제 bridge missing-create job shape 또는 동등한 `SyncCreationInput`으로 `CreateWorkItem`이 정확히 한 번 호출되고 created work item id와 `develop_matched` ledger가 저장되는 테스트를 추가한다.
- [ ] `cd services/core && go test -count=1 ./internal/gitosync ./internal/roadmapsyncpipeline`와 `cd services/core && go test -count=1 ./...`, `git diff --check`를 통과시킨다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 의존 관계 및 구현 순서
1. `bridge_test.go`의 missing-create args 또는 helper를 기준으로 pipeline input fixture를 만든다.
2. `service.go`의 missing-create predicate를 provider empty가 아니라 missing provider work item id/ref 기준으로 바꾼다.
3. provider/tenant/project validation과 duplicate-prevention branch를 테스트로 고정한다.
### [REVIEW_API-1] Align Missing-Create Contract
문제: bridge와 pipeline이 서로 다른 missing-create input shape를 사용한다. 실제 bridge path는 provider를 채우므로 pipeline create branch가 실행되지 않는다.
해결 방법: `SyncCreation`에서 existing row가 없고 `Expected.WorkItemID`와 `Ref.ID`가 비어 있는 active Milestone을 missing-create로 판정한다. create input에는 `Expected.Provider`가 있으면 그 값을 쓰고, 비어 있으면 `plane`을 사용한다. `Tenant`와 `Project`는 비어 있으면 create 전에 명시 오류를 반환한다. existing row가 있거나 work item id가 있으면 기존 match/reconcile/projection 경로를 유지한다.
수정 파일 및 체크리스트:
- [ ] `services/core/internal/roadmapsyncpipeline/service.go`
- [ ] `services/core/internal/roadmapsyncpipeline/service_test.go`
- [ ] 필요 시 `services/core/internal/gitosync/bridge_test.go`
테스트 작성: 작성한다. provider=`plane`, tenant/project set, work item id empty, ref empty인 실제 bridge-produced shape가 `CreateWorkItem`을 한 번 호출하고, created id를 `roadmap_sync_identities` fake store에 저장하며, `develop_matched`만 mark하는지 검증한다. 기존 provider-empty synthetic test는 실제 path에 맞게 고치거나 보조 케이스로 유지한다.
중간 검증: `cd services/core && go test -count=1 ./internal/gitosync ./internal/roadmapsyncpipeline`.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `services/core/internal/roadmapsyncpipeline/service.go` | REVIEW_API-1 |
| `services/core/internal/roadmapsyncpipeline/service_test.go` | REVIEW_API-1 |
| `services/core/internal/gitosync/bridge_test.go` | REVIEW_API-1 |
## 최종 검증
`cd services/core && go test -count=1 ./internal/gitosync ./internal/roadmapsyncpipeline`
`cd services/core && go test -count=1 ./...`
`git diff --check`
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,165 @@
<!-- task=m-plane-origin-authoring-roundtrip-sync/07+05,06_slug_reconcile plan=1 tag=REVIEW_REFACTOR -->
# Code Review Reference - REVIEW_REFACTOR
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a selected SDD decision or selected Milestone `구현 잠금 > 결정 필요` item, fill `사용자 리뷰 요청` with linked evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation.
## 개요
date=2026-06-21
task=m-plane-origin-authoring-roundtrip-sync/07+05,06_slug_reconcile, plan=1, tag=REVIEW_REFACTOR
## Roadmap Targets
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-origin-authoring-roundtrip-sync.md`
- Task ids:
- `slug-reconcile`: 같은 Milestone slug 재처리는 중복 Plane item을 만들지 않고 path, provider work item id, revision, 완료 step만 갱신한다.
- Completion mode: check-on-pass
## Spec Targets
- SDD: `agent-roadmap/sdd/agent-ops-mcp-control-plane/plane-origin-authoring-roundtrip-sync/SDD.md`
- Acceptance scenarios:
- `S12`: task=`slug-reconcile`; evidence=`retry/idempotency tests for same slug reprocess, partial failure resume, and slug conflict/reuse failure`
- Completion mode: spec-check-on-pass
## Archive Evidence Snapshot
- Current archived plan: `agent-task/m-plane-origin-authoring-roundtrip-sync/07+05,06_slug_reconcile/plan_cloud_G05_0.log`
- Current archived review: `agent-task/m-plane-origin-authoring-roundtrip-sync/07+05,06_slug_reconcile/code_review_cloud_G05_0.log`
- Verdict: FAIL
- Required summary:
- `services/core/internal/roadmapsyncpipeline/service.go:128`: existing row lookup overwrites incoming trigger identity/ref, so provider projection can target the stale persisted Plane item while the ledger upsert records the new work item id.
- `services/core/internal/roadmapsyncpipeline/service.go:265`: `ActionComplete` returns before safe attribute upsert, so all-steps-complete same-slug reprocesses do not update path/work item/revision attributes.
- Suggested/Nit summary: 없음
- Affected files:
- `services/core/internal/roadmapsyncpipeline/service.go`
- `services/core/internal/roadmapsyncpipeline/service_test.go`
- 필요 시 `services/core/internal/roadmapsync/retry.go` 또는 `services/core/internal/roadmapsync/retry_test.go`
- Verification evidence from failed loop:
- `go test -count=1 ./internal/gitosync ./internal/roadmapsync ./internal/storage ./internal/roadmapsyncpipeline` in `services/core`: PASS
- `go test -count=1 ./...` in `services/core`: PASS
- `git diff --check`: PASS after review-only EOF blank-line cleanup
- Roadmap carryover: `slug-reconcile`
- Spec carryover: `S12`
- Narrow archive reread allowed: if exact prior context is needed, read only the two archived files listed above.
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
1. 판정을 append한다.
2. `CODE_REVIEW-local-G05.md` -> `code_review_local_G05_N.log`, `PLAN-local-G05.md` -> `plan_local_G05_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 archive로 이동한다.
4. PASS이고 task group이 `m-plane-origin-authoring-roundtrip-sync`이면 완료 이벤트 메타데이터를 보고하고 roadmap은 직접 수정하지 않는다.
5. WARN/FAIL이면 후속 plan 또는 USER_REVIEW gate를 처리한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_REFACTOR-1] Preserve Trigger Ref For Provider Projection | [ ] |
| [REVIEW_REFACTOR-2] Update Attributes Before Complete No-Op | [ ] |
## 구현 체크리스트
- [ ] 기존 identity row가 있어도 `SyncCreation`이 incoming trigger identity/ref를 stale persisted row로 덮어쓰지 않고, provider projection target이 matched candidate의 최신 provider work item id/path를 사용하도록 수정한다.
- [ ] all-steps-complete same-slug 재처리에서도 provider call 없이 safe attribute upsert가 수행되어 path, provider work item id, revision이 갱신되도록 수정한다.
- [ ] `roadmapsyncpipeline` tests에 changed provider work item id 재처리 provider `Ref` 검증과 completed-cycle attribute update/no-provider-call 검증을 추가한다.
- [ ] `cd services/core && go test -count=1 ./internal/roadmapsyncpipeline``cd services/core && go test -count=1 ./internal/gitosync ./internal/roadmapsync ./internal/storage ./internal/roadmapsyncpipeline``cd services/core && go test -count=1 ./...``git diff --check`를 통과시킨다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [ ] `코드리뷰 결과``PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [ ] active `CODE_REVIEW-*-G??.md``code_review_local_G05_N.log`로 아카이브한다.
- [ ] active `PLAN-*-G??.md``plan_local_G05_M.log`로 아카이브한다.
- [ ] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md``agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [ ] PASS이면 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리를 archive로 이동한다.
- [ ] PASS이고 task group이 `m-plane-origin-authoring-roundtrip-sync`이면 런타임 완료 이벤트 메타데이터를 보고하고 roadmap 수정은 하지 않는다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active plan/review를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW이면 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 SDD 결정 또는 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- provider projection ref가 existing row의 stale work item id가 아니라 matched candidate의 최신 work item id를 사용하는지 확인한다.
- all-steps-complete same-slug reprocess가 provider side effect 없이 attribute row를 갱신하는지 확인한다.
- revision mismatch conflict guard가 upsert 전에 계속 막히는지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
### REVIEW_REFACTOR-1 중간 검증
```text
$ cd services/core && go test -count=1 ./internal/roadmapsyncpipeline
(output)
```
### REVIEW_REFACTOR-2 중간 검증
```text
$ cd services/core && go test -count=1 ./internal/roadmapsyncpipeline
(output)
```
### 최종 검증
```text
$ cd services/core && go test -count=1 ./internal/roadmapsyncpipeline
(output)
$ cd services/core && go test -count=1 ./internal/gitosync ./internal/roadmapsync ./internal/storage ./internal/roadmapsyncpipeline
(output)
$ cd services/core && go test -count=1 ./...
(output)
$ git diff --check
(output)
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.

View file

@ -0,0 +1,115 @@
<!-- task=m-plane-origin-authoring-roundtrip-sync/07+05,06_slug_reconcile plan=1 tag=REVIEW_REFACTOR -->
# Plan - REVIEW_REFACTOR
## 이 파일을 읽는 구현 에이전트에게
이 후속 계획은 이전 review의 Required 2건만 닫는다. 직접 사용자에게 질문하지 말고, 새 결정이 필요한 경우에도 선택된 SDD 결정 또는 Milestone lock 결정만 `CODE_REVIEW-*-G??.md``사용자 리뷰 요청`에 기록한다. 이번 범위는 repo-fixable code/test follow-up이므로 기본적으로 `USER_REVIEW.md` 대상이 아니다.
## 배경
이전 구현은 같은 filename slug 재처리에서 canonical id 중심 lookup과 conflict mapping을 추가했지만, 기존 identity row가 있을 때 provider projection target과 completed-cycle safe attribute update가 계획/SDD S12를 완전히 만족하지 못한다. 같은 slug 재처리는 중복 Plane item을 만들지 않으면서 path, provider work item id, revision, completed step ledger를 갱신해야 한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-origin-authoring-roundtrip-sync.md`
- Task ids:
- `slug-reconcile`: 같은 Milestone slug 재처리는 중복 Plane item을 만들지 않고 path, provider work item id, revision, 완료 step만 갱신한다.
- Completion mode: check-on-pass
## Spec Targets
- SDD: `agent-roadmap/sdd/agent-ops-mcp-control-plane/plane-origin-authoring-roundtrip-sync/SDD.md`
- Acceptance scenarios:
- `S12`: task=`slug-reconcile`; evidence=`retry/idempotency tests for same slug reprocess, partial failure resume, and slug conflict/reuse failure`
- Completion mode: spec-check-on-pass
## Archive Evidence Snapshot
- Current archived plan: `agent-task/m-plane-origin-authoring-roundtrip-sync/07+05,06_slug_reconcile/plan_cloud_G05_0.log`
- Current archived review: `agent-task/m-plane-origin-authoring-roundtrip-sync/07+05,06_slug_reconcile/code_review_cloud_G05_0.log`
- Verdict: FAIL
- Required summary:
- `services/core/internal/roadmapsyncpipeline/service.go:128`: existing row lookup overwrites incoming trigger identity/ref, so provider projection can target the stale persisted Plane item while the ledger upsert records the new work item id.
- `services/core/internal/roadmapsyncpipeline/service.go:265`: `ActionComplete` returns before safe attribute upsert, so all-steps-complete same-slug reprocesses do not update path/work item/revision attributes.
- Suggested/Nit summary: 없음
- Affected files:
- `services/core/internal/roadmapsyncpipeline/service.go`
- `services/core/internal/roadmapsyncpipeline/service_test.go`
- 필요 시 `services/core/internal/roadmapsync/retry.go` 또는 `services/core/internal/roadmapsync/retry_test.go`
- Verification evidence from failed loop:
- `go test -count=1 ./internal/gitosync ./internal/roadmapsync ./internal/storage ./internal/roadmapsyncpipeline` in `services/core`: PASS
- `go test -count=1 ./...` in `services/core`: PASS
- `git diff --check`: PASS after review-only EOF blank-line cleanup
- Roadmap carryover: `slug-reconcile`
- Spec carryover: `S12`
- Narrow archive reread allowed: if exact prior context is needed, read only the two archived files listed above.
## 분석 결과
### 범위 결정 근거
이 후속은 `SyncCreation` orchestration과 해당 unit tests에 한정한다. Scanner duplicate active slug detection, storage SQL unique index, live Plane smoke, Gito delivery, Plane Todo projection 전체 smoke는 이번 Required의 직접 수정 범위가 아니다.
### 빌드 등급
`local-G05`: bounded Go service/test 수정이며 provider side effect target과 complete-row update를 fake tests로 결정적으로 검증할 수 있다.
## 구현 체크리스트
- [ ] 기존 identity row가 있어도 `SyncCreation`이 incoming trigger identity/ref를 stale persisted row로 덮어쓰지 않고, provider projection target이 matched candidate의 최신 provider work item id/path를 사용하도록 수정한다.
- [ ] all-steps-complete same-slug 재처리에서도 provider call 없이 safe attribute upsert가 수행되어 path, provider work item id, revision이 갱신되도록 수정한다.
- [ ] `roadmapsyncpipeline` tests에 changed provider work item id 재처리 provider `Ref` 검증과 completed-cycle attribute update/no-provider-call 검증을 추가한다.
- [ ] `cd services/core && go test -count=1 ./internal/roadmapsyncpipeline``cd services/core && go test -count=1 ./internal/gitosync ./internal/roadmapsync ./internal/storage ./internal/roadmapsyncpipeline``cd services/core && go test -count=1 ./...``git diff --check`를 통과시킨다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 의존 관계 및 구현 순서
이 plan은 기존 `07+05,06_slug_reconcile` follow-up이다. 선행 complete evidence는 이전 loop에서 확인되었으므로 새 archive 검색을 반복하지 않는다.
### [REVIEW_REFACTOR-1] Preserve Trigger Ref For Provider Projection
문제: `SyncCreation`이 existing row를 milestone id로 찾은 뒤 `in.Expected``in.Ref`를 persisted row 값으로 덮어쓴다. 이후 `MatchDevelopMilestone`은 scanned identity의 새 work item id를 candidate로 만들 수 있지만, provider projection input은 stale `in.Ref`를 사용한다.
해결 방법: persisted row는 `ExistingIdentity`/revision/step ledger 판단에만 사용하고, incoming trigger identity/ref는 별도로 보존한다. Projection ref는 normalized `candidate.Identity`에서 만들거나, incoming ref가 candidate와 일치하면 incoming ref를 사용한다. tenant/project fallback이 필요하면 existing row를 "빈 필드 보충"에만 사용하고 새 work item id를 덮지 않는다.
수정 파일 및 체크리스트:
- [ ] `services/core/internal/roadmapsyncpipeline/service.go`
- [ ] `services/core/internal/roadmapsyncpipeline/service_test.go`
테스트 작성: 작성한다. fake provider가 comment/body/status input ref를 기록하도록 확장하고, existing row의 `WorkItemID=NOMAD-OLD`, scanned/incoming identity의 `WorkItemID=NOMAD-1`인 partial resume에서 provider calls가 `NOMAD-1`을 대상으로 실행되고 row도 `NOMAD-1`로 갱신되는지 확인한다.
중간 검증: `cd services/core && go test -count=1 ./internal/roadmapsyncpipeline`.
### [REVIEW_REFACTOR-2] Update Attributes Before Complete No-Op
문제: `ReconcileCreationCycle``ActionComplete`를 반환하면 `SyncCreation`이 즉시 `SyncActionComplete`로 빠져 safe attribute upsert를 실행하지 않는다. 모든 step이 완료된 same-slug reprocess에서 path/work item/revision 변경을 ledger row에 반영하지 못한다.
해결 방법: reconcile이 conflict/self-mutation이 아니고 existing row가 있는 경우, complete 반환 전에도 candidate identity와 trigger revisions가 persisted row와 다르면 provider side effect 없이 upsert한다. 다만 revision mismatch conflict는 기존처럼 upsert 전에 차단해야 한다.
수정 파일 및 체크리스트:
- [ ] `services/core/internal/roadmapsyncpipeline/service.go`
- [ ] `services/core/internal/roadmapsyncpipeline/service_test.go`
테스트 작성: 작성한다. completed set에 `develop_matched`, `original_comment_preserved`, `plane_body_updated`, `plane_todo_moved`가 모두 있는 existing row에서 scanned path/work item id/revision이 바뀐 입력을 넣고, 결과는 `SyncActionComplete`, provider calls는 없음, store upsert는 최신 path/work item id/revision으로 1회 발생해야 한다.
중간 검증: `cd services/core && go test -count=1 ./internal/roadmapsyncpipeline`.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `services/core/internal/roadmapsyncpipeline/service.go` | REVIEW_REFACTOR-1, REVIEW_REFACTOR-2 |
| `services/core/internal/roadmapsyncpipeline/service_test.go` | REVIEW_REFACTOR-1, REVIEW_REFACTOR-2 |
## 최종 검증
`cd services/core && go test -count=1 ./internal/roadmapsyncpipeline`
`cd services/core && go test -count=1 ./internal/gitosync ./internal/roadmapsync ./internal/storage ./internal/roadmapsyncpipeline`
`cd services/core && go test -count=1 ./...`
`git diff --check`
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,182 @@
<!-- task=m-plane-origin-authoring-roundtrip-sync/07+05,06_slug_reconcile/ PLAN-cloud-G05.md plan=0 tag=REFACTOR -->
# Code Review Reference - REFACTOR
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a selected SDD decision or selected Milestone `구현 잠금 > 결정 필요` item, fill `사용자 리뷰 요청` with linked evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`.
## 개요
date=2026-06-21
task=m-plane-origin-authoring-roundtrip-sync/07+05,06_slug_reconcile, plan=0, tag=REFACTOR
## Roadmap Targets
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-origin-authoring-roundtrip-sync.md`
- Task ids:
- `slug-reconcile`: 같은 Milestone slug 재처리는 중복 Plane item을 만들지 않고 path, provider work item id, revision, 완료 step만 갱신한다.
- Completion mode: check-on-pass
## Spec Targets
- SDD: `agent-roadmap/sdd/agent-ops-mcp-control-plane/plane-origin-authoring-roundtrip-sync/SDD.md`
- Acceptance scenarios:
- `S12`: task=`slug-reconcile`; evidence=`retry/idempotency tests for same slug reprocess, partial failure resume, and slug conflict/reuse failure`
- Completion mode: spec-check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
1. 판정을 append한다.
2. `CODE_REVIEW-cloud-G05.md` -> `code_review_cloud_G05_N.log`, `PLAN-cloud-G05.md` -> `plan_cloud_G05_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 archive로 이동한다.
4. PASS이고 task group이 `m-plane-origin-authoring-roundtrip-sync`이면 완료 이벤트 메타데이터를 보고하고 roadmap은 직접 수정하지 않는다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REFACTOR-1] Duplicate Slug Detection | [x] |
| [REFACTOR-2] Same Slug Retry And Conflict Semantics | [x] |
## 구현 체크리스트
- [x] 선행 `05_canonical_id`와 `06+05_plane_missing_create` complete evidence를 확인하고 없으면 구현하지 않는다.
- [x] scanner 또는 roadmapsync active index layer에서 duplicate active Milestone slug를 감지해 명시 failure로 반환한다.
- [x] `ReconcileCreationCycle`/pipeline tests를 같은 slug same revision, partial failure resume, changed path/provider work item attribute update 케이스로 보강한다.
- [x] slug conflict/reuse/rename 의심은 자동 병합하지 않고 typed conflict action/reason으로 멈추게 한다.
- [x] `cd services/core && go test ./internal/gitosync ./internal/roadmapsync ./internal/storage ./internal/roadmapsyncpipeline`와 `cd services/core && go test ./...`를 통과시킨다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G05_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md` to `plan_cloud_G05_M.log`로 아카이브한다.
- [ ] PASS이면 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리를 archive로 이동한다.
- [ ] PASS이고 task group이 `m-plane-origin-authoring-roundtrip-sync`이면 런타임 완료 이벤트 메타데이터를 보고하고 roadmap 수정은 하지 않는다.
- [x] WARN/FAIL이면 후속 plan 또는 USER_REVIEW gate를 처리한다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
- 없음. 계획된 중복 active slug 차단 검증, 느슨한(canonical) ID 매칭 적용을 통한 safe attribute update 및 db conflict 예외 래핑(conflict action)을 정상 구현 완료함.
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
- `Scanner`에서 `git ls-tree`로 추출한 변경된 active milestone 목록들을 `git show`하기 전에 파일명 slug(canonical id) 단위로 중복을 검사하도록 구현하여, 리소스 낭비 전에 빠른 실패(`ErrDuplicateActiveSlug`)를 수행하게 했습니다.
- `ReconcileCreationCycle`에서 matching 방식을 `MatchesStrict`에서 `Matches`로 수정하여 `Tenant`, `Project`에 독립적으로 `Provider`와 `MilestoneID`가 같으면 동일 identity로 판정하여 target milestone의 Safe Attribute Update(tenant, project, path, work item id 등)가 DB upsert 레벨에서 업데이트되도록 허용했습니다.
- `UpsertRoadmapSyncIdentity`에서 발생하는 `ErrRoadmapSyncIdentityConflict`를 잡아서 `SyncActionConflict`와 이유를 정상적으로 반환하도록 하여, 중복 work_item_id가 다른 milestone에 매핑되려는 filename reuse 등의 충돌 상황을 typed conflict action으로 멈추도록 보장했습니다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 SDD 결정 또는 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- duplicate active slug가 자동 병합되지 않는지 확인한다.
- same slug retry가 중복 Plane item이나 중복 provider call 없이 ledger를 재개하는지 확인한다.
- revision mismatch guard는 유지되는지 확인한다.
## 검증 결과
### REFACTOR-1 중간 검증
```text
$ cd services/core && go test -count=1 ./internal/gitosync
ok github.com/nomadcode/nomadcode-core/internal/gitosync 1.526s
```
### REFACTOR-2 중간 검증
```text
$ cd services/core && go test -count=1 ./internal/roadmapsync ./internal/storage ./internal/roadmapsyncpipeline
ok github.com/nomadcode/nomadcode-core/internal/roadmapsync 0.003s
ok github.com/nomadcode/nomadcode-core/internal/storage 0.003s
ok github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline 0.004s
```
### 최종 검증
```text
$ test -f agent-task/m-plane-origin-authoring-roundtrip-sync/05_canonical_id/complete.log || find agent-task/archive -path '*/m-plane-origin-authoring-roundtrip-sync/05_canonical_id/complete.log' -print -quit
agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/05_canonical_id/complete.log
$ test -f agent-task/m-plane-origin-authoring-roundtrip-sync/06+05_plane_missing_create/complete.log || find agent-task/archive -path '*/m-plane-origin-authoring-roundtrip-sync/06+05_plane_missing_create/complete.log' -print -quit
agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/06+05_plane_missing_create/complete.log
$ cd services/core && go test -count=1 ./internal/gitosync ./internal/roadmapsync ./internal/storage ./internal/roadmapsyncpipeline
ok github.com/nomadcode/nomadcode-core/internal/gitosync 1.415s
ok github.com/nomadcode/nomadcode-core/internal/roadmapsync 0.007s
ok github.com/nomadcode/nomadcode-core/internal/storage 0.005s
ok github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline 0.004s
$ cd services/core && go test -count=1 ./...
ok github.com/nomadcode/nomadcode-core/cmd/plane-smoke 0.010s
ok github.com/nomadcode/nomadcode-core/cmd/server 0.009s
ok github.com/nomadcode/nomadcode-core/internal/adapters/a2a 0.010s
ok github.com/nomadcode/nomadcode-core/internal/adapters/jira 0.012s
ok github.com/nomadcode/nomadcode-core/internal/adapters/mattermost 0.008s
ok github.com/nomadcode/nomadcode-core/internal/adapters/openai 0.017s
ok github.com/nomadcode/nomadcode-core/internal/adapters/plane 0.019s
? github.com/nomadcode/nomadcode-core/internal/agent [no test files]
ok github.com/nomadcode/nomadcode-core/internal/authoring 0.656s
ok github.com/nomadcode/nomadcode-core/internal/config 0.003s
? github.com/nomadcode/nomadcode-core/internal/db [no test files]
ok github.com/nomadcode/nomadcode-core/internal/gitoevents 0.006s
ok github.com/nomadcode/nomadcode-core/internal/gitosync 1.421s
ok github.com/nomadcode/nomadcode-core/internal/http 0.014s
? github.com/nomadcode/nomadcode-core/internal/model [no test files]
ok github.com/nomadcode/nomadcode-core/internal/notification 0.006s
ok github.com/nomadcode/nomadcode-core/internal/projectsync 0.012s
ok github.com/nomadcode/nomadcode-core/internal/protosocket 0.017s
ok github.com/nomadcode/nomadcode-core/internal/roadmapsync 0.007s
ok github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline 0.006s
ok github.com/nomadcode/nomadcode-core/internal/scheduler 2.041s
ok github.com/nomadcode/nomadcode-core/internal/storage 0.004s
ok github.com/nomadcode/nomadcode-core/internal/workflow 0.004s
ok github.com/nomadcode/nomadcode-core/internal/workitem 0.004s
ok github.com/nomadcode/nomadcode-core/internal/workitempipeline 0.004s
```
## 코드리뷰 결과
- 종합 판정: FAIL
- 차원별 평가:
- correctness: Fail
- completeness: Fail
- test coverage: Fail
- API contract: Fail
- code quality: Pass
- plan deviation: Fail
- verification trust: Pass
- spec conformance: Fail
- 발견된 문제:
- Required: `services/core/internal/roadmapsyncpipeline/service.go:128`에서 기존 milestone row를 찾으면 `in.Expected`와 `in.Ref`를 persisted row 값으로 덮어쓴 뒤, `services/core/internal/roadmapsyncpipeline/service.go:226`의 provider projection input이 그 stale `Ref`를 그대로 사용합니다. 같은 slug 재처리에서 새 provider work item id가 들어온 경우 `candidate.Identity`와 upsert는 새 work item id로 갈 수 있지만 comment/body/status provider call은 기존 Plane item을 대상으로 실행되어, 계획의 "provider work item attribute update"와 SDD S12의 duplicate/reuse guard를 깨뜨립니다. 수정은 persisted row를 reconcile용 existing view로만 사용하고, projection target ref는 matched `candidate.Identity` 또는 incoming ref에서 새 provider work item id를 보존하도록 분리해야 합니다. fake provider가 `CommentInput`/`BodyProjection`/`StatusProjection`의 `Ref`를 기록하고, changed work item id 재처리에서 새 ref가 사용되는 테스트가 필요합니다.
- Required: `services/core/internal/roadmapsyncpipeline/service.go:265`의 `ActionComplete` 분기는 `services/core/internal/roadmapsyncpipeline/service.go:278` 이후의 safe attribute upsert 블록에 도달하기 전에 반환합니다. 따라서 같은 slug가 재처리되고 모든 step ledger가 이미 완료된 경우 path/provider work item id/revision 변경이 row에 반영되지 않습니다. 계획과 Roadmap Target은 "같은 Milestone slug 재처리는 path, provider work item id, revision, 완료 step을 갱신"해야 하므로, complete no-op 전에도 reconcile이 통과한 safe attribute update는 수행해야 합니다. 모든 step이 complete인 row에서 path/work item/revision만 바뀐 입력을 넣고 provider call 없이 row update가 일어나는 테스트가 필요합니다.
- 검증:
- `go test -count=1 ./internal/gitosync ./internal/roadmapsync ./internal/storage ./internal/roadmapsyncpipeline` (workdir=`services/core`) PASS
- `go test -count=1 ./...` (workdir=`services/core`) PASS
- `git diff --check`는 최초 EOF blank line 2건을 확인했고, 비동작성 EOF 정리 후 PASS
- 다음 단계: FAIL 후속 `PLAN-*-G??.md`와 `CODE_REVIEW-*-G??.md`를 작성한다.

View file

@ -0,0 +1,148 @@
<!-- task=m-plane-origin-authoring-roundtrip-sync/07+05,06_slug_reconcile plan=0 tag=REFACTOR -->
# Plan - REFACTOR
## 이 파일을 읽는 구현 에이전트에게
`05_canonical_id`와 `06+05_plane_missing_create`의 `complete.log`가 없으면 구현을 시작하지 않는다. 구현 완료의 마지막 단계는 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채우는 것이다. finalization은 code-review 전용이며, 사용자 결정이 필요한 SDD/Milestone lock만 review stub의 `사용자 리뷰 요청`에 기록한다.
## 배경
같은 filename slug 재처리는 path/provider work item id/revision/step ledger만 갱신하거나 남은 step을 재개해야 한다. 동시에 active index 안에 같은 slug가 둘 이상 있거나 filename 재사용/rename 의심이 있으면 자동 병합하면 안 된다. SDD S12는 duplicate Plane item 없이 resume하고 slug conflict/reuse는 명시 failure로 멈추라고 요구한다.
## 사용자 리뷰 요청 흐름
구현 중 새 결정이 필요해 보여도 직접 질문하지 않는다. 선택된 SDD/Milestone lock 결정만 review stub의 `사용자 리뷰 요청`에 기록한다. 충돌 처리 구현 중 환경/검증 공백은 후속 plan 또는 검증 결과로 남긴다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-origin-authoring-roundtrip-sync.md`
- Task ids:
- `slug-reconcile`: 같은 Milestone slug 재처리는 중복 Plane item을 만들지 않고 path, provider work item id, revision, 완료 step만 갱신한다.
- Completion mode: check-on-pass
## Spec Targets
- SDD: `agent-roadmap/sdd/agent-ops-mcp-control-plane/plane-origin-authoring-roundtrip-sync/SDD.md`
- Acceptance scenarios:
- `S12`: task=`slug-reconcile`; evidence=`retry/idempotency tests for same slug reprocess, partial failure resume, and slug conflict/reuse failure`
- Completion mode: spec-check-on-pass
## 분석 결과
### 읽은 파일
- `agent-test/local/rules.md`
- `agent-test/local/core-smoke.md`
- `agent-test/local/workspace-ops-smoke.md`
- `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-origin-authoring-roundtrip-sync.md`
- `agent-roadmap/sdd/agent-ops-mcp-control-plane/plane-origin-authoring-roundtrip-sync/SDD.md`
- `services/core/internal/gitosync/scanner.go`
- `services/core/internal/gitosync/scanner_test.go`
- `services/core/internal/roadmapsync/identity.go`
- `services/core/internal/roadmapsync/identity_test.go`
- `services/core/internal/roadmapsync/retry.go`
- `services/core/internal/roadmapsync/retry_test.go`
- `services/core/internal/roadmapsyncpipeline/service.go`
- `services/core/internal/roadmapsyncpipeline/service_test.go`
- `services/core/internal/storage/roadmap_sync_identities.go`
- `services/core/internal/storage/roadmap_sync_identities_test.go`
### 테스트 환경 규칙
test_env=`local`. core 변경 최소 검증은 `cd services/core && go test ./...`다. 이 task는 live Plane smoke가 아니라 retry/idempotency unit tests로 닫는다.
### 테스트 커버리지 공백
- same slug + same revision 재처리에서 completed step ledger가 중복 provider call을 막는 테스트가 부족하다.
- same slug + partial failure resume이 path/provider work item id 속성 갱신과 충돌하지 않는 테스트가 부족하다.
- duplicate active slug 또는 filename reuse/rename 의심을 명시 failure로 반환하는 테스트가 없다.
### 심볼 참조
- 변경 후보: `ReconcileCreationCycle`, `ExistingIdentity`, `SyncCreation`, storage conflict mapping, scanner active index duplicate detection.
- removed/renamed symbol은 계획 단계에서는 없음.
### 분할 판단
split decision policy를 적용했다. 이 subtask는 `07+05,06_slug_reconcile`이며 predecessor `05_canonical_id`와 `06+05_plane_missing_create`가 필요하다. 현재 active/complete predecessor는 없으므로 구현 전 두 predecessor의 active 또는 archive `complete.log`를 확인해야 한다. slug conflict/retry behavior는 05/06 위에서만 의미가 있으므로 별도 dependent plan이 맞다.
### 범위 결정 근거
이 subtask는 duplicate prevention과 conflict/resume semantics만 다룬다. Plane live UI/API evidence, slot finalizer, full-cycle smoke는 제외한다. `gito-delivery`, `scan-enqueue`, `plane-todo`, `slot-finalize` Task는 후속 Epic 작업이다.
### 빌드 등급
`cloud-G05`: retry/idempotency behavior가 cross-module이지만 05/06 foundation 뒤에는 unit/fake tests로 닫을 수 있다.
## 구현 체크리스트
- [ ] 선행 `05_canonical_id`와 `06+05_plane_missing_create` complete evidence를 확인하고 없으면 구현하지 않는다.
- [ ] scanner 또는 roadmapsync active index layer에서 duplicate active Milestone slug를 감지해 명시 failure로 반환한다.
- [ ] `ReconcileCreationCycle`/pipeline tests를 같은 slug same revision, partial failure resume, changed path/provider work item attribute update 케이스로 보강한다.
- [ ] slug conflict/reuse/rename 의심은 자동 병합하지 않고 typed conflict action/reason으로 멈추게 한다.
- [ ] `cd services/core && go test ./internal/gitosync ./internal/roadmapsync ./internal/storage ./internal/roadmapsyncpipeline`와 `cd services/core && go test ./...`를 통과시킨다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 의존 관계 및 구현 순서
`07+05,06_slug_reconcile`는 디렉터리명 기준으로 predecessor `05_canonical_id`, `06+05_plane_missing_create`에 의존한다. 구현 전 두 predecessor의 `complete.log`가 active 또는 archive에 있어야 한다. 누락이면 구현을 시작하지 않는다.
### [REFACTOR-1] Duplicate Slug Detection
문제: active scanner는 현재 active paths를 나열하지만 같은 filename slug가 두 active phase에 동시에 있는 경우를 명시 conflict로 모델링하지 않는다. SDD S12는 slug 충돌 또는 파일명 재사용을 자동 병합하지 말라고 한다.
해결 방법: active index layer에서 `MilestoneID`별 path set을 만들고 같은 id가 둘 이상이면 typed error/result를 반환한다. archive candidate는 충돌 대상이 아니라 history lookup 후보로만 남긴다.
수정 파일 및 체크리스트:
- [ ] `services/core/internal/gitosync/scanner.go`
- [ ] `services/core/internal/gitosync/scanner_test.go`
- [ ] 필요 시 `services/core/internal/roadmapsync/identity.go`
테스트 작성: 작성한다. 두 active path가 같은 `m1.md`를 가질 때 scanner가 enqueue 없이 명시 error를 반환하고, active+archive 같은 slug는 error가 아닌 archive candidate로 남는 테스트를 추가한다.
중간 검증: `cd services/core && go test ./internal/gitosync`.
### [REFACTOR-2] Same Slug Retry And Conflict Semantics
문제: [retry.go](/config/workspace/nomadcode/services/core/internal/roadmapsync/retry.go:106)는 persisted identity와 trigger가 strict match하지 않으면 conflict를 반환한다. 05 이후 strict match는 provider/work item/path가 아니라 canonical slug 중심이어야 하며, revision mismatch와 duplicate slug는 여전히 conflict여야 한다.
해결 방법: `ExistingIdentity` 비교를 canonical id 중심으로 바꾸고, path/provider work item id는 safe attribute update로 허용한다. same slug + same revision + completed steps는 complete/resume하고, same slug + different roadmap revision은 기존 guard처럼 conflict한다. filename reuse 의심은 scanner/pipeline의 typed conflict reason으로 전달한다.
수정 파일 및 체크리스트:
- [ ] `services/core/internal/roadmapsync/retry.go`
- [ ] `services/core/internal/roadmapsync/retry_test.go`
- [ ] `services/core/internal/storage/roadmap_sync_identities.go`
- [ ] `services/core/internal/storage/roadmap_sync_identities_test.go`
- [ ] `services/core/internal/roadmapsyncpipeline/service.go`
- [ ] `services/core/internal/roadmapsyncpipeline/service_test.go`
테스트 작성: 작성한다. same slug/same revision no duplicate provider call, partial completed ledger resumes at first incomplete step, changed provider work item id updates row without creating a second Plane item, different roadmap revision conflicts.
중간 검증: `cd services/core && go test ./internal/roadmapsync ./internal/storage ./internal/roadmapsyncpipeline`.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `services/core/internal/gitosync/scanner.go` | REFACTOR-1 |
| `services/core/internal/gitosync/scanner_test.go` | REFACTOR-1 |
| `services/core/internal/roadmapsync/identity.go` | REFACTOR-1 |
| `services/core/internal/roadmapsync/retry.go` | REFACTOR-2 |
| `services/core/internal/roadmapsync/retry_test.go` | REFACTOR-2 |
| `services/core/internal/storage/roadmap_sync_identities.go` | REFACTOR-2 |
| `services/core/internal/storage/roadmap_sync_identities_test.go` | REFACTOR-2 |
| `services/core/internal/roadmapsyncpipeline/service.go` | REFACTOR-2 |
| `services/core/internal/roadmapsyncpipeline/service_test.go` | REFACTOR-2 |
## 최종 검증
`test -f agent-task/m-plane-origin-authoring-roundtrip-sync/05_canonical_id/complete.log || find agent-task/archive -path '*/m-plane-origin-authoring-roundtrip-sync/05_canonical_id/complete.log' -print -quit`
`test -f agent-task/m-plane-origin-authoring-roundtrip-sync/06+05_plane_missing_create/complete.log || find agent-task/archive -path '*/m-plane-origin-authoring-roundtrip-sync/06+05_plane_missing_create/complete.log' -print -quit`
`cd services/core && go test ./internal/gitosync ./internal/roadmapsync ./internal/storage ./internal/roadmapsyncpipeline`
`cd services/core && go test ./...`
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -236,6 +236,8 @@ func newGitoBridge(cfg config.Config, reader *plane.Client, enqueuer *scheduler.
RepoID: cfg.GitoRepoID, RepoID: cfg.GitoRepoID,
Branch: cfg.GitoBranch, Branch: cfg.GitoBranch,
TodoStateID: cfg.RoadmapCreationTodoStateID, TodoStateID: cfg.RoadmapCreationTodoStateID,
Tenant: cfg.PlaneWorkspaceSlug,
Project: cfg.PlaneProjectID,
}, logger) }, logger)
} }

View file

@ -124,8 +124,11 @@ func matchedScanOutput() gitosync.ScanOutput {
}, },
Docs: []gitosync.ScannedMilestoneDoc{ Docs: []gitosync.ScannedMilestoneDoc{
{ {
Milestone: scanned, Path: scanned.Path,
Markdown: "# Milestone Title\n\nbody content", MilestoneID: "gito-http-webhook-consumer-readiness",
Markdown: "# Milestone Title\n\nbody content",
HasIdentity: true,
Identity: scanned.Identity,
}, },
}, },
} }

View file

@ -48,6 +48,7 @@ type Config struct {
GitoDevelopRepoPath string GitoDevelopRepoPath string
GitoRemoteName string GitoRemoteName string
RoadmapCreationTodoStateID string RoadmapCreationTodoStateID string
PlaneProjectID string
ModelResponsesStream bool ModelResponsesStream bool
} }
@ -116,6 +117,7 @@ func Load() Config {
GitoDevelopRepoPath: os.Getenv("GITO_DEVELOP_REPO_PATH"), GitoDevelopRepoPath: os.Getenv("GITO_DEVELOP_REPO_PATH"),
GitoRemoteName: getEnv("GITO_REMOTE_NAME", "origin"), GitoRemoteName: getEnv("GITO_REMOTE_NAME", "origin"),
RoadmapCreationTodoStateID: firstEnv("ROADMAP_CREATION_TODO_STATE_ID", "PLANE_TODO_STATE_ID"), RoadmapCreationTodoStateID: firstEnv("ROADMAP_CREATION_TODO_STATE_ID", "PLANE_TODO_STATE_ID"),
PlaneProjectID: os.Getenv("PLANE_PROJECT_ID"),
ModelResponsesStream: getEnvBool("MODEL_RESPONSES_STREAM", false), ModelResponsesStream: getEnvBool("MODEL_RESPONSES_STREAM", false),
} }
} }

View file

@ -9,14 +9,14 @@ import (
"context" "context"
) )
const getRoadmapSyncIdentityByMilestonePath = `-- name: GetRoadmapSyncIdentityByMilestonePath :one const getRoadmapSyncIdentityByMilestoneID = `-- name: GetRoadmapSyncIdentityByMilestoneID :one
SELECT id, shape, roadmap_milestone_path, roadmap_item_id, provider, tenant, project, work_item_id, parent_work_item_id, provider_revision, roadmap_revision, created_at, updated_at SELECT id, shape, roadmap_milestone_path, roadmap_item_id, provider, tenant, project, work_item_id, parent_work_item_id, provider_revision, roadmap_revision, created_at, updated_at
FROM roadmap_sync_identities FROM roadmap_sync_identities
WHERE roadmap_milestone_path = $1 WHERE shape = 'milestone' AND roadmap_item_id = $1
` `
func (q *Queries) GetRoadmapSyncIdentityByMilestonePath(ctx context.Context, roadmapMilestonePath string) (RoadmapSyncIdentity, error) { func (q *Queries) GetRoadmapSyncIdentityByMilestoneID(ctx context.Context, roadmapItemID string) (RoadmapSyncIdentity, error) {
row := q.db.QueryRow(ctx, getRoadmapSyncIdentityByMilestonePath, roadmapMilestonePath) row := q.db.QueryRow(ctx, getRoadmapSyncIdentityByMilestoneID, roadmapItemID)
var i RoadmapSyncIdentity var i RoadmapSyncIdentity
err := row.Scan( err := row.Scan(
&i.ID, &i.ID,
@ -75,24 +75,26 @@ func (q *Queries) GetRoadmapSyncIdentityByWorkItem(ctx context.Context, arg GetR
return i, err return i, err
} }
const upsertRoadmapSyncIdentityByWorkItem = `-- name: UpsertRoadmapSyncIdentityByWorkItem :one const upsertRoadmapSyncIdentityByMilestoneID = `-- name: UpsertRoadmapSyncIdentityByMilestoneID :one
INSERT INTO roadmap_sync_identities ( INSERT INTO roadmap_sync_identities (
shape, roadmap_milestone_path, roadmap_item_id, provider, tenant, project, work_item_id, parent_work_item_id, provider_revision, roadmap_revision shape, roadmap_milestone_path, roadmap_item_id, provider, tenant, project, work_item_id, parent_work_item_id, provider_revision, roadmap_revision
) )
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (provider, tenant, project, work_item_id) ON CONFLICT (shape, roadmap_item_id) WHERE shape = 'milestone' AND roadmap_item_id <> ''
DO UPDATE SET DO UPDATE SET
shape = EXCLUDED.shape, roadmap_milestone_path = EXCLUDED.roadmap_milestone_path,
roadmap_item_id = EXCLUDED.roadmap_item_id, provider = EXCLUDED.provider,
tenant = EXCLUDED.tenant,
project = EXCLUDED.project,
work_item_id = EXCLUDED.work_item_id,
parent_work_item_id = EXCLUDED.parent_work_item_id, parent_work_item_id = EXCLUDED.parent_work_item_id,
provider_revision = EXCLUDED.provider_revision, provider_revision = EXCLUDED.provider_revision,
roadmap_revision = EXCLUDED.roadmap_revision, roadmap_revision = EXCLUDED.roadmap_revision,
updated_at = now() updated_at = now()
WHERE roadmap_sync_identities.roadmap_milestone_path = EXCLUDED.roadmap_milestone_path
RETURNING id, shape, roadmap_milestone_path, roadmap_item_id, provider, tenant, project, work_item_id, parent_work_item_id, provider_revision, roadmap_revision, created_at, updated_at RETURNING id, shape, roadmap_milestone_path, roadmap_item_id, provider, tenant, project, work_item_id, parent_work_item_id, provider_revision, roadmap_revision, created_at, updated_at
` `
type UpsertRoadmapSyncIdentityByWorkItemParams struct { type UpsertRoadmapSyncIdentityByMilestoneIDParams struct {
Shape string `json:"shape"` Shape string `json:"shape"`
RoadmapMilestonePath string `json:"roadmap_milestone_path"` RoadmapMilestonePath string `json:"roadmap_milestone_path"`
RoadmapItemID string `json:"roadmap_item_id"` RoadmapItemID string `json:"roadmap_item_id"`
@ -105,8 +107,8 @@ type UpsertRoadmapSyncIdentityByWorkItemParams struct {
RoadmapRevision *string `json:"roadmap_revision"` RoadmapRevision *string `json:"roadmap_revision"`
} }
func (q *Queries) UpsertRoadmapSyncIdentityByWorkItem(ctx context.Context, arg UpsertRoadmapSyncIdentityByWorkItemParams) (RoadmapSyncIdentity, error) { func (q *Queries) UpsertRoadmapSyncIdentityByMilestoneID(ctx context.Context, arg UpsertRoadmapSyncIdentityByMilestoneIDParams) (RoadmapSyncIdentity, error) {
row := q.db.QueryRow(ctx, upsertRoadmapSyncIdentityByWorkItem, row := q.db.QueryRow(ctx, upsertRoadmapSyncIdentityByMilestoneID,
arg.Shape, arg.Shape,
arg.RoadmapMilestonePath, arg.RoadmapMilestonePath,
arg.RoadmapItemID, arg.RoadmapItemID,

View file

@ -45,13 +45,14 @@ type BridgeConfig struct {
// ExternalSource/ExternalID tag the preservation comment so a re-run is // ExternalSource/ExternalID tag the preservation comment so a re-run is
// recognized as nomadcode-authored. ExternalSource defaults to "nomadcode". // recognized as nomadcode-authored. ExternalSource defaults to "nomadcode".
ExternalSource string ExternalSource string
Tenant string
Project string
} }
// Bridge turns a verified develop scan into one EnqueueRoadmapCreationSync call // Bridge turns a verified develop scan into one EnqueueRoadmapCreationSync call
// per matched Milestone. It validates the event's repo/branch, dedups by // per active Milestone. It validates the event's repo/branch, dedups by
// (repo, branch, after) revision, fetches the originating Plane work item to // (repo, branch, after) revision, fetches the originating Plane work item for
// carry the original body and ref into the job, and skips Milestones whose // identity-backed docs, and enqueues identity-less docs as missing-create jobs.
// scanned identity does not match the fetched work item's provider.
type Bridge struct { type Bridge struct {
scanner Scanner scanner Scanner
reader workitem.Reader reader workitem.Reader
@ -133,7 +134,38 @@ func (b *Bridge) Handle(ctx context.Context, ev gitoevents.BranchUpdatedEvent) e
// identity whose provider/work item id does not resolve to a fetchable work // identity whose provider/work item id does not resolve to a fetchable work
// item is a hard error so a misconfigured identity is surfaced, not swallowed. // item is a hard error so a misconfigured identity is surfaced, not swallowed.
func (b *Bridge) enqueueDoc(ctx context.Context, scan roadmapsync.ScanResult, doc ScannedMilestoneDoc) error { func (b *Bridge) enqueueDoc(ctx context.Context, scan roadmapsync.ScanResult, doc ScannedMilestoneDoc) error {
identity := doc.Milestone.Identity if !doc.HasIdentity {
providerID := workitem.ProviderID("plane")
if prov, ok := b.reader.(workitem.Provider); ok {
providerID = prov.ProviderID()
}
args := scheduler.RoadmapCreationSyncJobArgs{
Scan: scan,
Expected: roadmapsync.Identity{
Shape: roadmapsync.ShapeMilestone,
RoadmapMilestonePath: doc.Path,
RoadmapItemID: doc.MilestoneID,
Provider: providerID,
Tenant: b.cfg.Tenant,
Project: b.cfg.Project,
},
TodoStateID: b.cfg.TodoStateID,
MilestoneMarkdown: doc.Markdown,
RoadmapRevision: scan.Revision.Revision,
ExternalSource: b.cfg.ExternalSource,
ExternalID: "",
}
if err := b.enqueuer.EnqueueRoadmapCreationSync(ctx, args); err != nil {
return err
}
b.logInfo("gito missing-create sync enqueued",
"milestone_path", doc.Path,
"revision", scan.Revision.Revision,
)
return nil
}
identity := doc.Identity
ref := workitem.Ref{ ref := workitem.Ref{
Provider: identity.Provider, Provider: identity.Provider,
Tenant: identity.Tenant, Tenant: identity.Tenant,

View file

@ -71,7 +71,13 @@ func matchedScanOutput() ScanOutput {
ChangedFiles: []string{milestonePath}, ChangedFiles: []string{milestonePath},
ScannedMilestones: []roadmapsync.ScannedMilestone{scanned}, ScannedMilestones: []roadmapsync.ScannedMilestone{scanned},
}, },
Docs: []ScannedMilestoneDoc{{Milestone: scanned, Markdown: milestoneMarkdownWithIdentity}}, Docs: []ScannedMilestoneDoc{{
Path: milestonePath,
MilestoneID: "m1",
Markdown: milestoneMarkdownWithIdentity,
HasIdentity: true,
Identity: identity,
}},
} }
} }
@ -81,6 +87,8 @@ func newTestBridge(t *testing.T, scanner Scanner, reader workitem.Reader, enq En
RepoID: "nomadcode", RepoID: "nomadcode",
Branch: "develop", Branch: "develop",
TodoStateID: "state-todo", TodoStateID: "state-todo",
Tenant: "acme",
Project: "proj-1",
}, nil) }, nil)
if err != nil { if err != nil {
t.Fatalf("NewBridge: %v", err) t.Fatalf("NewBridge: %v", err)
@ -283,6 +291,8 @@ func TestBridgeNoDocRevisionIsProcessed(t *testing.T) {
RepoID: "nomadcode", RepoID: "nomadcode",
Branch: "develop", Branch: "develop",
TodoStateID: "state-todo", TodoStateID: "state-todo",
Tenant: "acme",
Project: "proj-1",
}, nil) }, nil)
if err != nil { if err != nil {
t.Fatalf("NewBridge: %v", err) t.Fatalf("NewBridge: %v", err)
@ -303,3 +313,45 @@ func TestBridgeNoDocRevisionIsProcessed(t *testing.T) {
t.Fatal("expected revision to be marked processed even when no docs were found") t.Fatal("expected revision to be marked processed even when no docs were found")
} }
} }
func TestBridgeEnqueuesMissingCreateJobArgs(t *testing.T) {
enq := &fakeEnqueuer{}
identityLessOutput := ScanOutput{
Result: roadmapsync.ScanResult{
Revision: roadmapsync.Revision{Branch: "develop", Revision: "bbbb"},
ChangedFiles: []string{milestonePath},
},
Docs: []ScannedMilestoneDoc{{
Path: milestonePath,
MilestoneID: "m1",
Markdown: milestoneMarkdownNoIdentity,
HasIdentity: false,
}},
}
bridge := newTestBridge(t, fakeScanner{out: identityLessOutput}, &fakeReader{}, enq)
if err := bridge.Handle(context.Background(), newScanEvent()); err != nil {
t.Fatalf("Handle: %v", err)
}
if len(enq.jobs) != 1 {
t.Fatalf("jobs: got %d, want 1", len(enq.jobs))
}
job := enq.jobs[0]
if job.Expected.RoadmapItemID != "m1" || job.Expected.RoadmapMilestonePath != milestonePath {
t.Fatalf("Expected mismatch: %+v", job.Expected)
}
if job.Expected.Provider != "plane" || job.Expected.Tenant != "acme" || job.Expected.Project != "proj-1" {
t.Fatalf("Expected Provider/Tenant/Project: got provider=%q tenant=%q project=%q",
job.Expected.Provider, job.Expected.Tenant, job.Expected.Project)
}
if job.Ref.ID != "" || job.Ref.Provider != "" {
t.Fatalf("Ref: got %+v, want empty", job.Ref)
}
if job.TodoStateID != "state-todo" {
t.Fatalf("TodoStateID: got %q", job.TodoStateID)
}
if job.MilestoneMarkdown != milestoneMarkdownNoIdentity {
t.Fatalf("MilestoneMarkdown mismatch")
}
}

View file

@ -21,6 +21,10 @@ import (
// the event announced is not observable locally and nothing should be enqueued. // the event announced is not observable locally and nothing should be enqueued.
var ErrNotReady = errors.New("gitosync: develop revision not ready") var ErrNotReady = errors.New("gitosync: develop revision not ready")
// ErrDuplicateActiveSlug is returned when the scan detects duplicate active
// milestones for the same milestone ID (filename slug).
var ErrDuplicateActiveSlug = errors.New("gitosync: duplicate active milestone slugs")
// CommandRunner runs a git command and returns its stdout. It is the test seam // CommandRunner runs a git command and returns its stdout. It is the test seam
// that keeps the scanner unit-testable without a real git checkout; production // that keeps the scanner unit-testable without a real git checkout; production
// wraps os/exec. trimming of the trailing newline is the caller's job. // wraps os/exec. trimming of the trailing newline is the caller's job.
@ -55,16 +59,28 @@ func (c ScannerConfig) branch() string {
// read at the target revision, so the bridge can both match the provider // read at the target revision, so the bridge can both match the provider
// identity and project the develop content without re-reading git. // identity and project the develop content without re-reading git.
type ScannedMilestoneDoc struct { type ScannedMilestoneDoc struct {
Milestone roadmapsync.ScannedMilestone Path string
Markdown string MilestoneID string
Markdown string
HasIdentity bool
Identity roadmapsync.Identity
}
// ArchivedMilestoneCandidate records an archive Milestone path that shares the
// same filename-slug lookup space but must not be reconciled as an active item.
type ArchivedMilestoneCandidate struct {
Path string
MilestoneID string
} }
// ScanOutput is the verified result of a develop scan: the provider-neutral // ScanOutput is the verified result of a develop scan: the provider-neutral
// ScanResult the develop match gate consumes, plus the per-path Milestone // ScanResult the develop match gate consumes, the per-path active Milestone
// Markdown the bridge projects. // Markdown the bridge projects, and archive Milestone candidates kept only for
// future slug lookup/history diagnostics.
type ScanOutput struct { type ScanOutput struct {
Result roadmapsync.ScanResult Result roadmapsync.ScanResult
Docs []ScannedMilestoneDoc Docs []ScannedMilestoneDoc
ArchiveCandidates []ArchivedMilestoneCandidate
} }
// BranchRevisionScanner re-verifies a Gito branch event against the local // BranchRevisionScanner re-verifies a Gito branch event against the local
@ -88,12 +104,12 @@ func NewBranchRevisionScanner(runner CommandRunner, cfg ScannerConfig) (*BranchR
} }
// Scan fetches the remote develop branch, verifies its revision matches the // Scan fetches the remote develop branch, verifies its revision matches the
// event's `after`, diffs the Milestone files changed between `before` and // event's `after`, indexes every active Milestone at that revision, and
// `after`, and reads/parses each at `after`. It returns ErrNotReady when the // reads/parses each active Milestone. It returns ErrNotReady when the local
// local remote revision does not yet match the event so a caller skips the // remote revision does not yet match the event so a caller skips the enqueue
// enqueue instead of acting on stale state. Milestone files without a provider // instead of acting on stale state. Active Milestone files without a provider
// identity are dropped from Docs but kept in the ScanResult ChangedFiles so the // identity stay in Docs as missing-create candidates and remain in ChangedFiles
// match gate's reason stays accurate. // so the match gate's reason stays accurate.
func (s *BranchRevisionScanner) Scan(ctx context.Context, ev gitoevents.BranchUpdatedEvent) (ScanOutput, error) { func (s *BranchRevisionScanner) Scan(ctx context.Context, ev gitoevents.BranchUpdatedEvent) (ScanOutput, error) {
dir := s.cfg.DevelopRepoPath dir := s.cfg.DevelopRepoPath
remote := s.cfg.remote() remote := s.cfg.remote()
@ -114,13 +130,33 @@ func (s *BranchRevisionScanner) Scan(ctx context.Context, ev gitoevents.BranchUp
return ScanOutput{}, ErrNotReady return ScanOutput{}, ErrNotReady
} }
diffRange := diffRange(ev.Before, ev.After) out, err := s.runner.Run(ctx, dir, "git", "ls-tree", "-r", "--name-only", remoteRev, "--", "agent-roadmap/phase", "agent-roadmap/archive")
out, err := s.runner.Run(ctx, dir, "git", "diff", "--name-only", diffRange, "--", "agent-roadmap/phase")
if err != nil { if err != nil {
return ScanOutput{}, fmt.Errorf("gitosync: diff failed: %w", err) return ScanOutput{}, fmt.Errorf("gitosync: active milestone index failed: %w", err)
} }
changed := milestonePaths(out) changed := milestonePaths(out)
archiveCandidates := archiveMilestoneCandidates(out)
// Verify that there are no duplicate active milestone slugs.
slugToPaths := make(map[string][]string)
for _, path := range changed {
slug := roadmapsync.MilestoneSlug(path)
if slug == "" {
continue
}
slugToPaths[slug] = append(slugToPaths[slug], path)
}
var dups []string
for slug, paths := range slugToPaths {
if len(paths) > 1 {
dups = append(dups, fmt.Sprintf("%s (%s)", slug, strings.Join(paths, ", ")))
}
}
if len(dups) > 0 {
return ScanOutput{}, fmt.Errorf("%w: %s", ErrDuplicateActiveSlug, strings.Join(dups, "; "))
}
result := roadmapsync.ScanResult{ result := roadmapsync.ScanResult{
Revision: roadmapsync.Revision{ Revision: roadmapsync.Revision{
@ -137,46 +173,33 @@ func (s *BranchRevisionScanner) Scan(ctx context.Context, ev gitoevents.BranchUp
return ScanOutput{}, fmt.Errorf("gitosync: show %q failed: %w", path, err) return ScanOutput{}, fmt.Errorf("gitosync: show %q failed: %w", path, err)
} }
identity, err := roadmapsync.ParseMilestoneIdentity(path, markdown) identity, err := roadmapsync.ParseMilestoneIdentity(path, markdown)
var hasIdentity bool
var milestoneID string
if err != nil { if err != nil {
// No provider identity block: keep the changed path in the result so hasIdentity = false
// the match gate's reason stays accurate, but do not project it. milestoneID = roadmapsync.MilestoneSlug(path)
continue } else {
hasIdentity = true
milestoneID = identity.MilestoneID()
scanned := roadmapsync.ScannedMilestone{Path: path, Identity: identity}
result.ScannedMilestones = append(result.ScannedMilestones, scanned)
} }
scanned := roadmapsync.ScannedMilestone{Path: path, Identity: identity} docs = append(docs, ScannedMilestoneDoc{
result.ScannedMilestones = append(result.ScannedMilestones, scanned) Path: path,
docs = append(docs, ScannedMilestoneDoc{Milestone: scanned, Markdown: markdown}) MilestoneID: milestoneID,
Markdown: markdown,
HasIdentity: hasIdentity,
Identity: identity,
})
} }
return ScanOutput{Result: result, Docs: docs}, nil return ScanOutput{Result: result, Docs: docs, ArchiveCandidates: archiveCandidates}, nil
} }
func remoteRef(remote, branch string) string { func remoteRef(remote, branch string) string {
return fmt.Sprintf("refs/remotes/%s/%s", remote, branch) return fmt.Sprintf("refs/remotes/%s/%s", remote, branch)
} }
// diffRange builds the `git diff` range. A missing/zero `before` (a new branch
// push) falls back to a single-revision diff so the first push still scans its
// Milestone files.
func diffRange(before, after string) string {
b := strings.TrimSpace(before)
if b == "" || isZeroSHA(b) {
return strings.TrimSpace(after)
}
return b + ".." + strings.TrimSpace(after)
}
func isZeroSHA(s string) bool {
if s == "" {
return true
}
for _, r := range s {
if r != '0' {
return false
}
}
return true
}
// milestonePaths keeps only the lines that look like roadmap Milestone // milestonePaths keeps only the lines that look like roadmap Milestone
// documents, normalizing each to a cleaned repo-relative path. // documents, normalizing each to a cleaned repo-relative path.
func milestonePaths(diffOutput string) []string { func milestonePaths(diffOutput string) []string {
@ -193,6 +216,22 @@ func milestonePaths(diffOutput string) []string {
return out return out
} }
func archiveMilestoneCandidates(treeOutput string) []ArchivedMilestoneCandidate {
var out []ArchivedMilestoneCandidate
for _, line := range strings.Split(treeOutput, "\n") {
path := strings.TrimSpace(line)
if path == "" || !isArchiveMilestonePath(path) {
continue
}
id := roadmapsync.MilestoneSlug(path)
if id == "" {
continue
}
out = append(out, ArchivedMilestoneCandidate{Path: path, MilestoneID: id})
}
return out
}
// isMilestonePath reports whether a path matches // isMilestonePath reports whether a path matches
// agent-roadmap/phase/<phase>/milestones/<file>.md. // agent-roadmap/phase/<phase>/milestones/<file>.md.
func isMilestonePath(p string) bool { func isMilestonePath(p string) bool {
@ -207,3 +246,19 @@ func isMilestonePath(p string) bool {
parts[1] == "phase" && parts[1] == "phase" &&
parts[3] == "milestones" parts[3] == "milestones"
} }
// isArchiveMilestonePath reports whether a path matches an archived Milestone
// snapshot under agent-roadmap/archive/phase/<phase>/milestones/<file>.md.
func isArchiveMilestonePath(p string) bool {
if !strings.HasSuffix(p, ".md") {
return false
}
parts := strings.Split(strings.TrimPrefix(p, "./"), "/")
if len(parts) != 6 {
return false
}
return parts[0] == "agent-roadmap" &&
parts[1] == "archive" &&
parts[2] == "phase" &&
parts[4] == "milestones"
}

View file

@ -15,6 +15,7 @@ import (
) )
const milestonePath = "agent-roadmap/phase/p1/milestones/m1.md" const milestonePath = "agent-roadmap/phase/p1/milestones/m1.md"
const archivedMilestonePath = "agent-roadmap/archive/phase/p1/milestones/old-m1.md"
const milestoneMarkdownWithIdentity = `# Milestone m1 const milestoneMarkdownWithIdentity = `# Milestone m1
@ -73,10 +74,10 @@ func newTestScanner(t *testing.T, runner CommandRunner) *BranchRevisionScanner {
return s return s
} }
func TestScanRunsFetchRevParseDiffShowInOrder(t *testing.T) { func TestScanRunsFetchRevParseActiveIndexShowInOrder(t *testing.T) {
runner := newFakeRunner() runner := newFakeRunner()
runner.responses["git rev-parse refs/remotes/origin/develop"] = "bbbb\n" runner.responses["git rev-parse refs/remotes/origin/develop"] = "bbbb\n"
runner.responses["git diff --name-only aaaa..bbbb -- agent-roadmap/phase"] = milestonePath + "\n" runner.responses["git ls-tree -r --name-only bbbb -- agent-roadmap/phase agent-roadmap/archive"] = milestonePath + "\n"
runner.responses["git show bbbb:"+milestonePath] = milestoneMarkdownWithIdentity runner.responses["git show bbbb:"+milestonePath] = milestoneMarkdownWithIdentity
out, err := newTestScanner(t, runner).Scan(context.Background(), newScanEvent()) out, err := newTestScanner(t, runner).Scan(context.Background(), newScanEvent())
@ -87,7 +88,7 @@ func TestScanRunsFetchRevParseDiffShowInOrder(t *testing.T) {
wantOrder := []string{ wantOrder := []string{
"git fetch --prune origin develop", "git fetch --prune origin develop",
"git rev-parse refs/remotes/origin/develop", "git rev-parse refs/remotes/origin/develop",
"git diff --name-only aaaa..bbbb -- agent-roadmap/phase", "git ls-tree -r --name-only bbbb -- agent-roadmap/phase agent-roadmap/archive",
"git show bbbb:" + milestonePath, "git show bbbb:" + milestonePath,
} }
if len(runner.calls) != len(wantOrder) { if len(runner.calls) != len(wantOrder) {
@ -124,40 +125,45 @@ func TestScanReturnsNotReadyOnRevisionMismatch(t *testing.T) {
} }
// It must not diff/show once it knows the revision is stale. // It must not diff/show once it knows the revision is stale.
for _, c := range runner.calls { for _, c := range runner.calls {
if strings.HasPrefix(c, "git diff") || strings.HasPrefix(c, "git show") { if strings.HasPrefix(c, "git ls-tree") || strings.HasPrefix(c, "git show") {
t.Fatalf("unexpected command after mismatch: %q", c) t.Fatalf("unexpected command after mismatch: %q", c)
} }
} }
} }
func TestScanDropsMilestoneWithoutProviderIdentity(t *testing.T) { func TestScanPreservesMilestoneWithoutProviderIdentity(t *testing.T) {
runner := newFakeRunner() runner := newFakeRunner()
runner.responses["git rev-parse refs/remotes/origin/develop"] = "bbbb" runner.responses["git rev-parse refs/remotes/origin/develop"] = "bbbb"
runner.responses["git diff --name-only aaaa..bbbb -- agent-roadmap/phase"] = milestonePath runner.responses["git ls-tree -r --name-only bbbb -- agent-roadmap/phase agent-roadmap/archive"] = milestonePath
runner.responses["git show bbbb:"+milestonePath] = milestoneMarkdownNoIdentity runner.responses["git show bbbb:"+milestonePath] = milestoneMarkdownNoIdentity
out, err := newTestScanner(t, runner).Scan(context.Background(), newScanEvent()) out, err := newTestScanner(t, runner).Scan(context.Background(), newScanEvent())
if err != nil { if err != nil {
t.Fatalf("Scan: %v", err) t.Fatalf("Scan: %v", err)
} }
// Kept in ChangedFiles (so the match gate's reason stays accurate) but not // Kept in ChangedFiles (so the match gate's reason stays accurate) and preserved in Docs for missing-create.
// projectable.
if len(out.Result.ChangedFiles) != 1 { if len(out.Result.ChangedFiles) != 1 {
t.Fatalf("changed files: got %v", out.Result.ChangedFiles) t.Fatalf("changed files: got %v", out.Result.ChangedFiles)
} }
if len(out.Result.ScannedMilestones) != 0 { if len(out.Result.ScannedMilestones) != 0 {
t.Fatalf("scanned milestones: want none, got %+v", out.Result.ScannedMilestones) t.Fatalf("scanned milestones: want none, got %+v", out.Result.ScannedMilestones)
} }
if len(out.Docs) != 0 { if len(out.Docs) != 1 {
t.Fatalf("docs: want none, got %+v", out.Docs) t.Fatalf("docs: got %d, want 1", len(out.Docs))
}
doc := out.Docs[0]
if doc.Path != milestonePath || doc.MilestoneID != "m1" || doc.Markdown != milestoneMarkdownNoIdentity || doc.HasIdentity {
t.Fatalf("doc mismatch: %+v", doc)
} }
} }
func TestScanIgnoresNonMilestoneChangedFiles(t *testing.T) { func TestScanIndexesOnlyActiveMilestonesAndKeepsArchiveCandidates(t *testing.T) {
runner := newFakeRunner() runner := newFakeRunner()
runner.responses["git rev-parse refs/remotes/origin/develop"] = "bbbb" runner.responses["git rev-parse refs/remotes/origin/develop"] = "bbbb"
runner.responses["git diff --name-only aaaa..bbbb -- agent-roadmap/phase"] = runner.responses["git ls-tree -r --name-only bbbb -- agent-roadmap/phase agent-roadmap/archive"] =
"agent-roadmap/phase/p1/PHASE.md\n" + milestonePath "agent-roadmap/phase/p1/PHASE.md\n" +
milestonePath + "\n" +
archivedMilestonePath + "\n"
runner.responses["git show bbbb:"+milestonePath] = milestoneMarkdownWithIdentity runner.responses["git show bbbb:"+milestonePath] = milestoneMarkdownWithIdentity
out, err := newTestScanner(t, runner).Scan(context.Background(), newScanEvent()) out, err := newTestScanner(t, runner).Scan(context.Background(), newScanEvent())
@ -167,12 +173,18 @@ func TestScanIgnoresNonMilestoneChangedFiles(t *testing.T) {
if len(out.Result.ChangedFiles) != 1 || out.Result.ChangedFiles[0] != milestonePath { if len(out.Result.ChangedFiles) != 1 || out.Result.ChangedFiles[0] != milestonePath {
t.Fatalf("changed files: got %v, want only the milestone path", out.Result.ChangedFiles) t.Fatalf("changed files: got %v, want only the milestone path", out.Result.ChangedFiles)
} }
if len(out.ArchiveCandidates) != 1 {
t.Fatalf("archive candidates: got %+v, want one", out.ArchiveCandidates)
}
if out.ArchiveCandidates[0].Path != archivedMilestonePath || out.ArchiveCandidates[0].MilestoneID != "old-m1" {
t.Fatalf("archive candidate mismatch: %+v", out.ArchiveCandidates[0])
}
} }
func TestScanUsesSingleRevisionRangeForNewBranchPush(t *testing.T) { func TestScanIndexesRemoteRevisionRegardlessOfBefore(t *testing.T) {
runner := newFakeRunner() runner := newFakeRunner()
runner.responses["git rev-parse refs/remotes/origin/develop"] = "bbbb" runner.responses["git rev-parse refs/remotes/origin/develop"] = "bbbb"
runner.responses["git diff --name-only bbbb -- agent-roadmap/phase"] = "" runner.responses["git ls-tree -r --name-only bbbb -- agent-roadmap/phase agent-roadmap/archive"] = ""
ev := newScanEvent() ev := newScanEvent()
ev.Before = "0000000000000000000000000000000000000000" ev.Before = "0000000000000000000000000000000000000000"
@ -181,8 +193,11 @@ func TestScanUsesSingleRevisionRangeForNewBranchPush(t *testing.T) {
t.Fatalf("Scan: %v", err) t.Fatalf("Scan: %v", err)
} }
for _, c := range runner.calls { for _, c := range runner.calls {
if strings.HasPrefix(c, "git diff") && !strings.Contains(c, "bbbb -- ") { if strings.HasPrefix(c, "git diff") {
t.Fatalf("expected single-revision diff range, got %q", c) t.Fatalf("active index must not use diff range, got %q", c)
}
if strings.HasPrefix(c, "git ls-tree") && !strings.Contains(c, "bbbb -- ") {
t.Fatalf("expected ls-tree at remote revision, got %q", c)
} }
} }
} }
@ -251,7 +266,7 @@ func TestScanRegressionWithIdentityWriter(t *testing.T) {
// 3. Mock the runner with the injected markdown as the output of `git show` // 3. Mock the runner with the injected markdown as the output of `git show`
runner := newFakeRunner() runner := newFakeRunner()
runner.responses["git rev-parse refs/remotes/origin/develop"] = "bbbb\n" runner.responses["git rev-parse refs/remotes/origin/develop"] = "bbbb\n"
runner.responses["git diff --name-only aaaa..bbbb -- agent-roadmap/phase"] = milestoneRelPath + "\n" runner.responses["git ls-tree -r --name-only bbbb -- agent-roadmap/phase agent-roadmap/archive"] = milestoneRelPath + "\n"
runner.responses["git show bbbb:"+milestoneRelPath] = injectedMarkdown runner.responses["git show bbbb:"+milestoneRelPath] = injectedMarkdown
out, err := newTestScanner(t, runner).Scan(context.Background(), newScanEvent()) out, err := newTestScanner(t, runner).Scan(context.Background(), newScanEvent())
@ -271,3 +286,36 @@ func TestScanRegressionWithIdentityWriter(t *testing.T) {
t.Fatalf("expected 1 doc with injected markdown, got %+v", out.Docs) t.Fatalf("expected 1 doc with injected markdown, got %+v", out.Docs)
} }
} }
func TestScanReturnsErrorOnDuplicateActiveSlugs(t *testing.T) {
runner := newFakeRunner()
runner.responses["git rev-parse refs/remotes/origin/develop"] = "bbbb"
runner.responses["git ls-tree -r --name-only bbbb -- agent-roadmap/phase agent-roadmap/archive"] =
"agent-roadmap/phase/p1/milestones/m1.md\n" +
"agent-roadmap/phase/p2/milestones/m1.md\n"
_, err := newTestScanner(t, runner).Scan(context.Background(), newScanEvent())
if !errors.Is(err, ErrDuplicateActiveSlug) {
t.Fatalf("Scan: got %v, want ErrDuplicateActiveSlug", err)
}
}
func TestScanAllowsSameSlugInActiveAndArchive(t *testing.T) {
runner := newFakeRunner()
runner.responses["git rev-parse refs/remotes/origin/develop"] = "bbbb"
runner.responses["git ls-tree -r --name-only bbbb -- agent-roadmap/phase agent-roadmap/archive"] =
"agent-roadmap/phase/p1/milestones/m1.md\n" +
"agent-roadmap/archive/phase/p2/milestones/m1.md\n"
runner.responses["git show bbbb:agent-roadmap/phase/p1/milestones/m1.md"] = milestoneMarkdownWithIdentity
out, err := newTestScanner(t, runner).Scan(context.Background(), newScanEvent())
if err != nil {
t.Fatalf("Scan: unexpected error: %v", err)
}
if len(out.Result.ChangedFiles) != 1 || out.Result.ChangedFiles[0] != "agent-roadmap/phase/p1/milestones/m1.md" {
t.Fatalf("expected 1 active file, got %v", out.Result.ChangedFiles)
}
if len(out.ArchiveCandidates) != 1 || out.ArchiveCandidates[0].Path != "agent-roadmap/archive/phase/p2/milestones/m1.md" {
t.Fatalf("expected 1 archive candidate, got %v", out.ArchiveCandidates)
}
}

View file

@ -55,22 +55,58 @@ func MatchDevelopMilestone(in MatchInput) ProjectionCandidate {
if revision.Revision == "" { if revision.Revision == "" {
return notReady(expected, revision, reasonNoRevision) return notReady(expected, revision, reasonNoRevision)
} }
if !changedFilesContainMilestone(in.Scan.ChangedFiles, expected.RoadmapMilestonePath) {
var matchedScanned ScannedMilestone
var matchedIdentity Identity
foundScanned := false
for _, sm := range in.Scan.ScannedMilestones {
cleanedPath := normalizeRepoPath(sm.Path)
if cleanedPath == "" || !isMilestonePath(cleanedPath) {
continue
}
smIdentity, err := sm.Identity.Normalize()
if err != nil {
continue
}
if expected.Matches(smIdentity) {
matchedScanned = sm
matchedScanned.Path = cleanedPath
matchedIdentity = smIdentity
foundScanned = true
break
}
}
if !foundScanned {
_, ok := scannedIdentityForPath(in.Scan.ScannedMilestones, expected.RoadmapMilestonePath)
if ok {
return notReady(expected, revision, reasonIdentityMismatch)
}
if changedFilesContainMilestone(in.Scan.ChangedFiles, expected.RoadmapMilestonePath) {
return notReady(expected, revision, reasonNoScannedIdentity)
}
return notReady(expected, revision, reasonNoMilestoneFile) return notReady(expected, revision, reasonNoMilestoneFile)
} }
scanned, ok := scannedIdentityForPath(in.Scan.ScannedMilestones, expected.RoadmapMilestonePath) if !changedFilesContainMilestone(in.Scan.ChangedFiles, matchedScanned.Path) {
if !ok { return notReady(expected, revision, reasonNoMilestoneFile)
// The path changed but no parsed provider identity backs it, so the
// pushed Milestone cannot be tied to the expected work item.
return notReady(expected, revision, reasonNoScannedIdentity)
} }
if !expected.Matches(scanned) {
return notReady(expected, revision, reasonIdentityMismatch) candidateIdentity := matchedIdentity
candidateIdentity.RoadmapMilestonePath = matchedScanned.Path
if candidateIdentity.Tenant == "" {
candidateIdentity.Tenant = expected.Tenant
}
if candidateIdentity.Project == "" {
candidateIdentity.Project = expected.Project
}
if candidateIdentity.ParentWorkItemID == "" {
candidateIdentity.ParentWorkItemID = expected.ParentWorkItemID
} }
return ProjectionCandidate{ return ProjectionCandidate{
Identity: expected, Identity: candidateIdentity,
Revision: revision, Revision: revision,
Ready: true, Ready: true,
Reason: reasonReady, Reason: reasonReady,

View file

@ -83,14 +83,14 @@ func TestMatchDevelopMilestoneNotReady(t *testing.T) {
}, },
wantReason: reasonNoScannedIdentity, wantReason: reasonNoScannedIdentity,
}, },
"path matches but scanned work item id differs": { "path matches but scanned milestone id differs": {
in: MatchInput{ in: MatchInput{
Scan: ScanResult{ Scan: ScanResult{
Revision: Revision{Branch: "develop", Revision: "abc"}, Revision: Revision{Branch: "develop", Revision: "abc"},
ChangedFiles: []string{milestoneFile}, ChangedFiles: []string{milestoneFile},
ScannedMilestones: []ScannedMilestone{{ ScannedMilestones: []ScannedMilestone{{
Path: milestoneFile, Path: milestoneFile,
Identity: Identity{RoadmapMilestonePath: milestoneFile, Provider: "plane", WorkItemID: "NOMAD-99"}, Identity: Identity{RoadmapMilestonePath: "agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/other-slug.md", Provider: "plane", WorkItemID: "NOMAD-13"},
}}, }},
}, },
Expected: readyExpected(), Expected: readyExpected(),
@ -161,3 +161,56 @@ func TestIsMilestonePath(t *testing.T) {
} }
} }
} }
func TestMatchDevelopMilestoneReadyWithWorkItemIDDifference(t *testing.T) {
milestoneFile := "agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md"
in := MatchInput{
Scan: ScanResult{
Revision: Revision{Branch: "develop", Revision: "abc123"},
ChangedFiles: []string{milestoneFile},
ScannedMilestones: []ScannedMilestone{{
Path: milestoneFile,
Identity: Identity{RoadmapMilestonePath: milestoneFile, Provider: "plane", WorkItemID: "NOMAD-99"},
}},
},
Expected: readyExpected(), // WorkItemID is NOMAD-13, Tenant is general, Project is NOMAD
}
got := MatchDevelopMilestone(in)
if !got.Ready {
t.Fatalf("expected ready, got reason %q", got.Reason)
}
if got.Identity.WorkItemID != "NOMAD-99" {
t.Errorf("expected WorkItemID to be NOMAD-99, got %q", got.Identity.WorkItemID)
}
if got.Identity.Tenant != "general" || got.Identity.Project != "NOMAD" {
t.Errorf("expected tenant/project to be preserved from expected, got tenant=%q project=%q", got.Identity.Tenant, got.Identity.Project)
}
}
func TestMatchDevelopMilestoneReadyAcrossPhaseMove(t *testing.T) {
oldMilestoneFile := "agent-roadmap/phase/p1/milestones/milestone-work-item-creation-sync.md"
newMilestoneFile := "agent-roadmap/phase/p2/milestones/milestone-work-item-creation-sync.md"
in := MatchInput{
Scan: ScanResult{
Revision: Revision{Branch: "develop", Revision: "abc123"},
ChangedFiles: []string{newMilestoneFile},
ScannedMilestones: []ScannedMilestone{{
Path: newMilestoneFile,
Identity: Identity{RoadmapMilestonePath: newMilestoneFile, Provider: "plane", WorkItemID: "NOMAD-13"},
}},
},
Expected: Identity{
RoadmapMilestonePath: oldMilestoneFile, // expected still points to p1
Provider: "plane",
WorkItemID: "NOMAD-13",
},
}
got := MatchDevelopMilestone(in)
if !got.Ready {
t.Fatalf("expected ready, got reason %q", got.Reason)
}
// candidate.Identity should have the updated path
if got.Identity.RoadmapMilestonePath != newMilestoneFile {
t.Errorf("expected updated path %q, got %q", newMilestoneFile, got.Identity.RoadmapMilestonePath)
}
}

View file

@ -36,8 +36,8 @@ func (s Shape) Valid() bool {
// Identity is the provider-neutral shape used to find and compare the same // Identity is the provider-neutral shape used to find and compare the same
// Milestone item regardless of which side changed. Field set follows the // Milestone item regardless of which side changed. Field set follows the
// contract sync identity candidate (packages/contracts note 5.2). The minimal // contract sync identity candidate (packages/contracts note 5.2). The minimal
// required match key is provider + work item id plus the Milestone path; the // required match key is provider + canonical Milestone id; path and provider
// authoring run id is intentionally not a required match key in this slice. // work item id are mutable attributes in this slice.
type Identity struct { type Identity struct {
Shape Shape `json:"shape"` Shape Shape `json:"shape"`
RoadmapMilestonePath string `json:"roadmap_milestone_path"` RoadmapMilestonePath string `json:"roadmap_milestone_path"`
@ -50,10 +50,27 @@ type Identity struct {
} }
// Normalize trims fields and enforces the minimal required match key: a valid // Normalize trims fields and enforces the minimal required match key: a valid
// shape, a milestone path, a provider, and a work item id. Task shapes // shape, a canonical Milestone id, and a provider. Task shapes
// additionally require a parent work item id so a Task is never mistaken for a // additionally require a parent work item id so a Task is never mistaken for a
// top-level Milestone. Missing required fields are a hard error rather than a // top-level Milestone. Missing required fields are a hard error rather than a
// silently accepted partial identity. // silently accepted partial identity.
// MilestoneID returns the canonical milestone ID (filename slug) for this identity.
// It checks RoadmapItemID first, then falls back to parsing from RoadmapMilestonePath.
func (i Identity) MilestoneID() string {
if i.RoadmapItemID != "" {
return i.RoadmapItemID
}
if i.RoadmapMilestonePath == "" {
return ""
}
idx := strings.LastIndex(i.RoadmapMilestonePath, "/")
filename := i.RoadmapMilestonePath
if idx != -1 {
filename = i.RoadmapMilestonePath[idx+1:]
}
return strings.TrimSuffix(filename, ".md")
}
func (i Identity) Normalize() (Identity, error) { func (i Identity) Normalize() (Identity, error) {
shape := Shape(strings.TrimSpace(string(i.Shape))) shape := Shape(strings.TrimSpace(string(i.Shape)))
if shape == "" { if shape == "" {
@ -74,9 +91,11 @@ func (i Identity) Normalize() (Identity, error) {
ParentWorkItemID: strings.TrimSpace(i.ParentWorkItemID), ParentWorkItemID: strings.TrimSpace(i.ParentWorkItemID),
} }
if normalized.RoadmapMilestonePath == "" || if normalized.Shape == ShapeMilestone && normalized.RoadmapItemID == "" {
normalized.Provider == "" || normalized.RoadmapItemID = normalized.MilestoneID()
normalized.WorkItemID == "" { }
if normalized.MilestoneID() == "" || normalized.Provider == "" {
return Identity{}, ErrInvalidIdentity return Identity{}, ErrInvalidIdentity
} }
if normalized.Shape == ShapeTask && normalized.ParentWorkItemID == "" { if normalized.Shape == ShapeTask && normalized.ParentWorkItemID == "" {
@ -86,7 +105,7 @@ func (i Identity) Normalize() (Identity, error) {
} }
// Matches reports whether two identities refer to the same Milestone item. The // Matches reports whether two identities refer to the same Milestone item. The
// comparison is on the normalized provider + work item id + milestone path, so // comparison is on the normalized provider + MilestoneID, so
// callers must normalize first; an un-normalizable identity never matches. // callers must normalize first; an un-normalizable identity never matches.
func (i Identity) Matches(other Identity) bool { func (i Identity) Matches(other Identity) bool {
left, err := i.Normalize() left, err := i.Normalize()
@ -98,18 +117,13 @@ func (i Identity) Matches(other Identity) bool {
return false return false
} }
return left.Provider == right.Provider && return left.Provider == right.Provider &&
left.WorkItemID == right.WorkItemID && left.MilestoneID() == right.MilestoneID()
left.RoadmapMilestonePath == right.RoadmapMilestonePath
} }
// MatchesStrict reports whether two identities refer to the same item using the // MatchesStrict reports whether two identities refer to the same item using the
// full provider-side identity key persisted by the identity map: provider, // full provider-side identity key: provider, tenant, project, plus MilestoneID.
// tenant, project, work item id, plus the Milestone path. Unlike Matches it // Unlike Matches it also compares tenant and project, so a re-process in a different Plane
// also compares tenant and project, so a re-process in a different Plane // workspace/project is never treated as the same identity.
// workspace/project is never treated as the same identity. The retry decision
// uses this so it cannot reuse an identity across project-scoped sync targets.
// Callers must be able to normalize both sides; an un-normalizable identity
// never matches.
func (i Identity) MatchesStrict(other Identity) bool { func (i Identity) MatchesStrict(other Identity) bool {
left, err := i.Normalize() left, err := i.Normalize()
if err != nil { if err != nil {
@ -122,8 +136,7 @@ func (i Identity) MatchesStrict(other Identity) bool {
return left.Provider == right.Provider && return left.Provider == right.Provider &&
left.Tenant == right.Tenant && left.Tenant == right.Tenant &&
left.Project == right.Project && left.Project == right.Project &&
left.WorkItemID == right.WorkItemID && left.MilestoneID() == right.MilestoneID()
left.RoadmapMilestonePath == right.RoadmapMilestonePath
} }
// Revision captures the branch and revision context that a scan observed, so a // Revision captures the branch and revision context that a scan observed, so a

View file

@ -23,6 +23,9 @@ func TestIdentityNormalizeValid(t *testing.T) {
if got.RoadmapMilestonePath != "agent-roadmap/phase/p/milestones/m.md" { if got.RoadmapMilestonePath != "agent-roadmap/phase/p/milestones/m.md" {
t.Errorf("path = %q", got.RoadmapMilestonePath) t.Errorf("path = %q", got.RoadmapMilestonePath)
} }
if got.RoadmapItemID != "m" {
t.Errorf("expected RoadmapItemID to be m, got %q", got.RoadmapItemID)
}
if got.Provider != "plane" || got.WorkItemID != "NOMAD-13" { if got.Provider != "plane" || got.WorkItemID != "NOMAD-13" {
t.Errorf("provider/work item not trimmed: %+v", got) t.Errorf("provider/work item not trimmed: %+v", got)
} }
@ -34,11 +37,7 @@ func TestIdentityNormalizeRejectsMissingFields(t *testing.T) {
RoadmapMilestonePath: "agent-roadmap/phase/p/milestones/m.md", RoadmapMilestonePath: "agent-roadmap/phase/p/milestones/m.md",
WorkItemID: "NOMAD-13", WorkItemID: "NOMAD-13",
}, },
"missing work item id": { "missing milestone path and item id": {
RoadmapMilestonePath: "agent-roadmap/phase/p/milestones/m.md",
Provider: "plane",
},
"missing milestone path": {
Provider: "plane", Provider: "plane",
WorkItemID: "NOMAD-13", WorkItemID: "NOMAD-13",
}, },
@ -78,8 +77,8 @@ func TestIdentityMatches(t *testing.T) {
differentWorkItem := base differentWorkItem := base
differentWorkItem.WorkItemID = "NOMAD-99" differentWorkItem.WorkItemID = "NOMAD-99"
if base.Matches(differentWorkItem) { if !base.Matches(differentWorkItem) {
t.Errorf("different work item id must not match") t.Errorf("different work item id must match if MilestoneID is same")
} }
invalid := Identity{Provider: "plane"} invalid := Identity{Provider: "plane"}
@ -112,12 +111,69 @@ func TestIdentityMatchesStrict(t *testing.T) {
t.Errorf("different project must not strict-match") t.Errorf("different project must not strict-match")
} }
differentWorkItem := base
differentWorkItem.WorkItemID = "NOMAD-99"
if !base.MatchesStrict(differentWorkItem) {
t.Errorf("different work item id must strict-match if key fields are same")
}
// Matches (loose) ignores tenant/project; MatchesStrict must not. // Matches (loose) ignores tenant/project; MatchesStrict must not.
if !base.Matches(differentTenant) { if !base.Matches(differentTenant) {
t.Errorf("sanity: loose Matches should still ignore tenant") t.Errorf("sanity: loose Matches should still ignore tenant")
} }
} }
func TestIdentityMatchesByMilestoneIDAcrossPhaseMove(t *testing.T) {
left := Identity{
RoadmapMilestonePath: "agent-roadmap/phase/p1/milestones/m.md",
Provider: "plane",
}
right := Identity{
RoadmapMilestonePath: "agent-roadmap/phase/p2/milestones/m.md", // moved phase
Provider: "plane",
}
if !left.Matches(right) {
t.Errorf("moved path must match if MilestoneID is same")
}
if !left.MatchesStrict(right) {
t.Errorf("moved path must strict-match if other key fields are same")
}
}
func TestIdentityMatchesStrictAllowsProviderWorkItemAttributeChange(t *testing.T) {
left := Identity{
RoadmapMilestonePath: "agent-roadmap/phase/p/milestones/m.md",
Provider: "plane",
Tenant: "general",
Project: "NOMAD",
WorkItemID: "NOMAD-13",
}
right := Identity{
RoadmapMilestonePath: "agent-roadmap/phase/p/milestones/m.md",
Provider: "plane",
Tenant: "general",
Project: "NOMAD",
WorkItemID: "NOMAD-999", // changed ticket
}
if !left.MatchesStrict(right) {
t.Errorf("changed work item id must still strict-match")
}
}
func TestIdentityNormalizeParsesSlugFromPath(t *testing.T) {
in := Identity{
RoadmapMilestonePath: "agent-roadmap/phase/p/milestones/some-slug.md",
Provider: "plane",
}
got, err := in.Normalize()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.RoadmapItemID != "some-slug" {
t.Errorf("expected RoadmapItemID to be some-slug, got %q", got.RoadmapItemID)
}
}
func TestRevisionNormalizeDefaultsDevelop(t *testing.T) { func TestRevisionNormalizeDefaultsDevelop(t *testing.T) {
got := Revision{}.Normalize() got := Revision{}.Normalize()
if got.Branch != DefaultBranch { if got.Branch != DefaultBranch {

View file

@ -87,13 +87,11 @@ func TestParseMilestoneIdentityBulletBoundaryRejectsSiblings(t *testing.T) {
} }
func TestParseMilestoneIdentityBulletBoundaryClosesOnOutdent(t *testing.T) { func TestParseMilestoneIdentityBulletBoundaryClosesOnOutdent(t *testing.T) {
// Nested children supply a partial identity, then an outdent sibling tries // Provider field is an outdent sibling, so it is not parsed.
// to add work item id. The outdent closes the block, so the identity stays
// incomplete and is rejected.
md := `# Milestone md := `# Milestone
- Provider identity: - Provider identity:
- provider: plane - provider: plane
- work item id: NOMAD-13 - work item id: NOMAD-13
` `
_, err := ParseMilestoneIdentity(milestonePath, md) _, err := ParseMilestoneIdentity(milestonePath, md)
@ -103,11 +101,12 @@ func TestParseMilestoneIdentityBulletBoundaryClosesOnOutdent(t *testing.T) {
} }
func TestParseMilestoneIdentityBulletBoundaryClosesOnBlankLine(t *testing.T) { func TestParseMilestoneIdentityBulletBoundaryClosesOnBlankLine(t *testing.T) {
// Blank line closes the block before provider field is parsed.
md := `# Milestone md := `# Milestone
- Provider identity: - Provider identity:
- provider: plane
- provider: plane
- work item id: NOMAD-13 - work item id: NOMAD-13
` `
_, err := ParseMilestoneIdentity(milestonePath, md) _, err := ParseMilestoneIdentity(milestonePath, md)
@ -131,10 +130,10 @@ provider 정보가 본문에 없다.
} }
func TestParseMilestoneIdentityIncompleteBlock(t *testing.T) { func TestParseMilestoneIdentityIncompleteBlock(t *testing.T) {
// Block present but missing work item id: must not silently succeed. // Block present but missing provider: must fail.
md := `## Provider identity md := `## Provider identity
- provider: plane
- project: NOMAD - project: NOMAD
- work item id: NOMAD-13
` `
_, err := ParseMilestoneIdentity(milestonePath, md) _, err := ParseMilestoneIdentity(milestonePath, md)
if !errors.Is(err, ErrNoProviderIdentity) { if !errors.Is(err, ErrNoProviderIdentity) {
@ -142,6 +141,20 @@ func TestParseMilestoneIdentityIncompleteBlock(t *testing.T) {
} }
} }
func TestParseMilestoneIdentityExplicitMilestoneID(t *testing.T) {
md := `## Provider identity
- provider: plane
- milestone id: custom-milestone-slug
`
got, err := ParseMilestoneIdentity(milestonePath, md)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.RoadmapItemID != "custom-milestone-slug" {
t.Errorf("expected RoadmapItemID to be custom-milestone-slug, got %q", got.RoadmapItemID)
}
}
func TestParseMilestoneIdentityEmptyPath(t *testing.T) { func TestParseMilestoneIdentityEmptyPath(t *testing.T) {
if _, err := ParseMilestoneIdentity(" ", "## Provider identity\n- provider: plane\n- work item id: X\n"); !errors.Is(err, ErrInvalidIdentity) { if _, err := ParseMilestoneIdentity(" ", "## Provider identity\n- provider: plane\n- work item id: X\n"); !errors.Is(err, ErrInvalidIdentity) {
t.Fatalf("err = %v, want ErrInvalidIdentity", err) t.Fatalf("err = %v, want ErrInvalidIdentity", err)

View file

@ -115,17 +115,17 @@ func ReconcileCreationCycle(in ReconcileInput) ReconcileResult {
return ReconcileResult{Action: ActionSkipSelfMutation, Reason: reasonSelfMutation} return ReconcileResult{Action: ActionSkipSelfMutation, Reason: reasonSelfMutation}
} }
// Strict match: when a persisted identity exists it must agree on the full // Match: when a persisted identity exists it must agree on the minimal key
// provider-side key (provider, tenant, project, work item id, Milestone // (provider and canonical Milestone id). A found-but-not-matching row means
// path). A found-but-not-strictly-matching row means the trigger disagrees // the trigger disagrees with what is stored (e.g. different provider), which
// with what is stored (e.g. a different Plane workspace/project for the same // is a conflict rather than a silent fresh resume. Changes to path, tenant,
// work item id/path), which is a conflict rather than a silent fresh resume. // project, or provider work item id are allowed safe attribute updates.
if in.Existing.Found && !in.Existing.Identity.MatchesStrict(identity) { if in.Existing.Found && !in.Existing.Identity.Matches(identity) {
return ReconcileResult{Action: ActionConflict, Reason: reasonIdentityKeyMismatch} return ReconcileResult{Action: ActionConflict, Reason: reasonIdentityKeyMismatch}
} }
identityKept := in.Existing.Found identityKept := in.Existing.Found
// Revision guard: when an identity already exists for this work item/path, // Revision guard: when an identity already exists for this milestone id,
// the trigger must observe the same revisions, otherwise resuming would // the trigger must observe the same revisions, otherwise resuming would
// silently overwrite work projected from a different develop state. // silently overwrite work projected from a different develop state.
if identityKept { if identityKept {

View file

@ -47,8 +47,9 @@ const (
// by *storage.Store and by test fakes so the orchestrator stays unit-testable // by *storage.Store and by test fakes so the orchestrator stays unit-testable
// without a database. // without a database.
type Store interface { type Store interface {
GetRoadmapSyncIdentityByMilestoneID(ctx context.Context, milestoneID string) (db.RoadmapSyncIdentity, error)
GetRoadmapSyncIdentityByWorkItem(ctx context.Context, args db.GetRoadmapSyncIdentityByWorkItemParams) (db.RoadmapSyncIdentity, error) GetRoadmapSyncIdentityByWorkItem(ctx context.Context, args db.GetRoadmapSyncIdentityByWorkItemParams) (db.RoadmapSyncIdentity, error)
UpsertRoadmapSyncIdentity(ctx context.Context, args db.UpsertRoadmapSyncIdentityByWorkItemParams) (db.RoadmapSyncIdentity, error) UpsertRoadmapSyncIdentity(ctx context.Context, args db.UpsertRoadmapSyncIdentityByMilestoneIDParams) (db.RoadmapSyncIdentity, error)
CompletedSteps(ctx context.Context, identityID int64) (map[storage.RoadmapSyncStep]bool, error) CompletedSteps(ctx context.Context, identityID int64) (map[storage.RoadmapSyncStep]bool, error)
MarkStepCompleted(ctx context.Context, identityID int64, step storage.RoadmapSyncStep) (db.RoadmapSyncStep, error) MarkStepCompleted(ctx context.Context, identityID int64, step storage.RoadmapSyncStep) (db.RoadmapSyncStep, error)
} }
@ -111,6 +112,109 @@ type SyncCreationResult struct {
// not-ready/conflict/self-mutation/complete decision returns without any // not-ready/conflict/self-mutation/complete decision returns without any
// provider side effect. // provider side effect.
func (s *Service) SyncCreation(ctx context.Context, in SyncCreationInput) (SyncCreationResult, error) { func (s *Service) SyncCreation(ctx context.Context, in SyncCreationInput) (SyncCreationResult, error) {
var existingRow db.RoadmapSyncIdentity
var completed map[storage.RoadmapSyncStep]bool
var found bool
var err error
// Load existing identity row (without mutation)
if in.Expected.MilestoneID() != "" {
existingRow, completed, found, err = s.loadExistingByID(ctx, in.Expected.MilestoneID())
if err != nil {
return SyncCreationResult{}, err
}
}
if found {
// Populate identity details from the DB
in.Expected.Provider = workitem.ProviderID(existingRow.Provider)
in.Expected.Tenant = existingRow.Tenant
in.Expected.Project = existingRow.Project
in.Expected.WorkItemID = existingRow.WorkItemID
in.Expected.ParentWorkItemID = existingRow.ParentWorkItemID
in.Ref = workitem.Ref{
Provider: in.Expected.Provider,
Tenant: in.Expected.Tenant,
Project: in.Expected.Project,
ID: in.Expected.WorkItemID,
}
} else if in.Expected.WorkItemID == "" && in.Ref.ID == "" {
// Plane missing-create scenario: no existing work item id means the
// active milestone has no Plane ticket yet. Expected.Provider is set by
// the bridge (e.g. "plane"); fall back to "plane" only when the caller
// omits it.
if in.Expected.Tenant == "" || in.Expected.Project == "" {
return SyncCreationResult{}, errors.New("roadmapsyncpipeline: tenant and project are required for missing-create")
}
creator, ok := s.provider.(workitem.Creator)
if !ok {
return SyncCreationResult{}, errors.New("roadmapsyncpipeline: provider does not support creation")
}
providerID := in.Expected.Provider
if string(providerID) == "" {
providerID = "plane"
}
projection := roadmapsync.FormatPlaneProjection(in.Expected.RoadmapMilestonePath, in.MilestoneMarkdown)
createRes, err := creator.CreateWorkItem(ctx, workitem.CreateInput{
Provider: providerID,
Tenant: in.Expected.Tenant,
Project: in.Expected.Project,
Title: projection.Title,
DescriptionHTML: projection.BodyHTML,
})
if err != nil {
return SyncCreationResult{}, err
}
identity := roadmapsync.Identity{
Shape: roadmapsync.ShapeMilestone,
RoadmapMilestonePath: in.Expected.RoadmapMilestonePath,
RoadmapItemID: in.Expected.RoadmapItemID,
Provider: createRes.Ref.Provider,
Tenant: createRes.Ref.Tenant,
Project: createRes.Ref.Project,
WorkItemID: createRes.Ref.ID,
}
upsertParams := db.UpsertRoadmapSyncIdentityByMilestoneIDParams{
Shape: string(identity.Shape),
RoadmapMilestonePath: identity.RoadmapMilestonePath,
RoadmapItemID: identity.RoadmapItemID,
Provider: string(identity.Provider),
Tenant: identity.Tenant,
Project: identity.Project,
WorkItemID: identity.WorkItemID,
ParentWorkItemID: identity.ParentWorkItemID,
RoadmapRevision: nullableString(in.RoadmapRevision),
}
identityRow, err := s.store.UpsertRoadmapSyncIdentity(ctx, upsertParams)
if err != nil {
if errors.Is(err, storage.ErrRoadmapSyncIdentityConflict) {
return SyncCreationResult{
Action: SyncActionConflict,
Reason: "roadmap sync identity conflict: work item already bound or filename reuse suspected",
}, nil
}
return SyncCreationResult{}, err
}
if _, err := s.store.MarkStepCompleted(ctx, identityRow.ID, toStorageStep(roadmapsync.StepDevelopMatched)); err != nil {
return SyncCreationResult{}, err
}
return SyncCreationResult{
Action: SyncActionProjected,
Reason: "plane work item created for missing active milestone",
NextStep: roadmapsync.StepDevelopMatched,
}, nil
}
candidate := roadmapsync.MatchDevelopMilestone(roadmapsync.MatchInput{ candidate := roadmapsync.MatchDevelopMilestone(roadmapsync.MatchInput{
Scan: in.Scan, Scan: in.Scan,
Expected: in.Expected, Expected: in.Expected,
@ -134,18 +238,27 @@ func (s *Service) SyncCreation(ctx context.Context, in SyncCreationInput) (SyncC
return SyncCreationResult{}, err return SyncCreationResult{}, err
} }
identityRow, completed, err := s.loadOrUpsertIdentityAndSteps(ctx, in, candidate) // 1. Load existing identity row (without mutation)
if err != nil { if !found {
return SyncCreationResult{}, err existingRow, completed, found, err = s.loadExistingIdentityAndSteps(ctx, candidate)
if err != nil {
return SyncCreationResult{}, err
}
} }
existingView := roadmapsync.ExistingIdentity{Found: found}
if found {
existingView = existingFromRow(existingRow)
}
// 2. Reconcile conflict using original existing row
decision := roadmapsync.ReconcileCreationCycle(roadmapsync.ReconcileInput{ decision := roadmapsync.ReconcileCreationCycle(roadmapsync.ReconcileInput{
Identity: candidate.Identity, Identity: candidate.Identity,
ProviderRevision: in.ProviderRevision, ProviderRevision: in.ProviderRevision,
RoadmapRevision: in.RoadmapRevision, RoadmapRevision: in.RoadmapRevision,
Actor: in.Actor, Actor: in.Actor,
SelfActor: s.selfActor, SelfActor: s.selfActor,
Existing: existingFromRow(identityRow), Existing: existingView,
CompletedSteps: toReconcileSteps(completed), CompletedSteps: toReconcileSteps(completed),
}) })
@ -162,6 +275,43 @@ func (s *Service) SyncCreation(ctx context.Context, in SyncCreationInput) (SyncC
return SyncCreationResult{}, errors.New("roadmapsyncpipeline: unexpected reconcile action " + string(decision.Action)) return SyncCreationResult{}, errors.New("roadmapsyncpipeline: unexpected reconcile action " + string(decision.Action))
} }
// 3. Reconcile says Resume: Perform mutation (upsert) to record/update path, work item attrs, revisions.
var identityRow db.RoadmapSyncIdentity
if !found {
identityRow, err = s.store.UpsertRoadmapSyncIdentity(ctx, upsertParams(candidate.Identity, in))
if err != nil {
if errors.Is(err, storage.ErrRoadmapSyncIdentityConflict) {
return SyncCreationResult{
Action: SyncActionConflict,
Reason: "roadmap sync identity conflict: work item already bound or filename reuse suspected",
}, nil
}
return SyncCreationResult{}, err
}
} else {
// Update only if anything changed
if existingRow.RoadmapMilestonePath != candidate.Identity.RoadmapMilestonePath ||
existingRow.Provider != string(candidate.Identity.Provider) ||
existingRow.Tenant != candidate.Identity.Tenant ||
existingRow.Project != candidate.Identity.Project ||
existingRow.WorkItemID != candidate.Identity.WorkItemID ||
derefString(existingRow.ProviderRevision) != in.ProviderRevision ||
derefString(existingRow.RoadmapRevision) != in.RoadmapRevision {
identityRow, err = s.store.UpsertRoadmapSyncIdentity(ctx, upsertParams(candidate.Identity, in))
if err != nil {
if errors.Is(err, storage.ErrRoadmapSyncIdentityConflict) {
return SyncCreationResult{
Action: SyncActionConflict,
Reason: "roadmap sync identity conflict: work item already bound or filename reuse suspected",
}, nil
}
return SyncCreationResult{}, err
}
} else {
identityRow = existingRow
}
}
lastStep, err := s.runRemainingSteps(ctx, identityRow.ID, decision.NextStep, completed, projectionInput) lastStep, err := s.runRemainingSteps(ctx, identityRow.ID, decision.NextStep, completed, projectionInput)
if err != nil { if err != nil {
return SyncCreationResult{}, err return SyncCreationResult{}, err
@ -169,34 +319,53 @@ func (s *Service) SyncCreation(ctx context.Context, in SyncCreationInput) (SyncC
return SyncCreationResult{Action: SyncActionProjected, Reason: decision.Reason, NextStep: lastStep}, nil return SyncCreationResult{Action: SyncActionProjected, Reason: decision.Reason, NextStep: lastStep}, nil
} }
// loadOrUpsertIdentityAndSteps finds the identity row by provider work item and // loadExistingIdentityAndSteps finds the existing identity row and its completed steps,
// upserts a fresh row when none exists, then reads its completed-step set. The // returning found=false if not found on either milestone ID or provider work item lookup.
// upsert reuses storage's atomic conflict handling so a concurrent re-process func (s *Service) loadExistingIdentityAndSteps(ctx context.Context, candidate roadmapsync.ProjectionCandidate) (db.RoadmapSyncIdentity, map[storage.RoadmapSyncStep]bool, bool, error) {
// cannot rebind one identity onto another.
func (s *Service) loadOrUpsertIdentityAndSteps(ctx context.Context, in SyncCreationInput, candidate roadmapsync.ProjectionCandidate) (db.RoadmapSyncIdentity, map[storage.RoadmapSyncStep]bool, error) {
identity := candidate.Identity identity := candidate.Identity
row, err := s.store.GetRoadmapSyncIdentityByWorkItem(ctx, db.GetRoadmapSyncIdentityByWorkItemParams{ // 1. Try canonical ID (MilestoneID) lookup first
Provider: string(identity.Provider), row, err := s.store.GetRoadmapSyncIdentityByMilestoneID(ctx, identity.MilestoneID())
Tenant: identity.Tenant,
Project: identity.Project,
WorkItemID: identity.WorkItemID,
})
if err != nil { if err != nil {
if !errors.Is(err, storage.ErrRoadmapSyncIdentityNotFound) { if !errors.Is(err, storage.ErrRoadmapSyncIdentityNotFound) {
return db.RoadmapSyncIdentity{}, nil, err return db.RoadmapSyncIdentity{}, nil, false, err
} }
row, err = s.store.UpsertRoadmapSyncIdentity(ctx, upsertParams(identity, in))
// 2. Fall back to provider work item lookup
row, err = s.store.GetRoadmapSyncIdentityByWorkItem(ctx, db.GetRoadmapSyncIdentityByWorkItemParams{
Provider: string(identity.Provider),
Tenant: identity.Tenant,
Project: identity.Project,
WorkItemID: identity.WorkItemID,
})
if err != nil { if err != nil {
return db.RoadmapSyncIdentity{}, nil, err if !errors.Is(err, storage.ErrRoadmapSyncIdentityNotFound) {
return db.RoadmapSyncIdentity{}, nil, false, err
}
return db.RoadmapSyncIdentity{}, nil, false, nil
} }
} }
completed, err := s.store.CompletedSteps(ctx, row.ID) completed, err := s.store.CompletedSteps(ctx, row.ID)
if err != nil { if err != nil {
return db.RoadmapSyncIdentity{}, nil, err return db.RoadmapSyncIdentity{}, nil, false, err
} }
return row, completed, nil return row, completed, true, nil
}
func (s *Service) loadExistingByID(ctx context.Context, milestoneID string) (db.RoadmapSyncIdentity, map[storage.RoadmapSyncStep]bool, bool, error) {
row, err := s.store.GetRoadmapSyncIdentityByMilestoneID(ctx, milestoneID)
if err != nil {
if !errors.Is(err, storage.ErrRoadmapSyncIdentityNotFound) {
return db.RoadmapSyncIdentity{}, nil, false, err
}
return db.RoadmapSyncIdentity{}, nil, false, nil
}
completed, err := s.store.CompletedSteps(ctx, row.ID)
if err != nil {
return db.RoadmapSyncIdentity{}, nil, false, err
}
return row, completed, true, nil
} }
// runRemainingSteps executes the creation-cycle steps from nextStep onward, // runRemainingSteps executes the creation-cycle steps from nextStep onward,
@ -251,8 +420,8 @@ func (s *Service) runStep(ctx context.Context, step roadmapsync.Step, in roadmap
// upsertParams maps the matched identity and trigger revisions to the storage // upsertParams maps the matched identity and trigger revisions to the storage
// upsert parameters. Empty revisions are stored as NULL so a first cycle that // upsert parameters. Empty revisions are stored as NULL so a first cycle that
// has not observed a revision is not treated as a conflicting empty string. // has not observed a revision is not treated as a conflicting empty string.
func upsertParams(identity roadmapsync.Identity, in SyncCreationInput) db.UpsertRoadmapSyncIdentityByWorkItemParams { func upsertParams(identity roadmapsync.Identity, in SyncCreationInput) db.UpsertRoadmapSyncIdentityByMilestoneIDParams {
return db.UpsertRoadmapSyncIdentityByWorkItemParams{ return db.UpsertRoadmapSyncIdentityByMilestoneIDParams{
Shape: string(identity.Shape), Shape: string(identity.Shape),
RoadmapMilestonePath: identity.RoadmapMilestonePath, RoadmapMilestonePath: identity.RoadmapMilestonePath,
RoadmapItemID: identity.RoadmapItemID, RoadmapItemID: identity.RoadmapItemID,

View file

@ -3,6 +3,7 @@ package roadmapsyncpipeline
import ( import (
"context" "context"
"errors" "errors"
"strings"
"testing" "testing"
"github.com/nomadcode/nomadcode-core/internal/db" "github.com/nomadcode/nomadcode-core/internal/db"
@ -19,9 +20,10 @@ const (
// fakeProvider records the order of provider facet calls and can fail a chosen // fakeProvider records the order of provider facet calls and can fail a chosen
// step so a stop-before-later-step test is possible. // step so a stop-before-later-step test is possible.
type fakeProvider struct { type fakeProvider struct {
calls []string calls []string
failOn string failOn string
failErr error failErr error
createdInput workitem.CreateInput
} }
func (f *fakeProvider) AppendComment(_ context.Context, _ workitem.CommentInput) error { func (f *fakeProvider) AppendComment(_ context.Context, _ workitem.CommentInput) error {
@ -48,6 +50,22 @@ func (f *fakeProvider) ProjectStatus(_ context.Context, _ workitem.StatusProject
return nil return nil
} }
func (f *fakeProvider) CreateWorkItem(_ context.Context, input workitem.CreateInput) (workitem.CreateResult, error) {
f.calls = append(f.calls, "create")
f.createdInput = input
if f.failOn == "create" {
return workitem.CreateResult{}, f.failErr
}
return workitem.CreateResult{
Ref: workitem.Ref{
Provider: input.Provider,
Tenant: input.Tenant,
Project: input.Project,
ID: "CREATED-123",
},
}, nil
}
// fakeStore is an in-memory Store. identity is the single persisted row (id 0 // fakeStore is an in-memory Store. identity is the single persisted row (id 0
// means "not found"); marked records the steps MarkStepCompleted was called // means "not found"); marked records the steps MarkStepCompleted was called
// with, in order. // with, in order.
@ -58,12 +76,20 @@ type fakeStore struct {
marked []storage.RoadmapSyncStep marked []storage.RoadmapSyncStep
upserted bool upserted bool
markErr error markErr error
upsertErr error
} }
func newFakeStore() *fakeStore { func newFakeStore() *fakeStore {
return &fakeStore{completed: map[storage.RoadmapSyncStep]bool{}} return &fakeStore{completed: map[storage.RoadmapSyncStep]bool{}}
} }
func (f *fakeStore) GetRoadmapSyncIdentityByMilestoneID(_ context.Context, milestoneID string) (db.RoadmapSyncIdentity, error) {
if !f.found || f.identity.RoadmapItemID != milestoneID {
return db.RoadmapSyncIdentity{}, storage.ErrRoadmapSyncIdentityNotFound
}
return f.identity, nil
}
func (f *fakeStore) GetRoadmapSyncIdentityByWorkItem(_ context.Context, _ db.GetRoadmapSyncIdentityByWorkItemParams) (db.RoadmapSyncIdentity, error) { func (f *fakeStore) GetRoadmapSyncIdentityByWorkItem(_ context.Context, _ db.GetRoadmapSyncIdentityByWorkItemParams) (db.RoadmapSyncIdentity, error) {
if !f.found { if !f.found {
return db.RoadmapSyncIdentity{}, storage.ErrRoadmapSyncIdentityNotFound return db.RoadmapSyncIdentity{}, storage.ErrRoadmapSyncIdentityNotFound
@ -71,12 +97,16 @@ func (f *fakeStore) GetRoadmapSyncIdentityByWorkItem(_ context.Context, _ db.Get
return f.identity, nil return f.identity, nil
} }
func (f *fakeStore) UpsertRoadmapSyncIdentity(_ context.Context, args db.UpsertRoadmapSyncIdentityByWorkItemParams) (db.RoadmapSyncIdentity, error) { func (f *fakeStore) UpsertRoadmapSyncIdentity(_ context.Context, args db.UpsertRoadmapSyncIdentityByMilestoneIDParams) (db.RoadmapSyncIdentity, error) {
if f.upsertErr != nil {
return db.RoadmapSyncIdentity{}, f.upsertErr
}
f.upserted = true f.upserted = true
f.identity = db.RoadmapSyncIdentity{ f.identity = db.RoadmapSyncIdentity{
ID: 1, ID: 1,
Shape: args.Shape, Shape: args.Shape,
RoadmapMilestonePath: args.RoadmapMilestonePath, RoadmapMilestonePath: args.RoadmapMilestonePath,
RoadmapItemID: args.RoadmapItemID,
Provider: args.Provider, Provider: args.Provider,
Tenant: args.Tenant, Tenant: args.Tenant,
Project: args.Project, Project: args.Project,
@ -109,6 +139,7 @@ func readyInput() SyncCreationInput {
identity := roadmapsync.Identity{ identity := roadmapsync.Identity{
Shape: roadmapsync.ShapeMilestone, Shape: roadmapsync.ShapeMilestone,
RoadmapMilestonePath: testMilestonePath, RoadmapMilestonePath: testMilestonePath,
RoadmapItemID: "m1",
Provider: "plane", Provider: "plane",
Tenant: "general", Tenant: "general",
Project: "nomad", Project: "nomad",
@ -198,10 +229,13 @@ func TestSyncCreationRetrySkipsCompletedSteps(t *testing.T) {
ID: 1, ID: 1,
Shape: string(roadmapsync.ShapeMilestone), Shape: string(roadmapsync.ShapeMilestone),
RoadmapMilestonePath: testMilestonePath, RoadmapMilestonePath: testMilestonePath,
RoadmapItemID: "m1",
Provider: "plane", Provider: "plane",
Tenant: "general", Tenant: "general",
Project: "nomad", Project: "nomad",
WorkItemID: "NOMAD-1", WorkItemID: "NOMAD-1",
ProviderRevision: nullableString(testRevision),
RoadmapRevision: nullableString(testRevision),
} }
store.completed[storage.StepDevelopMatched] = true store.completed[storage.StepDevelopMatched] = true
store.completed[storage.StepOriginalCommentPreserved] = true store.completed[storage.StepOriginalCommentPreserved] = true
@ -283,6 +317,81 @@ func TestSyncCreationSkipsSelfMutation(t *testing.T) {
} }
} }
func TestSyncCreationRevisionMismatchConflicts(t *testing.T) {
store := newFakeStore()
store.found = true
store.identity = db.RoadmapSyncIdentity{
ID: 1,
Shape: string(roadmapsync.ShapeMilestone),
RoadmapMilestonePath: testMilestonePath,
RoadmapItemID: "m1",
Provider: "plane",
Tenant: "general",
Project: "nomad",
WorkItemID: "NOMAD-1",
ProviderRevision: nullableString("old-prov-rev"), // mismatch
RoadmapRevision: nullableString("old-road-rev"), // mismatch
}
provider := &fakeProvider{}
svc := NewService(store, provider, "")
res, err := svc.SyncCreation(context.Background(), readyInput())
if err != nil {
t.Fatalf("SyncCreation returned error: %v", err)
}
if res.Action != SyncActionConflict {
t.Fatalf("expected conflict action, got %s (%s)", res.Action, res.Reason)
}
if store.upserted {
t.Fatal("expected no upsert on revision mismatch conflict")
}
if len(provider.calls) > 0 {
t.Fatal("expected no provider calls on revision mismatch conflict")
}
if len(store.marked) > 0 {
t.Fatal("expected no ledger marks on revision mismatch conflict")
}
}
func TestSyncCreationAllowedAttributeUpdateAndReconcile(t *testing.T) {
store := newFakeStore()
store.found = true
store.identity = db.RoadmapSyncIdentity{
ID: 1,
Shape: string(roadmapsync.ShapeMilestone),
RoadmapMilestonePath: "agent-roadmap/phase/old-path/milestones/m1.md", // differs
RoadmapItemID: "m1",
Provider: "plane",
Tenant: "general",
Project: "nomad",
WorkItemID: "NOMAD-OLD", // differs
ProviderRevision: nullableString(testRevision),
RoadmapRevision: nullableString(testRevision),
}
provider := &fakeProvider{}
svc := NewService(store, provider, "")
in := readyInput()
res, err := svc.SyncCreation(context.Background(), in)
if err != nil {
t.Fatalf("SyncCreation returned error: %v", err)
}
if res.Action != SyncActionProjected {
t.Fatalf("expected projected action, got %s (%s)", res.Action, res.Reason)
}
if !store.upserted {
t.Fatal("expected upsert for allowed attribute update")
}
if store.identity.RoadmapMilestonePath != testMilestonePath {
t.Errorf("expected path to be updated to %q, got %q", testMilestonePath, store.identity.RoadmapMilestonePath)
}
if store.identity.WorkItemID != "NOMAD-1" {
t.Errorf("expected WorkItemID to be updated to NOMAD-1, got %q", store.identity.WorkItemID)
}
}
func equalStrings(a, b []string) bool { func equalStrings(a, b []string) bool {
if len(a) != len(b) { if len(a) != len(b) {
return false return false
@ -306,3 +415,280 @@ func equalSteps(a, b []storage.RoadmapSyncStep) bool {
} }
return true return true
} }
func TestSyncCreationMissingActiveMilestoneCreatesWorkItemAndRecordsLedger(t *testing.T) {
store := newFakeStore()
provider := &fakeProvider{}
svc := NewService(store, provider, "")
// 1. Prepare input representing missing active milestone (expected identity without provider details)
in := readyInput()
in.Expected = roadmapsync.Identity{
Shape: roadmapsync.ShapeMilestone,
RoadmapMilestonePath: testMilestonePath,
RoadmapItemID: "m1",
Tenant: "general",
Project: "nomad",
}
// Also clear Ref as there is no associated work item yet
in.Ref = workitem.Ref{}
res, err := svc.SyncCreation(context.Background(), in)
if err != nil {
t.Fatalf("SyncCreation returned error: %v", err)
}
if res.Action != SyncActionProjected {
t.Fatalf("expected projected action, got %s (%s)", res.Action, res.Reason)
}
// Fake creator should be called once with "create"
wantCalls := []string{"create"}
if !equalStrings(provider.calls, wantCalls) {
t.Fatalf("provider calls = %v, want %v", provider.calls, wantCalls)
}
// Title should be `[milestone-id] title` (in our case extract title should return Milestone from Markdown)
expectedTitle := "[m1] Milestone"
if provider.createdInput.Title != expectedTitle {
t.Errorf("expected created title %q, got %q", expectedTitle, provider.createdInput.Title)
}
// Ledger should upsert and mark StepDevelopMatched
if !store.upserted {
t.Fatal("expected identity to be upserted in store")
}
if store.identity.WorkItemID != "CREATED-123" {
t.Errorf("expected upserted WorkItemID to be CREATED-123, got %q", store.identity.WorkItemID)
}
if store.identity.RoadmapItemID != "m1" {
t.Errorf("expected upserted RoadmapItemID to be m1, got %q", store.identity.RoadmapItemID)
}
wantLedger := []storage.RoadmapSyncStep{
storage.StepDevelopMatched,
}
if !equalSteps(store.marked, wantLedger) {
t.Fatalf("ledger marks = %v, want %v", store.marked, wantLedger)
}
if res.NextStep != roadmapsync.StepDevelopMatched {
t.Fatalf("expected next step to be develop_matched, got %s", res.NextStep)
}
}
// TestSyncCreationBridgeProducedMissingCreate tests the actual bridge-produced
// missing-create job shape: Expected.Provider="plane", Tenant and Project set,
// WorkItemID empty, Ref.ID empty. This is the path that was broken when the
// predicate required Provider == "".
func TestSyncCreationBridgeProducedMissingCreate(t *testing.T) {
store := newFakeStore()
provider := &fakeProvider{}
svc := NewService(store, provider, "")
// Build input exactly as bridge.enqueueDoc does for missing-create:
// Provider="plane", Tenant/Project set from config, WorkItemID/Ref empty.
in := SyncCreationInput{
Scan: roadmapsync.ScanResult{
Revision: roadmapsync.Revision{Branch: "develop", Revision: "bbbb"},
ChangedFiles: []string{testMilestonePath},
ScannedMilestones: []roadmapsync.ScannedMilestone{
{Path: testMilestonePath},
},
},
Expected: roadmapsync.Identity{
Shape: roadmapsync.ShapeMilestone,
RoadmapMilestonePath: testMilestonePath,
RoadmapItemID: "m1",
Provider: "plane",
Tenant: "acme",
Project: "proj-1",
},
Ref: workitem.Ref{},
OriginalBody: "",
TodoStateID: "todo-state",
MilestoneMarkdown: "# Milestone\n\nbody",
RoadmapRevision: "bbbb",
}
res, err := svc.SyncCreation(context.Background(), in)
if err != nil {
t.Fatalf("SyncCreation returned error: %v", err)
}
if res.Action != SyncActionProjected {
t.Fatalf("expected projected action, got %s (%s)", res.Action, res.Reason)
}
// Verify CreateWorkItem was called exactly once.
if !equalStrings(provider.calls, []string{"create"}) {
t.Fatalf("provider calls = %v, want [create]", provider.calls)
}
// Verify CreateInput has the right provider/tenant/project.
if provider.createdInput.Provider != "plane" {
t.Errorf("createdInput.Provider = %q, want %q", provider.createdInput.Provider, "plane")
}
if provider.createdInput.Tenant != "acme" {
t.Errorf("createdInput.Tenant = %q, want %q", provider.createdInput.Tenant, "acme")
}
if provider.createdInput.Project != "proj-1" {
t.Errorf("createdInput.Project = %q, want %q", provider.createdInput.Project, "proj-1")
}
// Verify identity was upserted with the created work item id.
if !store.upserted {
t.Fatal("expected identity to be upserted in store")
}
if store.identity.WorkItemID != "CREATED-123" {
t.Errorf("upserted WorkItemID = %q, want CREATED-123", store.identity.WorkItemID)
}
if store.identity.Provider != "plane" {
t.Errorf("upserted Provider = %q, want plane", store.identity.Provider)
}
if store.identity.Tenant != "acme" {
t.Errorf("upserted Tenant = %q, want acme", store.identity.Tenant)
}
if store.identity.Project != "proj-1" {
t.Errorf("upserted Project = %q, want proj-1", store.identity.Project)
}
// Verify ledger mark.
wantLedger := []storage.RoadmapSyncStep{
storage.StepDevelopMatched,
}
if !equalSteps(store.marked, wantLedger) {
t.Fatalf("ledger marks = %v, want %v", store.marked, wantLedger)
}
}
// TestSyncCreationMissingCreateRequiresTenantAndProject ensures that a
// missing-create job without Tenant or Project is rejected before attempting
// CreateWorkItem.
func TestSyncCreationMissingCreateRequiresTenantAndProject(t *testing.T) {
store := newFakeStore()
provider := &fakeProvider{}
svc := NewService(store, provider, "")
in := SyncCreationInput{
Expected: roadmapsync.Identity{
Shape: roadmapsync.ShapeMilestone,
RoadmapMilestonePath: testMilestonePath,
RoadmapItemID: "m1",
Provider: "plane",
Project: "proj-1",
// Tenant missing
},
Ref: workitem.Ref{},
}
_, err := svc.SyncCreation(context.Background(), in)
if err == nil {
t.Fatal("expected error for missing tenant, got nil")
}
if !equalStrings(provider.calls, nil) && len(provider.calls) != 0 {
t.Fatalf("provider must not be called on validation error, got %v", provider.calls)
}
}
func TestSyncCreationAllowedAttributeUpdateTenantAndProject(t *testing.T) {
store := newFakeStore()
store.found = true
store.identity = db.RoadmapSyncIdentity{
ID: 1,
Shape: string(roadmapsync.ShapeMilestone),
RoadmapMilestonePath: testMilestonePath,
RoadmapItemID: "m1",
Provider: "plane",
Tenant: "old-tenant", // differs
Project: "old-project", // differs
WorkItemID: "NOMAD-1",
ProviderRevision: nullableString(testRevision),
RoadmapRevision: nullableString(testRevision),
}
provider := &fakeProvider{}
svc := NewService(store, provider, "")
in := readyInput()
res, err := svc.SyncCreation(context.Background(), in)
if err != nil {
t.Fatalf("SyncCreation returned error: %v", err)
}
if res.Action != SyncActionProjected {
t.Fatalf("expected projected action, got %s (%s)", res.Action, res.Reason)
}
if !store.upserted {
t.Fatal("expected upsert for allowed tenant/project update")
}
if store.identity.Tenant != "general" {
t.Errorf("expected Tenant to be updated to general, got %q", store.identity.Tenant)
}
if store.identity.Project != "nomad" {
t.Errorf("expected Project to be updated to nomad, got %q", store.identity.Project)
}
}
func TestSyncCreationWorkItemConflictReturnsConflictAction(t *testing.T) {
store := newFakeStore()
store.found = true
store.identity = db.RoadmapSyncIdentity{
ID: 1,
Shape: string(roadmapsync.ShapeMilestone),
RoadmapMilestonePath: testMilestonePath,
RoadmapItemID: "m1",
Provider: "plane",
Tenant: "general",
Project: "nomad",
WorkItemID: "NOMAD-1",
ProviderRevision: nullableString(testRevision),
RoadmapRevision: nullableString(testRevision),
}
// Simulate unique constraint violation error on upsert (e.g. work item bound to different milestone)
store.upsertErr = storage.ErrRoadmapSyncIdentityConflict
provider := &fakeProvider{}
svc := NewService(store, provider, "")
in := readyInput()
// Trigger attribute update by changing the path
in.Scan.ChangedFiles = []string{"agent-roadmap/phase/p1/milestones/m-new.md"}
in.Scan.ScannedMilestones[0].Path = "agent-roadmap/phase/p1/milestones/m-new.md"
in.Expected.RoadmapMilestonePath = "agent-roadmap/phase/p1/milestones/m-new.md"
res, err := svc.SyncCreation(context.Background(), in)
if err != nil {
t.Fatalf("SyncCreation returned error: %v", err)
}
if res.Action != SyncActionConflict {
t.Fatalf("expected conflict action, got %s (%s)", res.Action, res.Reason)
}
if !strings.Contains(res.Reason, "identity conflict") {
t.Errorf("expected conflict reason, got %q", res.Reason)
}
}
func TestSyncCreationMissingCreateWorkItemConflictReturnsConflictAction(t *testing.T) {
store := newFakeStore()
store.upsertErr = storage.ErrRoadmapSyncIdentityConflict
provider := &fakeProvider{}
svc := NewService(store, provider, "")
in := readyInput()
in.Expected = roadmapsync.Identity{
Shape: roadmapsync.ShapeMilestone,
RoadmapMilestonePath: testMilestonePath,
RoadmapItemID: "m1",
Tenant: "general",
Project: "nomad",
}
in.Ref = workitem.Ref{}
res, err := svc.SyncCreation(context.Background(), in)
if err != nil {
t.Fatalf("SyncCreation returned error: %v", err)
}
if res.Action != SyncActionConflict {
t.Fatalf("expected conflict action, got %s (%s)", res.Action, res.Reason)
}
}

View file

@ -38,10 +38,10 @@ func (s *Store) GetRoadmapSyncIdentityByWorkItem(ctx context.Context, args db.Ge
return row, nil return row, nil
} }
// GetRoadmapSyncIdentityByMilestonePath returns the identity bound to a roadmap // GetRoadmapSyncIdentityByMilestoneID returns the identity bound to a roadmap
// Milestone path, or ErrRoadmapSyncIdentityNotFound when none exists. // Milestone ID (filename slug), or ErrRoadmapSyncIdentityNotFound when none exists.
func (s *Store) GetRoadmapSyncIdentityByMilestonePath(ctx context.Context, milestonePath string) (db.RoadmapSyncIdentity, error) { func (s *Store) GetRoadmapSyncIdentityByMilestoneID(ctx context.Context, milestoneID string) (db.RoadmapSyncIdentity, error) {
row, err := s.queries.GetRoadmapSyncIdentityByMilestonePath(ctx, milestonePath) row, err := s.queries.GetRoadmapSyncIdentityByMilestoneID(ctx, milestoneID)
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return db.RoadmapSyncIdentity{}, ErrRoadmapSyncIdentityNotFound return db.RoadmapSyncIdentity{}, ErrRoadmapSyncIdentityNotFound
@ -51,29 +51,17 @@ func (s *Store) GetRoadmapSyncIdentityByMilestonePath(ctx context.Context, miles
return row, nil return row, nil
} }
// UpsertRoadmapSyncIdentity persists an identity keyed on the provider work // UpsertRoadmapSyncIdentity persists an identity keyed on the milestone canonical id,
// item, reusing the existing row when the same Plane ticket or the same // reusing the existing row when the same Milestone slug is re-processed.
// Milestone path is re-processed.
// //
// Conflict protection is atomic in a single statement rather than a non-atomic // Conflict protection is atomic in a single statement. If a different milestone
// read-then-write: the upsert's DO UPDATE keeps the stored roadmap_milestone_path // tries to reuse the same provider work item, it will hit the provider work item unique constraint,
// and only fires when it already equals the incoming path. So a re-process of // returning ErrRoadmapSyncIdentityConflict.
// the same provider work item with a different Milestone path matches no row on func (s *Store) UpsertRoadmapSyncIdentity(ctx context.Context, args db.UpsertRoadmapSyncIdentityByMilestoneIDParams) (db.RoadmapSyncIdentity, error) {
// conflict and returns pgx.ErrNoRows, which maps to ErrRoadmapSyncIdentityConflict row, err := s.queries.UpsertRoadmapSyncIdentityByMilestoneID(ctx, args)
// instead of silently rebinding the ticket to a new path. A different work item
// re-using an existing Milestone path trips the path unique index (SQLSTATE
// 23505), which also maps to ErrRoadmapSyncIdentityConflict instead of a raw pg
// error. Two concurrent re-processes therefore cannot both win.
func (s *Store) UpsertRoadmapSyncIdentity(ctx context.Context, args db.UpsertRoadmapSyncIdentityByWorkItemParams) (db.RoadmapSyncIdentity, error) {
row, err := s.queries.UpsertRoadmapSyncIdentityByWorkItem(ctx, args)
if err != nil { if err != nil {
// Work item already bound to a different Milestone path: the conditional // If another row already has the same provider + tenant + project + work_item_id,
// DO UPDATE matched no row, so RETURNING produced nothing. // the unique constraint ux_roadmap_sync_identities_provider_work_item is violated.
if errors.Is(err, pgx.ErrNoRows) {
return db.RoadmapSyncIdentity{}, ErrRoadmapSyncIdentityConflict
}
// Different work item re-using an existing Milestone path: the path unique
// index rejected the INSERT.
var pgErr *pgconn.PgError var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == pgUniqueViolation { if errors.As(err, &pgErr) && pgErr.Code == pgUniqueViolation {
return db.RoadmapSyncIdentity{}, ErrRoadmapSyncIdentityConflict return db.RoadmapSyncIdentity{}, ErrRoadmapSyncIdentityConflict

View file

@ -41,7 +41,7 @@ func (m *identityMockDBTX) QueryRow(ctx context.Context, sql string, args ...int
switch { switch {
case strings.Contains(sql, "INSERT INTO roadmap_sync_identities"): case strings.Contains(sql, "INSERT INTO roadmap_sync_identities"):
return &identityMockRow{result: m.upsert} return &identityMockRow{result: m.upsert}
case strings.Contains(sql, "WHERE roadmap_milestone_path = $1"): case strings.Contains(sql, "roadmap_item_id = $1"):
return &identityMockRow{result: m.byPath} return &identityMockRow{result: m.byPath}
case strings.Contains(sql, "WHERE provider = $1"): case strings.Contains(sql, "WHERE provider = $1"):
return &identityMockRow{result: m.byWork} return &identityMockRow{result: m.byWork}
@ -101,10 +101,11 @@ func newIdentityStore(m *identityMockDBTX) *Store {
return NewStoreWithQueries(db.New(m)) return NewStoreWithQueries(db.New(m))
} }
func sampleUpsertParams() db.UpsertRoadmapSyncIdentityByWorkItemParams { func sampleUpsertParams() db.UpsertRoadmapSyncIdentityByMilestoneIDParams {
return db.UpsertRoadmapSyncIdentityByWorkItemParams{ return db.UpsertRoadmapSyncIdentityByMilestoneIDParams{
Shape: "milestone", Shape: "milestone",
RoadmapMilestonePath: "agent-roadmap/phase/p/milestones/m.md", RoadmapMilestonePath: "agent-roadmap/phase/p/milestones/m.md",
RoadmapItemID: "m",
Provider: "plane", Provider: "plane",
Tenant: "general", Tenant: "general",
Project: "NOMAD", Project: "NOMAD",
@ -114,11 +115,11 @@ func sampleUpsertParams() db.UpsertRoadmapSyncIdentityByWorkItemParams {
// ---- UpsertRoadmapSyncIdentity ---- // ---- UpsertRoadmapSyncIdentity ----
func TestUpsertRoadmapSyncIdentity_ByWorkItemReturnsRow(t *testing.T) { func TestUpsertRoadmapSyncIdentity_ByMilestoneIDReturnsRow(t *testing.T) {
args := sampleUpsertParams() args := sampleUpsertParams()
m := &identityMockDBTX{ m := &identityMockDBTX{
upsert: identityRouteResult{row: db.RoadmapSyncIdentity{ upsert: identityRouteResult{row: db.RoadmapSyncIdentity{
ID: 7, Shape: "milestone", RoadmapMilestonePath: args.RoadmapMilestonePath, ID: 7, Shape: "milestone", RoadmapMilestonePath: args.RoadmapMilestonePath, RoadmapItemID: "m",
Provider: "plane", Tenant: "general", Project: "NOMAD", WorkItemID: "NOMAD-13", Provider: "plane", Tenant: "general", Project: "NOMAD", WorkItemID: "NOMAD-13",
}}, }},
} }
@ -131,23 +132,15 @@ func TestUpsertRoadmapSyncIdentity_ByWorkItemReturnsRow(t *testing.T) {
if row.ID != 7 || row.WorkItemID != "NOMAD-13" || row.RoadmapMilestonePath != args.RoadmapMilestonePath { if row.ID != 7 || row.WorkItemID != "NOMAD-13" || row.RoadmapMilestonePath != args.RoadmapMilestonePath {
t.Errorf("identity not preserved: %+v", row) t.Errorf("identity not preserved: %+v", row)
} }
if !sqlLogContains(m.queryLog, "ON CONFLICT (provider, tenant, project, work_item_id)") { if !sqlLogContains(m.queryLog, "ON CONFLICT (shape, roadmap_item_id)") {
t.Errorf("expected upsert query shape, log=%v", m.queryLog) t.Errorf("expected upsert query shape, log=%v", m.queryLog)
} }
// The conflict path must keep the stored Milestone path and only update when
// the incoming path already matches, so the rebind race is closed in SQL.
if !sqlLogContains(m.queryLog, "WHERE roadmap_sync_identities.roadmap_milestone_path = EXCLUDED.roadmap_milestone_path") {
t.Errorf("expected conditional path guard in DO UPDATE, log=%v", m.queryLog)
}
if sqlLogContains(m.queryLog, "roadmap_milestone_path = EXCLUDED.roadmap_milestone_path,") {
t.Errorf("DO UPDATE must not overwrite roadmap_milestone_path, log=%v", m.queryLog)
}
} }
func TestUpsertRoadmapSyncIdentity_SamePathSameWorkItemReuses(t *testing.T) { func TestUpsertRoadmapSyncIdentity_SameSlugSameWorkItemReuses(t *testing.T) {
args := sampleUpsertParams() args := sampleUpsertParams()
existing := db.RoadmapSyncIdentity{ existing := db.RoadmapSyncIdentity{
ID: 7, Shape: "milestone", RoadmapMilestonePath: args.RoadmapMilestonePath, ID: 7, Shape: "milestone", RoadmapMilestonePath: args.RoadmapMilestonePath, RoadmapItemID: "m",
Provider: "plane", Tenant: "general", Project: "NOMAD", WorkItemID: "NOMAD-13", Provider: "plane", Tenant: "general", Project: "NOMAD", WorkItemID: "NOMAD-13",
} }
m := &identityMockDBTX{upsert: identityRouteResult{row: existing}} m := &identityMockDBTX{upsert: identityRouteResult{row: existing}}
@ -162,28 +155,32 @@ func TestUpsertRoadmapSyncIdentity_SamePathSameWorkItemReuses(t *testing.T) {
} }
} }
// Same provider work item arriving with a different Milestone path: the func TestUpsertRoadmapSyncIdentity_MovedPathUpdates(t *testing.T) {
// conditional DO UPDATE matches no row, RETURNING is empty, and the store maps
// pgx.ErrNoRows to a typed conflict instead of rebinding the ticket.
func TestUpsertRoadmapSyncIdentity_WorkItemRebindToDifferentPathConflicts(t *testing.T) {
args := sampleUpsertParams() args := sampleUpsertParams()
m := &identityMockDBTX{upsert: identityRouteResult{err: pgx.ErrNoRows}} args.RoadmapMilestonePath = "agent-roadmap/phase/p2/milestones/m.md" // moved path
m := &identityMockDBTX{
upsert: identityRouteResult{row: db.RoadmapSyncIdentity{
ID: 7, Shape: "milestone", RoadmapMilestonePath: args.RoadmapMilestonePath, RoadmapItemID: "m",
Provider: "plane", Tenant: "general", Project: "NOMAD", WorkItemID: "NOMAD-13",
}},
}
store := newIdentityStore(m) store := newIdentityStore(m)
_, err := store.UpsertRoadmapSyncIdentity(context.Background(), args) row, err := store.UpsertRoadmapSyncIdentity(context.Background(), args)
if !errors.Is(err, ErrRoadmapSyncIdentityConflict) { if err != nil {
t.Fatalf("expected conflict, got %v", err) t.Fatalf("unexpected error: %v", err)
}
if row.RoadmapMilestonePath != "agent-roadmap/phase/p2/milestones/m.md" {
t.Errorf("expected path to be updated, got %q", row.RoadmapMilestonePath)
} }
} }
// A different provider work item re-using an existing Milestone path trips the func TestUpsertRoadmapSyncIdentity_WorkItemUniqueViolationConflicts(t *testing.T) {
// path unique index (SQLSTATE 23505); the store maps it to a typed conflict
// rather than surfacing a raw pg error.
func TestUpsertRoadmapSyncIdentity_PathUniqueViolationConflicts(t *testing.T) {
args := sampleUpsertParams() args := sampleUpsertParams()
m := &identityMockDBTX{upsert: identityRouteResult{err: &pgconn.PgError{ m := &identityMockDBTX{upsert: identityRouteResult{err: &pgconn.PgError{
Code: pgUniqueViolation, Code: pgUniqueViolation,
ConstraintName: "ux_roadmap_sync_identities_milestone_path", ConstraintName: "ux_roadmap_sync_identities_provider_work_item",
}}} }}}
store := newIdentityStore(m) store := newIdentityStore(m)
@ -224,17 +221,17 @@ func TestGetRoadmapSyncIdentityByWorkItem_NotFoundSentinel(t *testing.T) {
} }
} }
func TestGetRoadmapSyncIdentityByMilestonePath_ReturnsRow(t *testing.T) { func TestGetRoadmapSyncIdentityByMilestoneID_ReturnsRow(t *testing.T) {
m := &identityMockDBTX{byPath: identityRouteResult{row: db.RoadmapSyncIdentity{ m := &identityMockDBTX{byPath: identityRouteResult{row: db.RoadmapSyncIdentity{
ID: 4, RoadmapMilestonePath: "agent-roadmap/phase/p/milestones/m.md", WorkItemID: "NOMAD-13", ID: 4, RoadmapMilestonePath: "agent-roadmap/phase/p/milestones/m.md", RoadmapItemID: "m", WorkItemID: "NOMAD-13",
}}} }}}
store := newIdentityStore(m) store := newIdentityStore(m)
row, err := store.GetRoadmapSyncIdentityByMilestonePath(context.Background(), "agent-roadmap/phase/p/milestones/m.md") row, err := store.GetRoadmapSyncIdentityByMilestoneID(context.Background(), "m")
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
if row.ID != 4 || row.WorkItemID != "NOMAD-13" { if row.ID != 4 || row.WorkItemID != "NOMAD-13" || row.RoadmapItemID != "m" {
t.Errorf("row not preserved: %+v", row) t.Errorf("row not preserved: %+v", row)
} }
} }

View file

@ -0,0 +1,23 @@
-- +goose Up
-- +goose StatementBegin
-- 1. Backfill existing milestone rows without roadmap_item_id
UPDATE roadmap_sync_identities
SET roadmap_item_id = replace(substring(roadmap_milestone_path from '[^/]+$'), '.md', '')
WHERE shape = 'milestone' AND (roadmap_item_id = '' OR roadmap_item_id IS NULL);
-- 2. Drop the unique index on milestone path
DROP INDEX IF EXISTS ux_roadmap_sync_identities_milestone_path;
-- 3. Add unique index for milestone canonical id
CREATE UNIQUE INDEX ux_roadmap_sync_identities_milestone_id
ON roadmap_sync_identities (shape, roadmap_item_id)
WHERE shape = 'milestone' AND roadmap_item_id <> '';
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP INDEX IF EXISTS ux_roadmap_sync_identities_milestone_id;
CREATE UNIQUE INDEX ux_roadmap_sync_identities_milestone_path
ON roadmap_sync_identities (roadmap_milestone_path);
-- +goose StatementEnd

View file

@ -3,23 +3,25 @@ SELECT id, shape, roadmap_milestone_path, roadmap_item_id, provider, tenant, pro
FROM roadmap_sync_identities FROM roadmap_sync_identities
WHERE provider = $1 AND tenant = $2 AND project = $3 AND work_item_id = $4; WHERE provider = $1 AND tenant = $2 AND project = $3 AND work_item_id = $4;
-- name: GetRoadmapSyncIdentityByMilestonePath :one -- name: GetRoadmapSyncIdentityByMilestoneID :one
SELECT id, shape, roadmap_milestone_path, roadmap_item_id, provider, tenant, project, work_item_id, parent_work_item_id, provider_revision, roadmap_revision, created_at, updated_at SELECT id, shape, roadmap_milestone_path, roadmap_item_id, provider, tenant, project, work_item_id, parent_work_item_id, provider_revision, roadmap_revision, created_at, updated_at
FROM roadmap_sync_identities FROM roadmap_sync_identities
WHERE roadmap_milestone_path = $1; WHERE shape = 'milestone' AND roadmap_item_id = $1;
-- name: UpsertRoadmapSyncIdentityByWorkItem :one -- name: UpsertRoadmapSyncIdentityByMilestoneID :one
INSERT INTO roadmap_sync_identities ( INSERT INTO roadmap_sync_identities (
shape, roadmap_milestone_path, roadmap_item_id, provider, tenant, project, work_item_id, parent_work_item_id, provider_revision, roadmap_revision shape, roadmap_milestone_path, roadmap_item_id, provider, tenant, project, work_item_id, parent_work_item_id, provider_revision, roadmap_revision
) )
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (provider, tenant, project, work_item_id) ON CONFLICT (shape, roadmap_item_id) WHERE shape = 'milestone' AND roadmap_item_id <> ''
DO UPDATE SET DO UPDATE SET
shape = EXCLUDED.shape, roadmap_milestone_path = EXCLUDED.roadmap_milestone_path,
roadmap_item_id = EXCLUDED.roadmap_item_id, provider = EXCLUDED.provider,
tenant = EXCLUDED.tenant,
project = EXCLUDED.project,
work_item_id = EXCLUDED.work_item_id,
parent_work_item_id = EXCLUDED.parent_work_item_id, parent_work_item_id = EXCLUDED.parent_work_item_id,
provider_revision = EXCLUDED.provider_revision, provider_revision = EXCLUDED.provider_revision,
roadmap_revision = EXCLUDED.roadmap_revision, roadmap_revision = EXCLUDED.roadmap_revision,
updated_at = now() updated_at = now()
WHERE roadmap_sync_identities.roadmap_milestone_path = EXCLUDED.roadmap_milestone_path
RETURNING id, shape, roadmap_milestone_path, roadmap_item_id, provider, tenant, project, work_item_id, parent_work_item_id, provider_revision, roadmap_revision, created_at, updated_at; RETURNING id, shape, roadmap_milestone_path, roadmap_item_id, provider, tenant, project, work_item_id, parent_work_item_id, provider_revision, roadmap_revision, created_at, updated_at;