fix(sync): G08 self-loop update conflict fixes and test additions
This commit is contained in:
parent
0d619b446c
commit
1557387638
11 changed files with 511 additions and 138 deletions
|
|
@ -0,0 +1,183 @@
|
|||
<!-- task=m-agent-origin-milestone-creation-sync/03+01,02_self_loop_update_conflict 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 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.
|
||||
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only.
|
||||
|
||||
## 개요
|
||||
|
||||
date=2026-07-02
|
||||
task=m-agent-origin-milestone-creation-sync/03+01,02_self_loop_update_conflict, plan=0, tag=API
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/agent-origin-milestone-creation-sync.md`
|
||||
- Task ids:
|
||||
- `self-loop`: Core-authored identity backfill webhook은 provider 생성/갱신을 반복하지 않고 ledger와 markdown identity 일치 여부만 확인한다.
|
||||
- `identity-update`: provider identity가 이미 있는 Milestone의 agent-origin 본문/제목 변경은 Plane 본문/제목 갱신으로 수렴한다.
|
||||
- `conflict-policy`: rename, filename slug reuse, provider identity mismatch, stale Plane work item, duplicate active slug를 안전하게 분류한다.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 이 파일을 읽는 리뷰 에이전트에게
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
|
||||
|
||||
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
|
||||
리뷰 완료는 판정 append, active 파일 log archive, PASS 시 `complete.log` 작성과 task directory archive까지 끝난 상태를 의미합니다.
|
||||
|
||||
---
|
||||
|
||||
## 구현 항목별 완료 여부
|
||||
|
||||
| 항목 | 완료 여부 |
|
||||
|------|---------|
|
||||
| [API-1] Self-Loop Redelivery Guard | [x] |
|
||||
| [API-2] Existing Identity Update Projection | [x] |
|
||||
| [API-3] Conflict Classification Matrix | [x] |
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [x] `self-loop`에서 Core-authored identity-only backfill commit 재전달은 markdown provider identity와 DB ledger를 비교하고 provider create/update 없이 complete/no-op으로 수렴한다. 검증: backfill commit 재전달과 Core 재시작 후 재전달 모두 provider mutation을 호출하지 않는다.
|
||||
- [x] `identity-update`에서 provider identity가 이미 있는 active Milestone의 제목/본문 변경은 `after` revision guard 후 Plane title/body update로 투영하고 completed-step ledger로 partial failure 재시도가 가능하다.
|
||||
- [x] `conflict-policy`에서 rename, filename slug reuse, provider identity mismatch, stale Plane work item, duplicate active slug가 destructive overwrite 없이 conflict/not-ready/skip으로 분류된다.
|
||||
- [x] conflict/not-ready/skip result는 operator가 확인할 수 있는 reason/log를 남기고, provider create/update/status mutation이 제한되는지 테스트한다.
|
||||
- [x] `git diff --check`, focused `go test -count=1`, focused `go vet`를 실행한다.
|
||||
- [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_G08_N.log`로 아카이브한다.
|
||||
- [x] active `PLAN-*-G??.md`를 `plan_cloud_G08_M.log`로 아카이브한다.
|
||||
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
|
||||
- [x] PASS이면 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
|
||||
- [x] PASS이면 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-agent-origin-milestone-creation-sync/03+01,02_self_loop_update_conflict/`로 이동한다.
|
||||
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
|
||||
- [x] PASS split 작업이면 이동 후 빈 active parent를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
|
||||
- [ ] WARN/FAIL이면 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성하고 `complete.log`를 작성하지 않는다.
|
||||
|
||||
## 계획 대비 변경 사항
|
||||
|
||||
선행 subtask `01_scan_create_ledger`와 `02+01_identity_backfill`가 이미 대부분의 런타임 로직(conflict/not-ready/skip typed action, reconcile revision/identity guard, missing-create + backfill duplicate retry, `BodyProjector` 기반 title/body update projection, scanner duplicate-active-slug 및 active/archive slug 분리)을 구현·검증(PASS, archive된 `complete.log` 확인)했다. 따라서 이 subtask에서 필요한 실제 신규 코드는 좁았고, 계획의 각 API 항목은 주로 S05/S06/S07 회귀 테스트 공백을 닫는 방향으로 구현했다. 구체적 차이:
|
||||
|
||||
- API-1 production: 계획의 "scanner.go에 identity-only/self-authored hint를 scan result에 보존"은 `ScanOutput.SelfAuthored` 힌트로 구현했다. tip commit(`ev.After`) 메시지의 Core backfill marker를 1회 `git show -s --format=%B`로 읽어 판정하며, **hint 전용**이다 — 활성 Milestone index(`git ls-tree`)와 identity/step ledger가 여전히 source of truth이고 hint는 enqueue/scan 대상을 바꾸지 않는다. bridge는 이 hint를 진단 로그로만 소비한다. marker 상수는 `roadmapsyncpipeline.CommitMarker`로 export해 backfiller와 scanner가 같은 문자열을 공유하게 했다(단일 source of truth). marker 조회 실패는 진단 힌트이므로 scan을 실패시키지 않고 `false`로 둔다.
|
||||
- API-1 service: "ledger+markdown identity match → provider mutation 없이 complete/no-op"은 기존 `ReconcileCreationCycle`의 `ActionComplete` 경로가 이미 만족하므로 로직 변경 없이 self-loop 재전달/재시작 등가 회귀 테스트로 보증했다. self-loop은 marker 없이도 durable ledger로 수렴한다.
|
||||
- API-2: `identity-update` projection(title/body update + partial-failure retry-from-ledger)은 기존 `BodyProjector`/`UpdatePlaneBody`/step ledger로 이미 지원되므로 provider 인터페이스/adapter는 재사용했고(신규 확장 없음), existing-identity partial-failure→resume 회귀 테스트만 추가했다.
|
||||
- API-3: rename/slug reuse, duplicate active slug, provider identity mismatch, stale work item(bridge `ErrNotFound` skip), storage slug/provider conflict는 선행 테스트로 이미 정합하게 커버되어 있었다. 신규로는 job 경계에서 conflict/not-ready/skip이 River retry storm 없이(worker가 nil 반환) 종결되고 external task를 finalize하지 않는지 검증하는 테이블 테스트를 scheduler에 추가했다.
|
||||
|
||||
계획 파일 요약표에 있으나 실제 수정하지 않은 파일: `gitosync/bridge_test.go`, `roadmapsync/retry.go`·`retry_test.go`, `storage/roadmap_sync_identities.go`·`_test.go`, `adapters/plane/client.go`·`_test.go`, `workitem/provider.go`, `scheduler/jobs.go`. 해당 동작은 선행 subtask 구현/테스트로 이미 충족되어 재수정이 불필요했다. scheduler 쪽 job 테스트는 `roadmap_sync_jobs_test.go`가 아니라 기존 worker 테스트가 있는 `scheduler/river_test.go`에 추가했다.
|
||||
|
||||
## 주요 설계 결정
|
||||
|
||||
- **self-authored marker는 hint 전용, ledger가 source of truth.** self-loop 방지의 durable 보증은 identity/step ledger이며 commit marker는 진단/관측 힌트다. marker를 `SkipSelfMutation` 스킵 트리거로 배선하지 않았다 — missing-create 흐름에서 remaining projection step(comment/body/status)을 실제로 진행시키는 트리거가 바로 self-authored backfill commit이므로, marker로 무조건 스킵하면 creation cycle이 영구 정지한다. 대신 완료된 ledger를 만나는 재전달만 `ActionComplete`로 수렴한다. self-loop은 body/status가 git commit을 만들지 않으므로 자연히 유한하게 끝난다.
|
||||
- **marker 상수 export.** `roadmapsyncpipeline.CommitMarker`를 단일 source of truth로 두고 scanner가 참조한다. gitosync → roadmapsyncpipeline import는 비순환이다(roadmapsyncpipeline은 gitosync/scheduler를 import하지 않음).
|
||||
- **restart 등가성은 서비스 레벨에서 표현.** 서비스는 프로세스 내 dedup 메모리가 없고 매 호출 DB ledger를 읽으므로, 새 `Service` 인스턴스(재시작 등가)로 같은 store에 재전달해도 complete/no-op이 나오는 것으로 durability를 증명했다. in-memory revision store는 bridge의 프로세스 수명 중복 억제일 뿐이다.
|
||||
- **conflict/not-ready/skip은 worker가 nil 반환.** 이미 provider mutation 없이 orchestrator가 내린 종결 결정이므로 River가 재시도하지 않도록 nil을 반환하고 external task finalize를 하지 않는다(retry storm 방지). reason은 worker 로그로 관측 가능하다.
|
||||
|
||||
## 사용자 리뷰 요청
|
||||
|
||||
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
|
||||
|
||||
- 상태: 없음
|
||||
- 사유 유형: 없음
|
||||
- 연결 대상: 없음
|
||||
- 결정 필요: 없음
|
||||
- 차단 근거: 없음
|
||||
- 실행한 검증/명령: 없음
|
||||
- 자동 후속 불가 이유: 없음
|
||||
- 재개 조건: 없음
|
||||
|
||||
## 리뷰어를 위한 체크포인트
|
||||
|
||||
- Implementation waited for predecessor `01_scan_create_ledger` and `02+01_identity_backfill` completion.
|
||||
- Self-loop redelivery compares markdown identity and ledger before returning complete/no-op.
|
||||
- Existing identity title/body update is retryable through completed-step ledger.
|
||||
- Conflict/not-ready/skip cases do not call provider mutation except the intended identity-update path.
|
||||
|
||||
## 검증 결과
|
||||
|
||||
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
|
||||
|
||||
신규/변경 테스트:
|
||||
- `internal/gitosync/scanner_test.go`: `TestScanFlagsSelfAuthoredBackfillCommitAsHintOnly`, `TestScanDoesNotFlagSelfAuthoredForOrdinaryCommit`, `TestScanRunsFetchRevParseActiveIndexShowInOrder`(신규 marker 조회 call 반영).
|
||||
- `internal/roadmapsyncpipeline/service_test.go`: `TestSyncCreationSelfLoopRedeliveryCompletesWithoutProviderMutation`, `TestSyncCreationSelfLoopRestartEquivalentUsesDurableLedger`(API-1), `TestSyncCreationExistingIdentityUpdatePartialFailureThenResumes`(API-2).
|
||||
- `internal/scheduler/river_test.go`: `TestRoadmapCreationSyncWorkerDoesNotRetryTerminalOutcomes`(conflict/not_ready/skip_self_mutation, API-3).
|
||||
|
||||
### API-1 중간 검증
|
||||
```sh
|
||||
$ cd services/core && go test -count=1 ./internal/gitosync ./internal/roadmapsyncpipeline ./internal/storage
|
||||
ok github.com/nomadcode/nomadcode-core/internal/gitosync 1.407s
|
||||
ok github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline 1.012s
|
||||
ok github.com/nomadcode/nomadcode-core/internal/storage 0.003s
|
||||
```
|
||||
|
||||
### API-2 중간 검증
|
||||
```sh
|
||||
$ cd services/core && go test -count=1 ./internal/roadmapsync ./internal/roadmapsyncpipeline ./internal/adapters/plane ./internal/workitem
|
||||
ok github.com/nomadcode/nomadcode-core/internal/roadmapsync 0.003s
|
||||
ok github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline 0.997s
|
||||
ok github.com/nomadcode/nomadcode-core/internal/adapters/plane 0.007s
|
||||
ok github.com/nomadcode/nomadcode-core/internal/workitem 0.003s
|
||||
```
|
||||
|
||||
### API-3 중간 검증
|
||||
```sh
|
||||
$ cd services/core && go test -count=1 ./internal/gitosync ./internal/roadmapsyncpipeline ./internal/storage ./internal/scheduler
|
||||
ok github.com/nomadcode/nomadcode-core/internal/gitosync 1.397s
|
||||
ok github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline 1.034s
|
||||
ok github.com/nomadcode/nomadcode-core/internal/storage 0.003s
|
||||
ok github.com/nomadcode/nomadcode-core/internal/scheduler 2.036s
|
||||
```
|
||||
|
||||
### 최종 검증
|
||||
```sh
|
||||
$ git diff --check
|
||||
(no output; exit 0)
|
||||
$ cd services/core && go test -count=1 ./internal/gitosync ./internal/roadmapsync ./internal/roadmapsyncpipeline ./internal/storage ./internal/adapters/plane ./internal/workitem ./internal/scheduler ./internal/http
|
||||
ok github.com/nomadcode/nomadcode-core/internal/gitosync 1.401s
|
||||
ok github.com/nomadcode/nomadcode-core/internal/roadmapsync 0.003s
|
||||
ok github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline 1.108s
|
||||
ok github.com/nomadcode/nomadcode-core/internal/storage 0.006s
|
||||
ok github.com/nomadcode/nomadcode-core/internal/adapters/plane 0.009s
|
||||
ok github.com/nomadcode/nomadcode-core/internal/workitem 0.003s
|
||||
ok github.com/nomadcode/nomadcode-core/internal/scheduler 2.037s
|
||||
ok github.com/nomadcode/nomadcode-core/internal/http 0.007s
|
||||
$ cd services/core && go vet ./internal/gitosync ./internal/roadmapsync ./internal/roadmapsyncpipeline ./internal/storage ./internal/adapters/plane ./internal/workitem ./internal/scheduler ./internal/http
|
||||
(no output; exit 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?**
|
||||
|
||||
## 코드리뷰 결과
|
||||
|
||||
- 종합 판정: PASS
|
||||
- 차원별 평가:
|
||||
- correctness: Pass
|
||||
- completeness: Pass
|
||||
- test coverage: Pass
|
||||
- API contract: Pass
|
||||
- code quality: Pass
|
||||
- implementation deviation: Pass
|
||||
- verification trust: Pass
|
||||
- spec conformance: Pass
|
||||
- 발견된 문제: 없음
|
||||
- 다음 단계: PASS이므로 active plan/review를 log로 아카이브하고 `complete.log` 작성 후 task directory를 archive로 이동한다.
|
||||
|
||||
### 리뷰 메모
|
||||
|
||||
- SDD S05/S06/S07 Evidence Map과 Roadmap Targets를 대조했다. self-loop complete/no-op, existing identity title/body projection retry, conflict/not-ready/skip terminal handling이 각각 테스트 evidence로 연결된다.
|
||||
- 직접 재실행 검증:
|
||||
- `git diff --check`: PASS, output 없음.
|
||||
- `cd services/core && go test -count=1 ./internal/gitosync ./internal/roadmapsync ./internal/roadmapsyncpipeline ./internal/storage ./internal/adapters/plane ./internal/workitem ./internal/scheduler ./internal/http`: PASS.
|
||||
- `cd services/core && go vet ./internal/gitosync ./internal/roadmapsync ./internal/roadmapsyncpipeline ./internal/storage ./internal/adapters/plane ./internal/workitem ./internal/scheduler ./internal/http`: PASS, output 없음.
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
# Complete - m-agent-origin-milestone-creation-sync/03+01,02_self_loop_update_conflict
|
||||
|
||||
## 완료 일시
|
||||
|
||||
2026-07-02
|
||||
|
||||
## 요약
|
||||
|
||||
Agent-origin Milestone creation sync의 self-loop, existing identity update, conflict policy slice를 1회 리뷰 루프로 완료했다. 최종 판정은 PASS다.
|
||||
|
||||
## 루프 이력
|
||||
|
||||
| Plan | Review | Verdict | 메모 |
|
||||
|------|--------|---------|------|
|
||||
| `plan_cloud_G08_0.log` | `code_review_cloud_G08_0.log` | PASS | S05/S06/S07 evidence와 focused core 검증 통과 |
|
||||
|
||||
## 구현/정리 내용
|
||||
|
||||
- Core-authored identity backfill commit marker를 `roadmapsyncpipeline.CommitMarker`로 공유하고, scanner가 self-authored backfill revision을 hint로 관측하되 active scan/enqueue source of truth는 ledger와 verified revision에 유지했다.
|
||||
- Completed-step ledger가 완성된 self-loop redelivery와 restart-equivalent reprocessing이 provider mutation 없이 `complete`로 수렴하는 회귀 테스트를 추가했다.
|
||||
- Existing provider identity가 있는 Milestone title/body projection이 partial failure 후 completed-step ledger에서 body/status만 재개되는지 검증했다.
|
||||
- conflict/not-ready/skip terminal outcome에서 River retry storm과 external task finalize가 발생하지 않는 scheduler job boundary 테스트를 추가했다.
|
||||
|
||||
## 최종 검증
|
||||
|
||||
- `git diff --check` - PASS; output 없음.
|
||||
- `cd services/core && go test -count=1 ./internal/gitosync ./internal/roadmapsync ./internal/roadmapsyncpipeline ./internal/storage ./internal/adapters/plane ./internal/workitem ./internal/scheduler ./internal/http` - PASS; 모든 대상 패키지 `ok`.
|
||||
- `cd services/core && go vet ./internal/gitosync ./internal/roadmapsync ./internal/roadmapsyncpipeline ./internal/storage ./internal/adapters/plane ./internal/workitem ./internal/scheduler ./internal/http` - PASS; output 없음.
|
||||
|
||||
## Roadmap Completion
|
||||
|
||||
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/agent-origin-milestone-creation-sync.md`
|
||||
- Completed task ids:
|
||||
- `self-loop`: PASS; evidence=`agent-task/archive/2026/07/m-agent-origin-milestone-creation-sync/03+01,02_self_loop_update_conflict/plan_cloud_G08_0.log`, `agent-task/archive/2026/07/m-agent-origin-milestone-creation-sync/03+01,02_self_loop_update_conflict/code_review_cloud_G08_0.log`; verification=`cd services/core && go test -count=1 ./internal/gitosync ./internal/roadmapsyncpipeline ./internal/storage`
|
||||
- `identity-update`: PASS; evidence=`agent-task/archive/2026/07/m-agent-origin-milestone-creation-sync/03+01,02_self_loop_update_conflict/plan_cloud_G08_0.log`, `agent-task/archive/2026/07/m-agent-origin-milestone-creation-sync/03+01,02_self_loop_update_conflict/code_review_cloud_G08_0.log`; verification=`cd services/core && go test -count=1 ./internal/roadmapsync ./internal/roadmapsyncpipeline ./internal/adapters/plane ./internal/workitem`
|
||||
- `conflict-policy`: PASS; evidence=`agent-task/archive/2026/07/m-agent-origin-milestone-creation-sync/03+01,02_self_loop_update_conflict/plan_cloud_G08_0.log`, `agent-task/archive/2026/07/m-agent-origin-milestone-creation-sync/03+01,02_self_loop_update_conflict/code_review_cloud_G08_0.log`; verification=`cd services/core && go test -count=1 ./internal/gitosync ./internal/roadmapsyncpipeline ./internal/storage ./internal/scheduler`
|
||||
- Not completed task ids: 없음
|
||||
|
||||
## 잔여 Nit
|
||||
|
||||
- 없음
|
||||
|
||||
## 후속 작업
|
||||
|
||||
- 없음
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
<!-- task=m-agent-origin-milestone-creation-sync/03+01,02_self_loop_update_conflict 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 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.
|
||||
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only.
|
||||
|
||||
## 개요
|
||||
|
||||
date=2026-07-02
|
||||
task=m-agent-origin-milestone-creation-sync/03+01,02_self_loop_update_conflict, plan=0, tag=API
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/agent-origin-milestone-creation-sync.md`
|
||||
- Task ids:
|
||||
- `self-loop`: Core-authored identity backfill webhook은 provider 생성/갱신을 반복하지 않고 ledger와 markdown identity 일치 여부만 확인한다.
|
||||
- `identity-update`: provider identity가 이미 있는 Milestone의 agent-origin 본문/제목 변경은 Plane 본문/제목 갱신으로 수렴한다.
|
||||
- `conflict-policy`: rename, filename slug reuse, provider identity mismatch, stale Plane work item, duplicate active slug를 안전하게 분류한다.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 이 파일을 읽는 리뷰 에이전트에게
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
|
||||
|
||||
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
|
||||
리뷰 완료는 판정 append, active 파일 log archive, PASS 시 `complete.log` 작성과 task directory archive까지 끝난 상태를 의미합니다.
|
||||
|
||||
---
|
||||
|
||||
## 구현 항목별 완료 여부
|
||||
|
||||
| 항목 | 완료 여부 |
|
||||
|------|---------|
|
||||
| [API-1] Self-Loop Redelivery Guard | [ ] |
|
||||
| [API-2] Existing Identity Update Projection | [ ] |
|
||||
| [API-3] Conflict Classification Matrix | [ ] |
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [ ] `self-loop`에서 Core-authored identity-only backfill commit 재전달은 markdown provider identity와 DB ledger를 비교하고 provider create/update 없이 complete/no-op으로 수렴한다. 검증: backfill commit 재전달과 Core 재시작 후 재전달 모두 provider mutation을 호출하지 않는다.
|
||||
- [ ] `identity-update`에서 provider identity가 이미 있는 active Milestone의 제목/본문 변경은 `after` revision guard 후 Plane title/body update로 투영하고 completed-step ledger로 partial failure 재시도가 가능하다.
|
||||
- [ ] `conflict-policy`에서 rename, filename slug reuse, provider identity mismatch, stale Plane work item, duplicate active slug가 destructive overwrite 없이 conflict/not-ready/skip으로 분류된다.
|
||||
- [ ] conflict/not-ready/skip result는 operator가 확인할 수 있는 reason/log를 남기고, provider create/update/status mutation이 제한되는지 테스트한다.
|
||||
- [ ] `git diff --check`, focused `go test -count=1`, focused `go vet`를 실행한다.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
## 코드리뷰 전용 체크리스트
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
|
||||
|
||||
- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
|
||||
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
|
||||
- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G08_N.log`로 아카이브한다.
|
||||
- [ ] active `PLAN-*-G??.md`를 `plan_cloud_G08_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 디렉터리를 `agent-task/archive/YYYY/MM/m-agent-origin-milestone-creation-sync/03+01,02_self_loop_update_conflict/`로 이동한다.
|
||||
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
|
||||
- [ ] PASS split 작업이면 이동 후 빈 active parent를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
|
||||
- [ ] WARN/FAIL이면 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성하고 `complete.log`를 작성하지 않는다.
|
||||
|
||||
## 계획 대비 변경 사항
|
||||
|
||||
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
|
||||
|
||||
## 주요 설계 결정
|
||||
|
||||
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
|
||||
|
||||
## 사용자 리뷰 요청
|
||||
|
||||
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
|
||||
|
||||
- 상태: 없음
|
||||
- 사유 유형: 없음
|
||||
- 연결 대상: 없음
|
||||
- 결정 필요: 없음
|
||||
- 차단 근거: 없음
|
||||
- 실행한 검증/명령: 없음
|
||||
- 자동 후속 불가 이유: 없음
|
||||
- 재개 조건: 없음
|
||||
|
||||
## 리뷰어를 위한 체크포인트
|
||||
|
||||
- Implementation waited for predecessor `01_scan_create_ledger` and `02+01_identity_backfill` completion.
|
||||
- Self-loop redelivery compares markdown identity and ledger before returning complete/no-op.
|
||||
- Existing identity title/body update is retryable through completed-step ledger.
|
||||
- Conflict/not-ready/skip cases do not call provider mutation except the intended identity-update path.
|
||||
|
||||
## 검증 결과
|
||||
|
||||
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
|
||||
|
||||
### API-1 중간 검증
|
||||
```sh
|
||||
$ cd services/core && go test -count=1 ./internal/gitosync ./internal/roadmapsyncpipeline ./internal/storage
|
||||
(output)
|
||||
```
|
||||
|
||||
### API-2 중간 검증
|
||||
```sh
|
||||
$ cd services/core && go test -count=1 ./internal/roadmapsync ./internal/roadmapsyncpipeline ./internal/adapters/plane ./internal/workitem
|
||||
(output)
|
||||
```
|
||||
|
||||
### API-3 중간 검증
|
||||
```sh
|
||||
$ cd services/core && go test -count=1 ./internal/gitosync ./internal/roadmapsyncpipeline ./internal/storage ./internal/scheduler
|
||||
(output)
|
||||
```
|
||||
|
||||
### 최종 검증
|
||||
```sh
|
||||
$ git diff --check
|
||||
(output)
|
||||
$ cd services/core && go test -count=1 ./internal/gitosync ./internal/roadmapsync ./internal/roadmapsyncpipeline ./internal/storage ./internal/adapters/plane ./internal/workitem ./internal/scheduler ./internal/http
|
||||
(output)
|
||||
$ cd services/core && go vet ./internal/gitosync ./internal/roadmapsync ./internal/roadmapsyncpipeline ./internal/storage ./internal/adapters/plane ./internal/workitem ./internal/scheduler ./internal/http
|
||||
(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?**
|
||||
|
|
@ -117,6 +117,15 @@ func (b *Bridge) Handle(ctx context.Context, ev gitoevents.BranchUpdatedEvent) e
|
|||
return err
|
||||
}
|
||||
|
||||
if out.SelfAuthored {
|
||||
// The wakeup is NomadCode's own identity-backfill commit looping back. It
|
||||
// is still scanned and enqueued normally — the ledger, not this hint,
|
||||
// decides whether the redelivery is a complete/no-op — but logging it makes
|
||||
// a self-loop visible to an operator rather than looking like fresh work.
|
||||
b.logInfo("gito self-authored backfill revision observed",
|
||||
"repo_id", ev.RepoID, "branch", ev.Branch, "after", ev.After)
|
||||
}
|
||||
|
||||
for _, doc := range out.Docs {
|
||||
if err := b.enqueueDoc(ctx, out.Result, doc); err != nil {
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import (
|
|||
|
||||
"github.com/nomadcode/nomadcode-core/internal/gitoevents"
|
||||
"github.com/nomadcode/nomadcode-core/internal/roadmapsync"
|
||||
"github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline"
|
||||
)
|
||||
|
||||
// ErrNotReady is returned by the scanner when the local develop checkout's
|
||||
|
|
@ -77,10 +78,18 @@ type ArchivedMilestoneCandidate struct {
|
|||
// ScanResult the develop match gate consumes, the per-path active Milestone
|
||||
// Markdown the bridge projects, and archive Milestone candidates kept only for
|
||||
// future slug lookup/history diagnostics.
|
||||
//
|
||||
// SelfAuthored is a hint only: it is true when the scanned revision's tip commit
|
||||
// carries the Core identity-backfill marker, i.e. the wakeup was NomadCode's own
|
||||
// backfill commit looping back through Gito. It never gates what gets scanned or
|
||||
// enqueued — the full active Milestone index and the identity/step ledger remain
|
||||
// the source of truth — so a self-loop still converges through the ledger's
|
||||
// complete/no-op path even when this hint is absent or wrong.
|
||||
type ScanOutput struct {
|
||||
Result roadmapsync.ScanResult
|
||||
Docs []ScannedMilestoneDoc
|
||||
ArchiveCandidates []ArchivedMilestoneCandidate
|
||||
SelfAuthored bool
|
||||
}
|
||||
|
||||
// BranchRevisionScanner re-verifies a Gito branch event against the local
|
||||
|
|
@ -198,7 +207,15 @@ func (s *BranchRevisionScanner) Scan(ctx context.Context, ev gitoevents.BranchUp
|
|||
})
|
||||
}
|
||||
|
||||
return ScanOutput{Result: result, Docs: docs, ArchiveCandidates: archiveCandidates}, nil
|
||||
// Read the tip commit message once for the self-authored hint. This is
|
||||
// best-effort diagnostics: a failure here must not fail an otherwise valid
|
||||
// scan, so a lookup error leaves the hint false rather than aborting.
|
||||
selfAuthored := false
|
||||
if msg, err := s.runner.Run(ctx, dir, "git", "show", "-s", "--format=%B", remoteRev); err == nil {
|
||||
selfAuthored = strings.Contains(msg, roadmapsyncpipeline.CommitMarker)
|
||||
}
|
||||
|
||||
return ScanOutput{Result: result, Docs: docs, ArchiveCandidates: archiveCandidates, SelfAuthored: selfAuthored}, nil
|
||||
}
|
||||
|
||||
func remoteRef(remote, branch string) string {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import (
|
|||
"github.com/nomadcode/nomadcode-core/internal/authoring"
|
||||
"github.com/nomadcode/nomadcode-core/internal/gitoevents"
|
||||
"github.com/nomadcode/nomadcode-core/internal/roadmapsync"
|
||||
"github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline"
|
||||
)
|
||||
|
||||
const milestonePath = "agent-roadmap/phase/p1/milestones/m1.md"
|
||||
|
|
@ -90,6 +91,7 @@ func TestScanRunsFetchRevParseActiveIndexShowInOrder(t *testing.T) {
|
|||
"git rev-parse refs/remotes/origin/develop",
|
||||
"git ls-tree -r --name-only bbbb -- agent-roadmap/phase agent-roadmap/archive",
|
||||
"git show bbbb:" + milestonePath,
|
||||
"git show -s --format=%B bbbb",
|
||||
}
|
||||
if len(runner.calls) != len(wantOrder) {
|
||||
t.Fatalf("call count: got %d (%v), want %d", len(runner.calls), runner.calls, len(wantOrder))
|
||||
|
|
@ -314,6 +316,53 @@ func TestScanChangedFilesHintDoesNotGateActiveIndexScan(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestScanFlagsSelfAuthoredBackfillCommitAsHintOnly verifies that a tip commit
|
||||
// carrying the Core identity-backfill marker sets ScanOutput.SelfAuthored, but
|
||||
// the hint does not change what gets scanned: the full active Milestone index at
|
||||
// the verified revision is still the source of truth (S05 — the self-loop
|
||||
// wakeup is recognized without bypassing the active scan).
|
||||
func TestScanFlagsSelfAuthoredBackfillCommitAsHintOnly(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"] = milestonePath
|
||||
runner.responses["git show bbbb:"+milestonePath] = milestoneMarkdownWithIdentity
|
||||
runner.responses["git show -s --format=%B bbbb"] =
|
||||
"chore(roadmap): backfill provider identity for m1\n\n" + roadmapsyncpipeline.CommitMarker + "\n"
|
||||
|
||||
out, err := newTestScanner(t, runner).Scan(context.Background(), newScanEvent())
|
||||
if err != nil {
|
||||
t.Fatalf("Scan: %v", err)
|
||||
}
|
||||
if !out.SelfAuthored {
|
||||
t.Fatal("expected SelfAuthored hint for a Core backfill tip commit")
|
||||
}
|
||||
// The hint must not suppress the active-index scan.
|
||||
if len(out.Result.ScannedMilestones) != 1 || out.Result.ScannedMilestones[0].Path != milestonePath {
|
||||
t.Fatalf("self-authored hint must not bypass active scan, got %+v", out.Result.ScannedMilestones)
|
||||
}
|
||||
if len(out.Docs) != 1 {
|
||||
t.Fatalf("docs: got %d, want 1", len(out.Docs))
|
||||
}
|
||||
}
|
||||
|
||||
// TestScanDoesNotFlagSelfAuthoredForOrdinaryCommit verifies an ordinary tip
|
||||
// commit (no Core marker) leaves the hint false.
|
||||
func TestScanDoesNotFlagSelfAuthoredForOrdinaryCommit(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"] = milestonePath
|
||||
runner.responses["git show bbbb:"+milestonePath] = milestoneMarkdownWithIdentity
|
||||
runner.responses["git show -s --format=%B bbbb"] = "feat: an ordinary user push\n"
|
||||
|
||||
out, err := newTestScanner(t, runner).Scan(context.Background(), newScanEvent())
|
||||
if err != nil {
|
||||
t.Fatalf("Scan: %v", err)
|
||||
}
|
||||
if out.SelfAuthored {
|
||||
t.Fatal("expected SelfAuthored to stay false for an ordinary commit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanReturnsErrorOnDuplicateActiveSlugs(t *testing.T) {
|
||||
runner := newFakeRunner()
|
||||
runner.responses["git rev-parse refs/remotes/origin/develop"] = "bbbb"
|
||||
|
|
|
|||
|
|
@ -13,10 +13,14 @@ import (
|
|||
"github.com/nomadcode/nomadcode-core/internal/roadmapsync"
|
||||
)
|
||||
|
||||
// commitMarker tags a backfill commit as Core-authored so a later self-loop
|
||||
// check can recognize its own commit and skip re-processing it as a fresh
|
||||
// authoring trigger.
|
||||
const commitMarker = "NomadCode-Identity-Backfill: true"
|
||||
// CommitMarker tags a backfill commit as Core-authored so a later self-loop
|
||||
// check can recognize its own commit and treat the redelivery as a hint rather
|
||||
// than a fresh authoring trigger. It is the single source of truth for the
|
||||
// marker string; the gitosync scanner reads it back off the tip commit to set
|
||||
// its self-authored hint. The marker is only a hint: the durable idempotency
|
||||
// guarantee is the identity/step ledger, so a lost or absent marker never
|
||||
// causes a duplicate provider mutation.
|
||||
const CommitMarker = "NomadCode-Identity-Backfill: true"
|
||||
|
||||
// ErrIdentityBackfillMismatch is returned when the target Milestone file
|
||||
// already carries a provider identity block that disagrees with the identity
|
||||
|
|
@ -152,7 +156,7 @@ func (b *GitBackfiller) BackfillIdentity(ctx context.Context, in BackfillInput)
|
|||
return b.cleanup(ctx, remoteRev, err)
|
||||
}
|
||||
|
||||
commitMsg := fmt.Sprintf("chore(roadmap): backfill provider identity for %s\n\n%s", in.Identity.MilestoneID(), commitMarker)
|
||||
commitMsg := fmt.Sprintf("chore(roadmap): backfill provider identity for %s\n\n%s", in.Identity.MilestoneID(), CommitMarker)
|
||||
if err := b.run(ctx, "commit", "-m", commitMsg); err != nil {
|
||||
return b.cleanup(ctx, remoteRev, err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -142,7 +142,7 @@ func TestGitBackfillerCommitsAndPushesIdentityOnly(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("git log origin/develop failed: %v: %s", err, string(out))
|
||||
}
|
||||
if !strings.Contains(string(out), commitMarker) {
|
||||
if !strings.Contains(string(out), CommitMarker) {
|
||||
t.Errorf("expected pushed commit to carry Core marker, got: %s", string(out))
|
||||
}
|
||||
|
||||
|
|
@ -317,7 +317,7 @@ func TestGitBackfillerRetriesAfterPushFailure(t *testing.T) {
|
|||
if _, perr := roadmapsync.ParseMilestoneIdentity(path, pushed); perr != nil {
|
||||
t.Fatalf("expected origin/develop to carry the identity after retry: %v", perr)
|
||||
}
|
||||
if out := gitOut(t, workDir, "log", "origin/develop", "-1", "--format=%b"); !strings.Contains(out, commitMarker) {
|
||||
if out := gitOut(t, workDir, "log", "origin/develop", "-1", "--format=%b"); !strings.Contains(out, CommitMarker) {
|
||||
t.Errorf("expected pushed commit to carry Core marker, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -291,6 +291,76 @@ func TestSyncCreationRetrySkipsCompletedSteps(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestSyncCreationExistingIdentityUpdatePartialFailureThenResumes covers S06:
|
||||
// a Milestone whose provider identity already exists is an update candidate
|
||||
// (Plane title/body projection), not a create candidate. When the body/title
|
||||
// update fails mid-cycle, the already-completed comment step is not repeated and
|
||||
// a retry resumes from the completed-step ledger at the body update, then moves
|
||||
// to Todo — no duplicate comment, no re-create.
|
||||
func TestSyncCreationExistingIdentityUpdatePartialFailureThenResumes(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),
|
||||
}
|
||||
// Provider identity already backfilled and the original body already
|
||||
// preserved; only the title/body update and the Todo move remain.
|
||||
store.completed[storage.StepDevelopMatched] = true
|
||||
store.completed[storage.StepIdentityBackfilled] = true
|
||||
store.completed[storage.StepOriginalCommentPreserved] = true
|
||||
|
||||
// First attempt: the title/body update (ProjectBody) fails.
|
||||
bodyErr := errors.New("plane title/body update failed")
|
||||
provider := &fakeProvider{failOn: "body", failErr: bodyErr}
|
||||
svc := NewService(store, provider, "")
|
||||
|
||||
if _, err := svc.SyncCreation(context.Background(), readyInput()); !errors.Is(err, bodyErr) {
|
||||
t.Fatalf("expected body update error, got %v", err)
|
||||
}
|
||||
// The completed comment step is not repeated; body ran but failed; status
|
||||
// must not run; the body step is not marked because its provider call failed.
|
||||
if !equalStrings(provider.calls, []string{"body"}) {
|
||||
t.Fatalf("first attempt provider calls = %v, want [body]", provider.calls)
|
||||
}
|
||||
if len(store.marked) != 0 {
|
||||
t.Fatalf("no ledger mark expected for a failed body update, got %v", store.marked)
|
||||
}
|
||||
|
||||
// Retry against the same durable store resumes from the ledger at the body
|
||||
// update and finishes the Todo move without repeating the comment.
|
||||
retryProvider := &fakeProvider{}
|
||||
retrySvc := NewService(store, retryProvider, "")
|
||||
res, err := retrySvc.SyncCreation(context.Background(), readyInput())
|
||||
if err != nil {
|
||||
t.Fatalf("retry SyncCreation returned error: %v", err)
|
||||
}
|
||||
if res.Action != SyncActionProjected {
|
||||
t.Fatalf("expected projected on retry, got %s (%s)", res.Action, res.Reason)
|
||||
}
|
||||
if !equalStrings(retryProvider.calls, []string{"body", "status"}) {
|
||||
t.Fatalf("retry provider calls = %v, want [body status] (comment not repeated)", retryProvider.calls)
|
||||
}
|
||||
wantLedger := []storage.RoadmapSyncStep{
|
||||
storage.StepPlaneBodyUpdated,
|
||||
storage.StepPlaneTodoMoved,
|
||||
}
|
||||
if !equalSteps(store.marked, wantLedger) {
|
||||
t.Fatalf("retry ledger marks = %v, want %v", store.marked, wantLedger)
|
||||
}
|
||||
if res.NextStep != roadmapsync.StepPlaneTodoMoved {
|
||||
t.Fatalf("expected last step plane_todo_moved, got %s", res.NextStep)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncCreationStopsBeforeLaterStepOnProviderError(t *testing.T) {
|
||||
store := newFakeStore()
|
||||
bodyErr := errors.New("plane body projection failed")
|
||||
|
|
@ -417,6 +487,96 @@ func TestSyncCreationWorkItemConflictViaMismatch(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// completedIdentityStore seeds a store whose identity row matches readyInput()
|
||||
// exactly and whose ledger already records every creation-cycle step, i.e. a
|
||||
// milestone that is fully synced. It is the persisted state a self-loop
|
||||
// redelivery lands on.
|
||||
func completedIdentityStore() *fakeStore {
|
||||
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),
|
||||
}
|
||||
store.completed[storage.StepDevelopMatched] = true
|
||||
store.completed[storage.StepIdentityBackfilled] = true
|
||||
store.completed[storage.StepOriginalCommentPreserved] = true
|
||||
store.completed[storage.StepPlaneBodyUpdated] = true
|
||||
store.completed[storage.StepPlaneTodoMoved] = true
|
||||
return store
|
||||
}
|
||||
|
||||
// TestSyncCreationSelfLoopRedeliveryCompletesWithoutProviderMutation covers S05:
|
||||
// a Core-authored identity-backfill commit loops back as a fresh branch.updated
|
||||
// webhook whose scanned Milestone now carries the provider identity block. With
|
||||
// the ledger already complete and the markdown identity matching the persisted
|
||||
// row, the cycle must converge to complete/no-op — no create, comment, body, or
|
||||
// status mutation, and no redundant upsert.
|
||||
func TestSyncCreationSelfLoopRedeliveryCompletesWithoutProviderMutation(t *testing.T) {
|
||||
store := completedIdentityStore()
|
||||
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 != SyncActionComplete {
|
||||
t.Fatalf("expected complete on self-loop redelivery, got %s (%s)", res.Action, res.Reason)
|
||||
}
|
||||
if len(provider.calls) != 0 {
|
||||
t.Fatalf("expected no provider mutation on self-loop redelivery, got %v", provider.calls)
|
||||
}
|
||||
if store.upserted {
|
||||
t.Fatal("expected no upsert when the redelivered identity already matches")
|
||||
}
|
||||
if len(store.marked) != 0 {
|
||||
t.Fatalf("expected no new ledger marks on a complete cycle, got %v", store.marked)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSyncCreationSelfLoopRestartEquivalentUsesDurableLedger covers the
|
||||
// restart-equivalent half of S05: durability comes from the persisted
|
||||
// identity/step ledger, not from any in-process dedup memory. A first Service
|
||||
// runs the cycle to completion; a second, freshly constructed Service (as after
|
||||
// a Core restart) handling the same redelivered input against the same store
|
||||
// must still return complete/no-op with zero provider mutation.
|
||||
func TestSyncCreationSelfLoopRestartEquivalentUsesDurableLedger(t *testing.T) {
|
||||
store := newFakeStore()
|
||||
|
||||
// First process (pre-restart) runs the full cycle.
|
||||
first := NewService(store, &fakeProvider{}, "")
|
||||
firstRes, err := first.SyncCreation(context.Background(), readyInput())
|
||||
if err != nil {
|
||||
t.Fatalf("first SyncCreation returned error: %v", err)
|
||||
}
|
||||
if firstRes.Action != SyncActionProjected {
|
||||
t.Fatalf("expected first cycle to project, got %s (%s)", firstRes.Action, firstRes.Reason)
|
||||
}
|
||||
|
||||
// Second process (post-restart): brand new Service and provider, same store.
|
||||
restartProvider := &fakeProvider{}
|
||||
second := NewService(store, restartProvider, "")
|
||||
secondRes, err := second.SyncCreation(context.Background(), readyInput())
|
||||
if err != nil {
|
||||
t.Fatalf("post-restart SyncCreation returned error: %v", err)
|
||||
}
|
||||
if secondRes.Action != SyncActionComplete {
|
||||
t.Fatalf("expected complete after restart, got %s (%s)", secondRes.Action, secondRes.Reason)
|
||||
}
|
||||
if len(restartProvider.calls) != 0 {
|
||||
t.Fatalf("expected no provider mutation after restart, got %v", restartProvider.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func equalStrings(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
|
|
|
|||
|
|
@ -221,6 +221,42 @@ func TestRoadmapCreationSyncWorkerDoesNotFinalizeNotReady(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestRoadmapCreationSyncWorkerDoesNotRetryTerminalOutcomes covers S07's
|
||||
// operator-visible-but-safe requirement at the job boundary: conflict,
|
||||
// not-ready, and skip_self_mutation are terminal decisions the orchestrator
|
||||
// already made without any provider mutation, so the worker returns nil (River
|
||||
// does not retry — no retry storm) and does not finalize the external task.
|
||||
func TestRoadmapCreationSyncWorkerDoesNotRetryTerminalOutcomes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
action roadmapsyncpipeline.SyncAction
|
||||
}{
|
||||
{"conflict", roadmapsyncpipeline.SyncActionConflict},
|
||||
{"not_ready", roadmapsyncpipeline.SyncActionNotReady},
|
||||
{"skip_self_mutation", roadmapsyncpipeline.SyncActionSkipSelfMutation},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
runner := &fakeCreationSyncRunner{
|
||||
result: roadmapsyncpipeline.SyncCreationResult{Action: tc.action, Reason: string(tc.action)},
|
||||
}
|
||||
finalizer := &fakeTaskFinalizer{}
|
||||
worker := &RoadmapCreationSyncWorker{Sync: runner, TaskFinalizer: finalizer}
|
||||
|
||||
err := worker.Work(context.Background(), &river.Job[RoadmapCreationSyncJobArgs]{Args: sampleCreationSyncArgs()})
|
||||
if err != nil {
|
||||
t.Fatalf("Work returned error (would trigger River retry storm): %v", err)
|
||||
}
|
||||
if !runner.called {
|
||||
t.Fatal("expected orchestrator to be called")
|
||||
}
|
||||
if finalizer.called {
|
||||
t.Fatalf("expected finalizer NOT called on %s outcome", tc.action)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoadmapCreationSyncWorkerDoesNotUpdateSlotOnDuplicateComplete(t *testing.T) {
|
||||
runner := &fakeCreationSyncRunner{
|
||||
result: roadmapsyncpipeline.SyncCreationResult{Action: roadmapsyncpipeline.SyncActionComplete, Reason: "already complete"},
|
||||
|
|
|
|||
Loading…
Reference in a new issue