diff --git a/agent-roadmap/phase/agent-ops-mcp-control-plane/PHASE.md b/agent-roadmap/phase/agent-ops-mcp-control-plane/PHASE.md index 6b38f64..881151a 100644 --- a/agent-roadmap/phase/agent-ops-mcp-control-plane/PHASE.md +++ b/agent-roadmap/phase/agent-ops-mcp-control-plane/PHASE.md @@ -20,7 +20,11 @@ - [진행중] Milestone Work Item Creation Sync - 경로: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md` - - 요약: Plane-origin 방식으로 Milestone을 생성하고 Plane Todo 티켓까지 동기화한 뒤 멈추는 진행중 마일스톤이다. Workspace agent 실행은 IOP CLI 1차 통로를 선행으로 두며, Agent chat/IDE-origin 생성 projection은 이번 범위에서 제외한다. + - 요약: Plane-origin 방식으로 Milestone을 생성하고 Plane Todo 티켓까지 동기화한 뒤 멈추는 마일스톤이다. 코드레벨 검토에서 develop scan 결과를 identity/step ledger와 Plane `Todo` projection에 연결하는 runtime sync orchestrator 누락이 확인되어 `creation-sync-orchestrator` 보완 계획을 진행한다. + +- [계획] Gito Branch Event Creation Sync Bridge + - 경로: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/gito-branch-event-creation-sync-bridge.md` + - 요약: Gito가 proto-socket `branch.updated` wakeup을 제공하면 NomadCode core가 이를 구독하고 target branch를 재검증/스캔한 뒤 creation sync job으로 enqueue하는 bridge를 구현한다. - [계획] Roadmap Driven Agent-Ops Automation - 경로: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/roadmap-driven-agent-ops-automation.md` diff --git a/agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/gito-branch-event-creation-sync-bridge.md b/agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/gito-branch-event-creation-sync-bridge.md new file mode 100644 index 0000000..7b755e5 --- /dev/null +++ b/agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/gito-branch-event-creation-sync-bridge.md @@ -0,0 +1,69 @@ +# Milestone: Gito Branch Event Creation Sync Bridge + +## 위치 + +- Roadmap: `agent-roadmap/ROADMAP.md` +- Phase: `agent-roadmap/phase/agent-ops-mcp-control-plane/PHASE.md` + +## 목표 + +Gito가 제공하는 proto-socket `branch.updated` wakeup을 NomadCode core가 소비하고, `develop` target branch를 재검증/스캔한 뒤 Plane-origin creation sync job으로 enqueue한다. 이 Milestone이 끝나면 Forgejo/Gito branch event가 단순 UI wakeup에 머물지 않고 NomadCode backend sync loop의 결정적 입력으로 연결된다. + +## 상태 + +[계획] + +## 구현 잠금 + +- 상태: 해제 +- 결정 필요: 없음 + +## 범위 + +- Gito 제공 계약 `gito.forgejo-branch-events.v1`의 `event.subscribe` / `branch.updated` 소비 +- NomadCode core runtime에서 Gito `/proto-socket` endpoint에 연결하는 client/runner +- `repo_id=nomadcode`, `branch=develop` 구독과 reconnect/error handling의 최소 기준 +- `branch.updated` payload의 `changed_files`를 wakeup signal로만 사용하고 target branch revision을 별도 검증/스캔하는 흐름 +- `agent-roadmap/phase/*/milestones/*.md` 변경과 provider identity를 확인해 `roadmapsync.ScanResult`와 `RoadmapCreationSyncJobArgs`를 구성하는 흐름 +- 기존 `scheduler.EnqueueRoadmapCreationSync` seam으로 creation sync worker를 깨우는 흐름 +- 중복 event, 동일 revision, not-ready scan의 idempotency 기준 + +## 기능 + +### Epic: [gito-wakeup] Gito branch event wakeup bridge + +Gito가 provider webhook과 branch watch를 정규화해 `branch.updated`를 발행하면, NomadCode core가 이를 backend sync wakeup으로 받아 Plane creation sync orchestrator에 전달한다. + +- [ ] [contract-consume] Gito 제공 계약 `gito.forgejo-branch-events.v1`을 NomadCode core 소비 계약으로 반영한다. 검증: `event.subscribe` 요청과 `branch.updated` payload 필드가 계약 fixture 또는 테스트에 고정된다. +- [ ] [core-client] NomadCode core에 Gito proto-socket event client/runner를 추가한다. 검증: fake proto-socket server 또는 transport로 `event.subscribe`를 보내고 `branch.updated`를 수신한다. +- [ ] [branch-filter] `repo_id`, `branch`, `changed_files` 기준으로 roadmap Milestone 후보 event만 선별한다. 검증: 다른 repo/branch/path event는 creation sync enqueue를 만들지 않는다. +- [ ] [revision-scan] 이벤트 payload를 최종 증거로 단정하지 않고 target branch revision을 fetch/scan해 `roadmapsync.ScanResult`를 만든다. 검증: scanned Milestone identity가 원래 Plane work item identity와 맞을 때만 ready 후보가 된다. +- [ ] [creation-enqueue] scan 결과와 provider identity를 `RoadmapCreationSyncJobArgs`로 변환해 `scheduler.EnqueueRoadmapCreationSync`를 호출한다. 검증: fake scheduler가 정확한 job args를 받으며 duplicate/retry 입력이 중복 side effect를 만들지 않는다. +- [ ] [runtime-wiring] server/scheduler lifecycle에 Gito event consumer를 선택적으로 wiring한다. 검증: Gito endpoint 미설정 시 기존 core 동작은 유지되고, 설정 시 runner가 시작/종료된다. + +## 완료 리뷰 + +- 상태: 없음 +- 요청일: 없음 +- 완료 근거: 없음 +- 리뷰 필요: + - [ ] 사용자가 완료 결과를 확인했다 + - [ ] archive 이동을 승인했다 +- 리뷰 코멘트: 없음 + +## 범위 제외 + +- Gito의 Forgejo webhook 수신/branch watch 제공 구현 +- Gito provider delivery dedupe, branch watch persistence 구현 +- Plane `Todo -> In Progress` 이후 실행 lifecycle +- Milestone Task와 Plane 하위 티켓 동기화 +- Flutter UI에서 branch event를 표시하거나 수동 재시도 버튼을 제공하는 작업 + +## 작업 컨텍스트 + +- 관련 경로: `services/core/internal/protosocket/`, `services/core/internal/roadmapsync/`, `services/core/internal/roadmapsyncpipeline/`, `services/core/internal/scheduler/`, `services/core/cmd/server/main.go`, `packages/contracts/notes/flutter-core-api-candidates.md` +- 외부 계약: Gito `agent-contract/provided/gito-forgejo-branch-events-v1.md` +- 표준선(선택): Gito는 branch event producer이고, NomadCode core는 이 event를 wakeup signal로 소비한 뒤 target branch를 재검증/스캔해 Plane mutation 여부를 결정한다. +- 선행 작업: `Milestone Work Item Creation Sync`의 `creation-sync-orchestrator` +- 후속 작업: `Milestone Execution Lifecycle Sync` +- 확인 필요: 없음 diff --git a/agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md b/agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md index 9eff15e..a9e0d32 100644 --- a/agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md +++ b/agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md @@ -93,28 +93,34 @@ Plane-origin sync가 workspace agent를 실행할 수 있도록 IOP Edge OpenAI- Plane-origin 생성 경로를 `develop` agent-roadmap 기준의 `Todo` 검토 상태까지 수렴시킨다. -- [ ] [plane-trigger] Plane `Backlog + AGENT assignee` 상위 티켓을 Milestone 생성 trigger로 처리한다. 검증: NomadCode가 자신이 만든 후속 변경을 다시 사용자 trigger로 오인하지 않는 actor guard가 설명되어 있다. 계획: `agent-task/m-milestone-work-item-creation-sync/09_creation_trigger_gate/` +- [x] [plane-trigger] Plane `Backlog + AGENT assignee` 상위 티켓을 Milestone 생성 trigger로 처리한다. 검증: NomadCode가 자신이 만든 후속 변경을 다시 사용자 trigger로 오인하지 않는 actor guard가 설명되어 있다. 계획: `agent-task/m-milestone-work-item-creation-sync/09_creation_trigger_gate/` - [x] [plane-agent-authoring] Plane 티켓 제목/본문을 workspace agent authoring run의 입력으로 전달하는 경계를 정의한다. 검증: 정상 happy path에서 필수 agent/model 개입은 1회이며, 결과물은 별도 변환 응답이 아니라 `slots/` workspace 안의 `agent-roadmap` Milestone 파일 작성/갱신과 `develop` commit/push다. 이 단계는 IOP Edge OpenAI-compatible HTTP 통로가 준비된 뒤에만 실행된다. -- [ ] [plane-draft] Plane 티켓 본문을 읽어 workspace agent가 roadmap skill로 Milestone 초안을 생성하거나 갱신하고 `develop`에 반영한다. 검증: 1상위 티켓은 1마일스톤으로 매핑되고 같은 Plane 티켓 재처리 시 중복 Milestone을 만들지 않는다. 이번 범위는 Plane-origin에 한정하되, Milestone 파일 작성과 push는 IDE-origin과 같은 workspace agent 경로를 쓴다. 계획: `agent-task/m-milestone-work-item-creation-sync/10+09_plane_draft_authoring/` +- [x] [plane-draft] Plane 티켓 본문을 읽어 workspace agent가 roadmap skill로 Milestone 초안을 생성하거나 갱신하고 `develop`에 반영한다. 검증: 1상위 티켓은 1마일스톤으로 매핑되고 같은 Plane 티켓 재처리 시 중복 Milestone을 만들지 않는다. 이번 범위는 Plane-origin에 한정하되, Milestone 파일 작성과 push는 IDE-origin과 같은 workspace agent 경로를 쓴다. 계획: `agent-task/m-milestone-work-item-creation-sync/10+09_plane_draft_authoring/` - [x] [develop-truth] `develop` branch의 agent-roadmap을 sync source of truth로 정의한다. 검증: feature branch나 로컬 초안은 Plane `Todo` projection 대상이 아니며, Plane `Todo` 티켓은 `develop`에 Milestone 변경이 commit/push된 뒤에만 생성/갱신된다. - [x] [pushed-milestone-match] Sync layer가 pushed commit과 Plane 티켓을 매칭하는 규칙을 정의한다. 검증: pushed commit 또는 roadmap scan에서 실제 `agent-roadmap/phase/*/milestones/*.md` 변경이 있고, 해당 Milestone에 provider와 Plane work item id가 확인될 때만 Plane 갱신 단계로 넘어간다. - [x] [plane-preserve] 원본 Plane 본문을 `사용자 요청:` 댓글로 보존한 뒤, `develop`에 반영된 Milestone 내용을 기준으로 Plane 본문을 치환한다. 검증: 댓글 보존 실패 시 본문 치환으로 넘어가지 않고, develop 반영 전에는 Todo 이동을 하지 않는다. - [x] [plane-todo] sync layer가 `develop` push 완료 또는 roadmap scan으로 감지한 Milestone을 기준으로 Plane 제목을 `[milestone-id] 제목` 형식으로 바꾸고 티켓을 `Todo`로 이동한다. 검증: milestone id는 `develop`의 Milestone 파일 slug를 기본값으로 쓰고, 실제 identity는 제목이 아니라 identity map의 Milestone path와 provider work item id로 추적된다. - [x] [creation-idmap] Milestone path, Milestone id, provider, work item id, provider revision, roadmap revision을 identity map으로 연결한다. 검증: 같은 Plane 티켓 또는 같은 Milestone path가 재처리되어도 같은 Milestone/work item pair를 찾을 수 있다. - [x] [creation-retry] 생성 동기화의 idempotency와 부분 실패 재시도 정책을 정한다. 검증: develop 반영, 댓글 보존, 본문 치환, 제목 변경, Todo 이동 중 일부만 성공해도 재시도 시 중복 생성 없이 남은 단계만 적용된다. +- [ ] [creation-sync-orchestrator] develop Milestone match, identity/step ledger, Plane 원문 보존/본문·제목 갱신/`Todo` 이동을 하나의 runtime sync orchestrator로 연결한다. 검증: provider fake와 store fake 기반 테스트에서 not-ready는 provider 호출 없음, ready는 ledger 단계별 재시도와 Plane projection 순서를 보장한다. 계획: `agent-task/m-milestone-work-item-creation-sync/11_creation_sync_orchestrator/` ## 완료 리뷰 -- 상태: 없음 -- 요청일: 없음 -- 완료 근거: 없음 +- 상태: 보완 필요 +- 요청일: 2026-06-14 +- 완료 근거: + - 코드레벨 검토 결과, 현재 Core에는 `roadmapsync` helper와 storage wrapper가 있으나 develop scan 결과를 identity/step ledger와 Plane `Todo` projection에 연결하는 runtime orchestrator/caller가 없다. + - `cd services/core && go test ./...`, `cd services/core && go vet ./...`, `git diff --check`는 PASS였지만 helper 미연결을 검출하지 못한다. + - 보완 계획은 `agent-task/m-milestone-work-item-creation-sync/11_creation_sync_orchestrator/`에 작성했다. - 리뷰 필요: - - [ ] 사용자가 완료 결과를 확인했다 + - [ ] `creation-sync-orchestrator` 계획이 PASS되고 runtime caller가 검증되었다 + - [ ] 이후 사용자가 완료 결과를 확인했다 - [ ] archive 이동을 승인했다 -- 리뷰 코멘트: 없음 +- 리뷰 코멘트: 코드레벨 검토 결과 runtime sync orchestrator 누락이 확인되어 완료/아카이브를 보류한다. ## 범위 제외 +- Gito `/proto-socket`의 `branch.updated`를 NomadCode core가 구독하고 target branch를 재검증/스캔해 creation sync job을 enqueue하는 backend bridge 구현. 이 범위는 후속 `Gito Branch Event Creation Sync Bridge` Milestone으로 분리한다. - Plane `Todo -> In Progress` 실행 시작 - Milestone Task를 Plane 하위 티켓으로 생성하거나 동기화하는 구현 - `agent-task/m-` plan/code-review 실행 lifecycle @@ -128,6 +134,7 @@ Plane-origin 생성 경로를 `develop` agent-roadmap 기준의 `Todo` 검토 - 관련 경로: `services/core/internal/workitem/`, `services/core/internal/scheduler/`, `services/core/internal/http/`, `services/core/internal/db/`, `services/core/migrations/`, `services/core/queries/`, `packages/contracts/`, `agent-roadmap/` - 선행 작업: Roadmap Driven Agent-Ops Automation의 sync domain 방향 정리 - 후속 작업: `Milestone Execution Lifecycle Sync` +- 후속 분리(2026-06-14): Gito는 Forgejo/webhook 기반 `branch.updated` event producer이고, NomadCode core는 이를 proto-socket으로 소비해 target branch를 재검증/스캔한 뒤 `EnqueueRoadmapCreationSync`를 호출해야 한다. 이 backend consumer/bridge는 `Gito Branch Event Creation Sync Bridge` Milestone으로 분리했다. 현재 `creation-sync-orchestrator` task는 matched scan/job 입력 이후의 orchestrator, worker, enqueue seam을 닫는 범위로 본다. - 현재 지점: 2026-06-08부터 이 마일스톤을 활성 진행 대상으로 둔다. 우선 Plane-origin Milestone 생성 sync를 `Todo` 검토 상태까지 수렴시키는 범위에서 확인한다. - Source of truth: `develop` branch의 `agent-roadmap`을 Milestone sync의 source of truth로 둔다. Plane은 이 마일스톤의 intake/projection/review UI이며, agent chat/IDE-origin 생성은 이번 범위에서 제외한다. - Develop 반영 기준: 이 마일스톤에서 `develop`에 반영되었다는 말은 PR 요청이나 PR 생성이 아니라 `develop` branch에 Milestone 변경 commit/push가 완료된 상태를 뜻한다. @@ -151,9 +158,11 @@ Plane-origin 생성 경로를 `develop` agent-roadmap 기준의 `Todo` 검토 - Workspace provision pipeline 동기화(2026-06-14): `agent-task/archive/2026/06/m-milestone-work-item-creation-sync/07+06_workspace_provision_pipeline/complete.log`의 `Roadmap Completion` 근거로 `[workspace-provision]`을 완료 처리했다. 검증 근거는 workspace provision hook의 slot 예약/provider fetch/task creation 이전 실행, HTTP binder의 default `slots/000` 등록, `ErrWorkspaceProvisionNotReady` 409 매핑, `cd services/core && go test -count=1 ./internal/projectsync ./internal/workitempipeline ./internal/http`, `cd services/core && go test ./...`, `cd services/core && go vet ./...`, `git diff --check` PASS다. - Agent bridge 소형 항목 동기화(2026-06-14): 현재 Core 코드의 `authoring.BuildAuthoringGenerateInput`, OpenAI-compatible `/v1/responses` adapter, scheduler authoring route/state metadata, `roadmapsync` identity/develop match 기준을 근거로 `[iop-http-channel]`, `[authoring-state]`, `[provider-identity]`, `[agent-bridge-lock]`을 완료 처리했다. 검증: `cd services/core && go test -count=1 ./internal/authoring ./internal/scheduler ./internal/roadmapsync` PASS. `[agent-run-result]`는 model bridge 응답 성공과 `develop` push 검증 사이의 성공 판정 경계를 보강해야 하므로 `agent-task/m-milestone-work-item-creation-sync/08_agent_run_result_gate/` 계획으로 분리했다. - Agent run result gate 동기화(2026-06-14): `agent-task/archive/2026/06/m-milestone-work-item-creation-sync/08_agent_run_result_gate/complete.log`의 `Roadmap Completion` 근거로 `[agent-run-result]`를 완료 처리했다. 검증 근거는 `DecideAuthoringResult` helper, authoring failure category metadata, scheduler authoring state 보정, `cd services/core && go test ./...`, `cd services/core && go vet ./...`, `git diff --check` PASS다. -- Creation sync 소형 항목 동기화(2026-06-14): 현재 Core 코드의 `workitem.BuildCreateTaskInput`, `authoring.BuildAuthoringGenerateInput`, scheduler authoring route, `roadmapsync.MatchDevelopMilestone`, `ParseMilestoneIdentity`, `ProjectPlaneTodo`, `FormatPlaneProjection`, roadmap sync identity/step store, `ReconcileCreationCycle` 근거로 `[plane-agent-authoring]`, `[develop-truth]`, `[pushed-milestone-match]`, `[plane-preserve]`, `[plane-todo]`, `[creation-idmap]`, `[creation-retry]`를 완료 처리했다. 검증: `cd services/core && go test -count=1 ./internal/workitem ./internal/workitempipeline ./internal/authoring ./internal/roadmapsync ./internal/adapters/plane ./internal/storage ./internal/http ./internal/scheduler` PASS, `git diff --check` PASS. `[plane-trigger]`는 Backlog + AGENT assignee eligibility gate가 아직 없고, `[plane-draft]`는 Milestone 초안 작성/갱신의 provider identity와 중복 방지 authoring contract 보강이 필요해 각각 plan으로 분리했다. -- 중지 지점: project sync 설정, workspace topology, IOP Edge HTTP authoring run 경계, authoring run 성공/실패 판정, pushed Milestone identity match, Plane 원문 댓글 보존/본문·제목 갱신/`Todo` 이동 projection, identity map 기반 idempotency/retry는 완료 근거가 있다. -- 루프 목표: Plane `Backlog + AGENT assignee` -> `slots/` workspace 예약/준비 -> IOP Edge HTTP로 workspace agent authoring run 실행 -> agent가 Milestone 파일 작성/갱신 후 `develop` push -> sync layer가 `branches/develop` 또는 remote `develop`에서 push 완료/roadmap scan을 감지 -> Plane 원문 댓글 보존/본문 치환/제목 변경/`Todo` 이동 -> 사용자가 확인 후 `In Progress`로 이동하는 지점까지다. 최상위 `plane-nomad-iop-cycle` 실행 단위는 완료 근거가 있으며, `In Progress` 이후 실행은 후속 잠금 범위다. +- Creation sync 소형 항목 동기화(2026-06-14): 현재 Core 코드의 `workitem.BuildCreateTaskInput`, `authoring.BuildAuthoringGenerateInput`, scheduler authoring route, `roadmapsync.MatchDevelopMilestone`, `ParseMilestoneIdentity`, `ProjectPlaneTodo`, `FormatPlaneProjection`, roadmap sync identity/step store, `ReconcileCreationCycle` 근거로 `[plane-agent-authoring]`, `[develop-truth]`, `[pushed-milestone-match]`, `[plane-preserve]`, `[plane-todo]`, `[creation-idmap]`, `[creation-retry]`를 완료 처리했다. 검증: `cd services/core && go test -count=1 ./internal/workitem ./internal/workitempipeline ./internal/authoring ./internal/roadmapsync ./internal/adapters/plane ./internal/storage ./internal/http ./internal/scheduler` PASS, `git diff --check` PASS. 당시 남은 `[plane-trigger]`, `[plane-draft]`는 후속 완료 로그 기준으로 아래 현황 동기화에서 닫았다. +- Creation trigger/draft 현황 동기화(2026-06-14): `agent-task/archive/2026/06/m-milestone-work-item-creation-sync/09_creation_trigger_gate/complete.log`의 `Roadmap Completion` 근거로 `[plane-trigger]`를 완료 처리했다. 검증 근거는 ignored trigger를 workspace provision/slot reservation 전에 판정하는 gate, legacy Plane route trigger 전달 테스트, `cd services/core && go test -count=1 ./internal/workitempipeline ./internal/http`, `cd services/core && go test ./...`, `git diff --check` PASS다. `agent-task/archive/2026/06/m-milestone-work-item-creation-sync/10+09_plane_draft_authoring/complete.log`의 `Roadmap Completion` 근거로 `[plane-draft]`를 완료 처리했다. 검증 근거는 full provider identity context 보존, idempotent Milestone draft instructions, scheduler authoring route regression, `cd services/core && go test -count=1 ./internal/authoring ./internal/roadmapsync ./internal/adapters/openai ./internal/scheduler`, `cd services/core && go test -count=1 ./...`, `git diff --check` PASS다. +- 완료 보완 리뷰(2026-06-14): 현재 Core 코드에는 `roadmapsync` helper와 저장소는 있으나 이를 호출해 develop scan 결과를 identity/step ledger와 Plane `Todo` projection에 연결하는 runtime sync orchestrator가 없다. 보완 계획: `agent-task/m-milestone-work-item-creation-sync/11_creation_sync_orchestrator/`. +- 중지 지점: project sync 설정, workspace topology, IOP Edge HTTP authoring run 경계, authoring run 성공/실패 판정, pushed Milestone identity match, Plane 원문 댓글 보존/본문·제목 갱신/`Todo` 이동 projection, identity map 기반 idempotency/retry는 helper와 단위 경계 완료 근거가 있다. runtime orchestrator 연결은 `creation-sync-orchestrator` 완료 전까지 미완료다. +- 루프 목표: Plane `Backlog + AGENT assignee` -> `slots/` workspace 예약/준비 -> IOP Edge HTTP로 workspace agent authoring run 실행 -> agent가 Milestone 파일 작성/갱신 후 `develop` push -> sync layer가 `branches/develop` 또는 remote `develop`에서 push 완료/roadmap scan을 감지 -> Plane 원문 댓글 보존/본문 치환/제목 변경/`Todo` 이동 -> 사용자가 확인 후 `In Progress`로 이동하는 지점까지다. 최상위 `plane-nomad-iop-cycle` 실행 단위의 완료 판정은 `creation-sync-orchestrator` 완료 후 재검토하며, `In Progress` 이후 실행은 후속 잠금 범위다. - 후속 잠금: `Todo -> In Progress` 이후 실행 lifecycle은 사용자가 명시적으로 잠금을 해제할 때까지 진행하지 않는다. - Plane work item 경계: 이 마일스톤에서 말하는 Plane Milestone은 Plane native milestone object가 아니라 1상위 티켓 = 1 agent-roadmap Milestone으로 보는 work item mapping이다. - 확인 필요: 없음 diff --git a/agent-task/archive/2026/06/m-milestone-work-item-creation-sync/11_creation_sync_orchestrator/USER_REVIEW.md b/agent-task/archive/2026/06/m-milestone-work-item-creation-sync/11_creation_sync_orchestrator/USER_REVIEW.md new file mode 100644 index 0000000..5aab01e --- /dev/null +++ b/agent-task/archive/2026/06/m-milestone-work-item-creation-sync/11_creation_sync_orchestrator/USER_REVIEW.md @@ -0,0 +1,70 @@ +# User Review Required - m-milestone-work-item-creation-sync/11_creation_sync_orchestrator + +## 요청 일시 + +2026-06-14 + +## 상태 + +RESOLVED + +## 해소 결과 + +- 해소일: 2026-06-14 +- 최종 해소 판정: PASS/RESOLVED +- 사용자 결정: Gito proto-socket `branch.updated` backend bridge는 현재 task의 미완료로 남기지 않고, 별도 Milestone `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/gito-branch-event-creation-sync-bridge.md`로 분리한다. +- 현재 task 완료 범위: matched develop scan/job 입력 이후의 creation sync orchestrator, River worker, server wiring, `EnqueueRoadmapCreationSync` seam까지로 축소해 닫는다. + +## 사유 + +- 유형: implementation-blocked +- 현재 리뷰 회차: 1 +- 최종 판정: FAIL +- 요약: creation sync orchestrator와 River worker/enqueue seam은 구현됐지만, 이를 실제로 호출할 develop scan producer가 repo에 없다. producer를 이번 slice에 포함할지와 트리거 메커니즘을 무엇으로 둘지는 public/internal API 경계와 proto-socket/REST 예외 판단에 걸려 있어 자동 follow-up이 임의 결정하지 않는다. + +## 루프 이력 + +| Plan | Review | Verdict | 메모 | +|------|--------|---------|------| +| `plan_cloud_G07_0.log` | `code_review_cloud_G07_0.log` | FAIL | `EnqueueRoadmapCreationSync` caller가 없어 REVIEW_SYNC-2가 사용자 범위 결정 대기 상태로 남음 | +| `USER_REVIEW.md` | user decision | PASS/RESOLVED | Gito event consumer/producer bridge를 새 Milestone으로 분리하고 현재 task는 orchestrator/worker/enqueue seam 범위로 종료 | + +## 차단 근거 + +- 문제: `services/core/internal/scheduler/river.go:99`에 enqueue seam이 있고 `services/core/internal/scheduler/roadmap_sync_jobs.go:18`은 develop-scan handoff가 호출한다고 설명하지만, 실제 repo에는 그 handoff/producer가 없다. +- 현재 archive plan: `plan_cloud_G07_0.log` +- 현재 archive review: `code_review_cloud_G07_0.log` +- 검증 명령: `rg --sort path "scheduler\\.New\\(|EnqueueRoadmapCreationSync|RoadmapCreationSync|CreationSyncRunner" services/core` +- 실제 출력: `EnqueueRoadmapCreationSync` 호출자는 `services/core/internal/scheduler/river.go`의 method 정의뿐이며, 나머지는 worker/job/test/주입 코드다. 추가 검증 `cd services/core && go test -count=1 ./...`, `cd services/core && go vet ./...`, `git diff --check`는 모두 PASS했다. +- 차단 판단 근거: 계획은 producer가 없으면 dead worker만으로 PASS하지 말고 "runtime scan producer 범위 결정 필요"를 사용자 리뷰 요청으로 남기라고 명시했다. 새 producer를 만들려면 callback route, scheduled scan, 기존 authoring handoff 확장 등 트리거 형태를 결정해야 하며, 이는 contracts/core domain boundary와 직결되는 범위 결정이다. + +## 사용자 결정 필요 + +- [ ] 자동 follow-up plan/review를 계속 진행한다. +- [ ] 계획을 재작성한다. +- [ ] 테스트 환경, secret, 외부 서비스, SDK, 장비 조건을 준비한 뒤 재시도한다. +- [x] 작업 범위를 줄이거나 보류/폐기한다. + +구체적으로 아래 중 하나가 필요하다. + +- producer를 이번 slice에 포함하고, 선택한 트리거 메커니즘에서 `EnqueueRoadmapCreationSync`를 호출하도록 구현한다. +- producer를 이번 slice 범위에서 제외하고, 현재 worker/seam/server 주입과 단위 테스트만으로 이 task를 종료할지 결정한다. +- producer 설계를 별도 plan으로 재작성한다. + +해소: producer 설계는 별도 Milestone으로 분리했고, 현재 task는 producer를 포함하지 않는 worker/seam/server wiring 범위로 종료한다. + +## 재개 조건 + +- producer를 포함한다면 트리거 메커니즘이 정해져 있고, 해당 caller가 `RoadmapCreationSyncJobArgs`를 만들 수 있는 입력 출처가 정해져 있어야 한다. +- producer를 제외한다면 이번 task의 완료 범위를 worker/seam/server wiring으로 축소한다는 사용자 결정이 필요하다. +- 계획 재작성이라면 새 scope가 public HTTP, proto-socket, scheduled scan, 기존 authoring handoff 중 어느 경계를 사용할지 명시돼야 한다. + +## 다음 실행 힌트 + +- 사용자 결정 후 `plan` 스킬로 이 `USER_REVIEW.md`를 `user_review_0.log`로 아카이브하고, 같은 task path에서 새 `PLAN-*-G??.md` / `CODE_REVIEW-*-G??.md`를 작성한다. +- producer 구현을 계속한다면 우선 `services/core/internal/authoring/result.go`, `services/core/internal/workitempipeline/service.go`, `services/core/internal/scheduler/river.go`, `services/core/internal/scheduler/roadmap_sync_jobs.go`의 책임 경계를 비교한다. + +## 종료 규칙 + +- 사용자가 이 stop state를 완료/PASS로 해소하면 `USER_REVIEW.md`를 해소 상태로 갱신하고, `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다. +- 새 구현이 필요하면 `plan` 스킬이 `USER_REVIEW.md`를 `user_review_N.log`로 아카이브한 뒤 새 `PLAN-*-G??.md` / `CODE_REVIEW-*-G??.md`를 작성한다. diff --git a/agent-task/archive/2026/06/m-milestone-work-item-creation-sync/11_creation_sync_orchestrator/code_review_cloud_G07_0.log b/agent-task/archive/2026/06/m-milestone-work-item-creation-sync/11_creation_sync_orchestrator/code_review_cloud_G07_0.log new file mode 100644 index 0000000..c292f90 --- /dev/null +++ b/agent-task/archive/2026/06/m-milestone-work-item-creation-sync/11_creation_sync_orchestrator/code_review_cloud_G07_0.log @@ -0,0 +1,216 @@ + + +# Code Review Reference - REVIEW_SYNC + +> **[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 user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts 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 the needed 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-14 +task=m-milestone-work-item-creation-sync/11_creation_sync_orchestrator, plan=0, tag=REVIEW_SYNC + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md` +- Task ids: + - `creation-sync-orchestrator`: develop Milestone match, identity/step ledger, Plane 원문 보존/본문·제목 갱신/`Todo` 이동 runtime sync orchestrator +- Completion mode: check-on-pass + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. +리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. + +1. 판정을 append한다. +2. `CODE_REVIEW-cloud-G07.md` -> `code_review_cloud_G07_N.log`, `PLAN-cloud-G07.md` -> `plan_cloud_G07_M.log`로 아카이브한다. +3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-milestone-work-item-creation-sync/11_creation_sync_orchestrator/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다. +4. PASS이고 task group이 `m-`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. +5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [REVIEW_SYNC-1] Creation sync orchestrator | [x] | +| [REVIEW_SYNC-2] Runtime wiring | [x] (scan producer 범위는 `사용자 리뷰 요청` 참고) | + +## 구현 체크리스트 + +- [x] [REVIEW_SYNC-1] develop scan candidate를 identity/step ledger와 Plane projection 단계에 연결하는 creation sync orchestrator를 구현한다. +- [x] [REVIEW_SYNC-2] orchestrator가 dead code로 남지 않도록 scheduler/server runtime wiring 또는 기존 develop scan handoff에 연결하고 테스트로 증명한다. +- [x] 중간 및 최종 검증 명령을 fresh 실행하고 실제 stdout/stderr를 `CODE_REVIEW-cloud-G07.md`에 붙여 넣는다. +- [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_G07_N.log`로 아카이브한다. +- [x] active `PLAN-*-G??.md`를 `plan_cloud_G07_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-milestone-work-item-creation-sync/11_creation_sync_orchestrator/`를 `agent-task/archive/YYYY/MM/m-milestone-work-item-creation-sync/11_creation_sync_orchestrator/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [x] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-milestone-work-item-creation-sync/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G07.md`와 `CODE_REVIEW-cloud-G07.md`를 작성하고 `complete.log`를 작성하지 않는다. +- [x] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다. +- [x] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다. + +## 계획 대비 변경 사항 + +- `plane_projection.go`의 step helper 분리 방식: 계획은 "unexported helper로 쪼개거나 동등 단계별 호출을 orchestrator에 구현" 둘 중 하나를 허용했다. orchestrator가 `roadmapsync` 외부 package(`roadmapsyncpipeline`)에 있어 unexported helper를 호출할 수 없으므로, exported 단계별 helper(`PreserveOriginalComment`, `UpdatePlaneBody`, `MovePlaneTodo`)와 입력 검증 helper(`ValidateProjectionInput`)로 분리했다. 기존 `ProjectPlaneTodo`는 이 helper들을 재사용하도록 바꿔 공개 동작과 기존 테스트를 그대로 유지한다. +- `StepDevelopMatched`에는 provider side effect가 없다(매칭 통과 사실만 기록). orchestrator의 `runStep`에서 이 단계는 no-op로 처리하고 ledger mark만 기록한다. 계획의 step 목록(`StepDevelopMatched` 포함)을 그대로 따른 결과다. +- self actor: `cmd/server/main.go`에서 전용 NomadCode Plane 계정 id 설정값이 아직 없어 `selfActor`를 빈 문자열로 주입했다. `ReconcileCreationCycle`은 빈 selfActor일 때 self-mutation guard를 no-op로 두므로 실제 트리거를 잘못 skip하지 않는다. 계정 id 설정은 후속 범위. +- 검증 명령은 계획 그대로 실행했다. 추가로 gofmt가 신규 테스트 파일의 struct 정렬을 한 번 정리했으나(`gofmt -w internal/roadmapsyncpipeline/service_test.go`) 동작 변경은 없고 최종 `go test`/`go vet`/`git diff --check`는 재실행해 PASS를 확인했다. + +## 주요 설계 결정 + +- **새 side-effect package `internal/roadmapsyncpipeline`**: 계획대로 `roadmapsync`는 provider-neutral helper 중심으로 유지하고, identity/step 저장소 + provider facet을 묶어 호출하며 단계별 ledger를 기록하는 orchestration만 새 package에 두었다. core domain rule의 "provider별 세부를 workflow/service에 직접 흘리지 않는다" 경계를 따른다. +- **narrow seam interface**: orchestrator는 `Store`(identity get/upsert, completed steps, mark step)와 `roadmapsync.PlaneTodoProvider`만 의존한다. `*storage.Store`와 plane adapter가 그대로 만족하고, 테스트는 fake로 DB/Plane 없이 단위 검증한다. +- **단계 성공 직후 ledger 기록**: `runRemainingSteps`는 `OrderedSteps` 순서로 nextStep부터 실행하며, 각 provider 호출이 성공한 직후에만 `MarkStepCompleted`를 호출한다. 중간 실패 시 이후 provider 호출과 ledger mark가 모두 일어나지 않아, retry가 남은 단계만 재실행한다(테스트 `...StopsBeforeLaterStepOnProviderError`, `...RetrySkipsCompletedSteps`로 증명). +- **not-ready/conflict/self-mutation/complete는 provider side effect 없이 typed result 반환**: develop match gate와 `ReconcileCreationCycle` 결과를 `SyncAction`으로 매핑하고, resume일 때만 provider를 호출한다. +- **identity 입력 검증 시점**: 어떤 identity write나 provider 호출 전에 `ValidateProjectionInput`으로 projection 입력(ref/original body/todo state/milestone markdown)을 먼저 거른다. +- **scheduler 선택적 worker 등록**: `scheduler.New`에 `CreationSyncRunner`(nil 허용) 인자를 추가하고, non-nil일 때만 `RoadmapCreationSyncWorker`를 등록한다. nil이면 기존 task worker만 등록되어 기본 동작이 유지된다(테스트 `...KeepsTaskWorkerOnlyBehavior`). enqueue seam `EnqueueRoadmapCreationSync`는 `EnqueueTask`와 분리된 내부 seam이며 public HTTP route를 추가하지 않았다. +- **worker terminal outcome 처리**: not-ready/conflict/self-mutation/complete는 orchestrator가 이미 side effect 없이 보장하는 정상 종결이므로 worker는 로그 후 nil을 반환한다. provider/storage 오류만 error로 반환해 River가 재시도한다. +- **새 DB migration 없음**: 기존 `roadmap_sync_identities`/`roadmap_sync_steps`와 storage wrapper만 사용했다. + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._ + +- 상태: 필요 +- 사유 유형: 계획 범위 결정 (runtime develop scan producer) +- 결정 필요: `EnqueueRoadmapCreationSync`를 실제로 호출하는 develop scan producer를 이번 슬라이스에서 구현할지, 그리고 그 트리거 메커니즘(예: 새 internal/public develop-scan callback route, scheduled 주기 scan, 기존 authoring handoff 확장 중 어느 것)을 무엇으로 둘지에 대한 사용자 결정이 필요하다. +- 차단 근거: 계획 REVIEW_SYNC-2가 "기존 develop scan producer가 있으면 그 producer에서 `EnqueueRoadmapCreationSync`를 호출한다. 현재 repo에 producer가 끝내 없으면 dead worker만으로 완료하지 말고 `사용자 리뷰 요청`에 'runtime scan producer 범위 결정 필요'를 기록한다"고 명시한다. 코드 전수 검색 결과 repo에 develop scan producer가 없다(아래 grep 출력은 주석 1건만 매칭). producer 트리거를 무엇으로 둘지는 contracts/도메인 경계(proto-socket 우선, REST 예외 사유)와 직결되어 임의 선택이 부적절하다. core domain rule상 새 internal 런타임 통신은 proto-socket 우선이고 REST/HTTP는 예외 사유를 남겨야 하므로, 트리거 형태 자체가 사용자/설계 결정 사항이다. +- 실행한 검증/명령: + - `grep -rn "ScanResult\|MatchDevelopMilestone\|ProjectPlaneTodo\|develop scan\|DevelopScan\|ScanDevelop" --include=*.go internal cmd | grep -v "_test.go" | grep -v "roadmapsync/"` → 출력은 `internal/workitempipeline/service.go:65:// distinguish the develop scanning base from the slot's independent checkout.` (주석) 1건뿐. 실제 scan producer/caller 없음을 확인. + - 본 슬라이스가 추가한 worker/enqueue seam/server 주입과 단위 테스트는 모두 PASS(아래 검증 결과 참고). worker는 등록되고 `EnqueueRoadmapCreationSync` seam은 호출 가능하지만, 아직 이를 호출하는 producer가 없다. +- 자동 후속 불가 이유: producer를 추가하려면 트리거 메커니즘(callback route 신설 여부, proto-socket vs REST 예외, scan 입력 출처)을 결정해야 하며 이는 명령 재실행이나 산출물 수집으로 채울 수 있는 검증 공백이 아니라 설계/범위 결정이다. +- 재개 조건: 사용자가 (1) producer를 이번 슬라이스 범위에 포함할지, (2) 포함한다면 트리거 메커니즘을 무엇으로 둘지 결정하면, 결정된 seam에서 `EnqueueRoadmapCreationSync`를 호출하도록 연결하고 해당 caller 테스트를 추가해 재개한다. 범위에서 제외하기로 하면 worker/seam/server 주입 + 단위 테스트로 본 슬라이스를 마무리한다. + +## 리뷰어를 위한 체크포인트 + +- helper-level 테스트만 추가하고 runtime caller/wiring 없이 PASS 처리하지 않았는지 확인한다. +- not-ready/self-mutation/conflict 경로에서 provider side effect가 없는지 확인한다. +- comment, body, status 각 provider 성공 직후 ledger mark가 기록되고 retry가 남은 단계만 실행하는지 확인한다. +- scheduler/server wiring이 실제로 compile되고 nil service regression이 유지되는지 확인한다. +- 새 public HTTP route나 DB migration을 몰래 추가하지 않았는지 확인한다. + +## 검증 결과 + +_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ + +필수 규칙: +- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다. +- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. +- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. +- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다. +- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다. + +### REVIEW_SYNC-1 중간 검증 + +```bash +$ 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.004s +ok github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline 0.003s +``` + +### REVIEW_SYNC-2 중간 검증 + +```bash +$ cd services/core && go test -count=1 ./internal/scheduler ./internal/http ./internal/adapters/plane +ok github.com/nomadcode/nomadcode-core/internal/scheduler 2.013s +ok github.com/nomadcode/nomadcode-core/internal/http 0.005s +ok github.com/nomadcode/nomadcode-core/internal/adapters/plane 0.007s +``` + +### 최종 검증 + +```bash +$ cd services/core && go test -count=1 ./... +ok github.com/nomadcode/nomadcode-core/cmd/plane-smoke 0.005s +? github.com/nomadcode/nomadcode-core/cmd/server [no test files] +ok github.com/nomadcode/nomadcode-core/internal/adapters/a2a 0.008s +ok github.com/nomadcode/nomadcode-core/internal/adapters/jira 0.008s +ok github.com/nomadcode/nomadcode-core/internal/adapters/mattermost 0.007s +ok github.com/nomadcode/nomadcode-core/internal/adapters/openai 0.007s +ok github.com/nomadcode/nomadcode-core/internal/adapters/plane 0.009s +? github.com/nomadcode/nomadcode-core/internal/agent [no test files] +ok github.com/nomadcode/nomadcode-core/internal/authoring 0.003s +ok github.com/nomadcode/nomadcode-core/internal/config 0.002s +? github.com/nomadcode/nomadcode-core/internal/db [no test files] +ok github.com/nomadcode/nomadcode-core/internal/http 0.005s +? github.com/nomadcode/nomadcode-core/internal/model [no test files] +ok github.com/nomadcode/nomadcode-core/internal/notification 0.002s +ok github.com/nomadcode/nomadcode-core/internal/projectsync 0.004s +ok github.com/nomadcode/nomadcode-core/internal/protosocket 0.017s +ok github.com/nomadcode/nomadcode-core/internal/roadmapsync 0.003s +ok github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline 0.003s +ok github.com/nomadcode/nomadcode-core/internal/scheduler 2.012s +ok github.com/nomadcode/nomadcode-core/internal/storage 0.005s +ok github.com/nomadcode/nomadcode-core/internal/workflow 0.004s +ok github.com/nomadcode/nomadcode-core/internal/workitem 0.006s +ok github.com/nomadcode/nomadcode-core/internal/workitempipeline 0.004s +``` + +```bash +$ cd services/core && go vet ./... +(출력 없음) +``` + +```bash +$ git diff --check +(출력 없음) +``` + +--- + +> **[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. + +## Ownership + +| Section | Owner | Note | +|---------|-------|------| +| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these | +| Roadmap Targets | Fixed at stub creation from plan | Implementing agent must not modify | +| 구현 항목별 완료 여부 | Implementing agent checks only | Item names are fixed | +| 구현 체크리스트 | Implementing agent checks only | Text/order are fixed | +| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify | +| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content | +| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless user input is required | +| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Pre-filled from plan | +| 검증 결과 | Implementing agent fills outputs only | Command changes require `계획 대비 변경 사항` | + +## 코드리뷰 결과 + +### 2026-06-14 - cloud-G07 + +- 종합 판정: FAIL +- 차원별 평가: + - correctness: Pass + - completeness: Fail + - test coverage: Pass + - API contract: Pass + - code quality: Pass + - plan deviation: Fail + - verification trust: Pass +- 발견된 문제: + - Required `services/core/internal/scheduler/river.go:99`: `EnqueueRoadmapCreationSync` seam과 worker는 추가됐지만, repo 안에는 이를 호출하는 develop scan producer가 없다. `services/core/internal/scheduler/roadmap_sync_jobs.go:18`은 internal develop-scan handoff가 enqueue한다고 설명하지만, 실제 검색 결과 `EnqueueRoadmapCreationSync` 호출자는 seam 자신뿐이고 기존 `workitempipeline`/`authoring` 경로도 authoring task 생성과 결과 판정까지만 담당한다. 계획이 명시한 대로 이 상태는 dead worker만으로 PASS할 수 없으며, 사용자 결정 후 선택된 producer/callback/scheduled scan 경로에서 enqueue를 연결하거나 이번 slice에서 producer를 제외한다고 명시적으로 범위를 줄여야 한다. +- 다음 단계: USER_REVIEW - 구현 파일의 `사용자 리뷰 요청`은 구체적인 범위/트리거 결정, 검색 근거, 재개 조건을 포함하고 있어 user-review gate가 트리거된다. active plan/review를 로그로 아카이브한 뒤 `USER_REVIEW.md`를 작성한다. diff --git a/agent-task/archive/2026/06/m-milestone-work-item-creation-sync/11_creation_sync_orchestrator/complete.log b/agent-task/archive/2026/06/m-milestone-work-item-creation-sync/11_creation_sync_orchestrator/complete.log new file mode 100644 index 0000000..4cb92f6 --- /dev/null +++ b/agent-task/archive/2026/06/m-milestone-work-item-creation-sync/11_creation_sync_orchestrator/complete.log @@ -0,0 +1,45 @@ +# Complete - m-milestone-work-item-creation-sync/11_creation_sync_orchestrator + +## 완료 일시 + +2026-06-14 + +## 요약 + +creation sync orchestrator task는 1회 code-review에서 Gito/develop scan producer 범위 미결정으로 FAIL/USER_REVIEW가 되었고, 사용자 결정에 따라 해당 bridge를 별도 Milestone으로 분리한 뒤 PASS/RESOLVED로 종료했다. + +## 루프 이력 + +| Plan | Review | Verdict | 메모 | +|------|--------|---------|------| +| `plan_cloud_G07_0.log` | `code_review_cloud_G07_0.log` | FAIL | `EnqueueRoadmapCreationSync` caller가 없어 Gito/proto-socket producer 범위 결정이 필요하다고 판정 | +| `USER_REVIEW.md` | user decision | PASS/RESOLVED | Gito branch event backend bridge를 별도 Milestone으로 분리하고 현재 task는 orchestrator/worker/enqueue seam 범위로 종료 | + +## 구현/정리 내용 + +- `roadmapsyncpipeline.Service`가 matched develop scan candidate를 identity/step ledger와 Plane projection 단계에 연결한다. +- scheduler/server에 optional `RoadmapCreationSyncWorker`와 `EnqueueRoadmapCreationSync` seam을 wiring했다. +- Gito proto-socket `branch.updated` backend bridge는 `gito-branch-event-creation-sync-bridge.md` Milestone으로 분리했다. + +## 최종 검증 + +- `cd services/core && go test -count=1 ./internal/roadmapsync ./internal/storage ./internal/roadmapsyncpipeline` - PASS; `roadmapsync`, `storage`, `roadmapsyncpipeline` 패키지 모두 ok. +- `cd services/core && go test -count=1 ./internal/scheduler ./internal/http ./internal/adapters/plane` - PASS; `scheduler`, `http`, `adapters/plane` 패키지 모두 ok. +- `cd services/core && go test -count=1 ./...` - PASS; core 전체 패키지 ok. +- `cd services/core && go vet ./...` - PASS; 출력 없음. +- `git diff --check` - PASS; 출력 없음. + +## Roadmap Completion + +- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md` +- Completed task ids: + - `creation-sync-orchestrator`: PASS; evidence=`plan_cloud_G07_0.log`, `code_review_cloud_G07_0.log`, `USER_REVIEW.md`; verification=`cd services/core && go test -count=1 ./...`, `cd services/core && go vet ./...`, `git diff --check` +- Not completed task ids: 없음 + +## 잔여 Nit + +- 없음 + +## 후속 작업 + +- Gito branch event backend bridge: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/gito-branch-event-creation-sync-bridge.md` diff --git a/agent-task/archive/2026/06/m-milestone-work-item-creation-sync/11_creation_sync_orchestrator/plan_cloud_G07_0.log b/agent-task/archive/2026/06/m-milestone-work-item-creation-sync/11_creation_sync_orchestrator/plan_cloud_G07_0.log new file mode 100644 index 0000000..cabe418 --- /dev/null +++ b/agent-task/archive/2026/06/m-milestone-work-item-creation-sync/11_creation_sync_orchestrator/plan_cloud_G07_0.log @@ -0,0 +1,379 @@ + + +# Plan - REVIEW_SYNC Creation Sync Orchestrator + +## 이 파일을 읽는 구현 에이전트에게 + +`CODE_REVIEW-cloud-G07.md`의 구현 에이전트 소유 섹션을 채우는 것이 구현의 필수 마지막 단계다. 구현 후 검증 명령의 실제 출력, 계획 대비 변경 사항, 주요 설계 결정을 기록하고 active 파일을 그대로 둔 채 리뷰 준비를 보고한다. 최종 판정, log rename, `complete.log`, archive 이동은 code-review-skill 전용이다. + +구현 중 사용자만 결정할 수 있는 외부 환경 전제, scope 충돌, 또는 안전한 진행을 막는 결정이 생기면 사용자에게 직접 묻지 말고 review stub의 `사용자 리뷰 요청` 섹션에 정확한 결정 필요 사항, 근거, 실행한 명령과 출력, 재개 조건을 기록하고 멈춘다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백은 사용자 리뷰 요청이 아니다. + +## 배경 + +현재 마일스톤은 Plane-origin Milestone 생성 sync를 `Todo` 검토 상태까지 수렴시킨다고 되어 있지만, 코드레벨 검토 결과 helper와 저장소만 있고 이를 호출하는 런타임 orchestration 경로가 없다. `roadmapsync`의 match/projection/retry 함수, Plane adapter projection facet, storage ledger는 각각 테스트되어 있으나 develop scan 결과에서 Plane comment/body/status projection으로 이어지는 caller가 없다. 따라서 마일스톤 완료 대신 이 보완 작업을 먼저 구현해야 한다. + +## 사용자 리뷰 요청 흐름 + +구현 중 차단 사항은 active `CODE_REVIEW-cloud-G07.md`의 `사용자 리뷰 요청` 섹션에 기록한다. 구현 에이전트는 채팅에서 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. code-review가 요청의 타당성을 확인한 뒤 실제 `USER_REVIEW.md` 작성 여부를 결정한다. + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md` +- Task ids: + - `creation-sync-orchestrator`: develop Milestone match, identity/step ledger, Plane 원문 보존/본문·제목 갱신/`Todo` 이동 runtime sync orchestrator +- Completion mode: check-on-pass + +## 분석 결과 + +### 읽은 파일 + +- 규칙/로드맵: + - `agent-ops/rules/project/rules.md` + - `agent-ops/rules/private/rules.md` + - `agent-ops/rules/common/rules-roadmap.md` + - `agent-ops/skills/common/router.md` + - `agent-ops/skills/common/code-review/SKILL.md` + - `agent-ops/skills/common/update-roadmap/SKILL.md` + - `agent-ops/skills/common/analyze-roadmap-position/SKILL.md` + - `agent-ops/skills/common/plan/SKILL.md` + - `agent-ops/skills/common/_templates/roadmap-position-report-template.md` + - `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` + - `agent-roadmap/ROADMAP.md` + - `agent-roadmap/current.md` + - `agent-roadmap/phase/agent-ops-mcp-control-plane/PHASE.md` + - `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md` +- 도메인 룰: + - `agent-ops/rules/project/domain/core/rules.md` + - `agent-ops/rules/project/domain/contracts/rules.md` + - `agent-ops/rules/project/domain/workspace-ops/rules.md` +- 테스트 규칙: + - `agent-test/local/rules.md` + - `agent-test/local/core-smoke.md` + - `agent-test/local/contracts-smoke.md` + - `agent-test/local/workspace-ops-smoke.md` +- 소스: + - `services/core/cmd/server/main.go` + - `services/core/internal/adapters/openai/client.go` + - `services/core/internal/adapters/plane/client.go` + - `services/core/internal/authoring/request.go` + - `services/core/internal/authoring/result.go` + - `services/core/internal/db/models.go` + - `services/core/internal/http/handlers.go` + - `services/core/internal/http/router.go` + - `services/core/internal/model/model.go` + - `services/core/internal/projectsync/checkout.go` + - `services/core/internal/projectsync/provision.go` + - `services/core/internal/roadmapsync/develop_match.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/retry.go` + - `services/core/internal/scheduler/jobs.go` + - `services/core/internal/scheduler/river.go` + - `services/core/internal/storage/roadmap_sync_identities.go` + - `services/core/internal/storage/roadmap_sync_steps.go` + - `services/core/internal/storage/store.go` + - `services/core/internal/workflow/model.go` + - `services/core/internal/workflow/lifecycle.go` + - `services/core/internal/workitem/provider.go` + - `services/core/internal/workitempipeline/service.go` +- 테스트: + - `services/core/internal/adapters/openai/client_test.go` + - `services/core/internal/adapters/plane/client_test.go` + - `services/core/internal/http/handlers_test.go` + - `services/core/internal/roadmapsync/develop_match_test.go` + - `services/core/internal/roadmapsync/identity_test.go` + - `services/core/internal/roadmapsync/milestone_parser_test.go` + - `services/core/internal/roadmapsync/plane_format_test.go` + - `services/core/internal/roadmapsync/plane_projection_test.go` + - `services/core/internal/roadmapsync/retry_test.go` + - `services/core/internal/scheduler/jobs_test.go` + - `services/core/internal/storage/roadmap_sync_identities_test.go` + - `services/core/internal/storage/roadmap_sync_steps_test.go` + +### 테스트 환경 규칙 + +- `test_env=local`. +- `agent-test/local/rules.md`가 존재해 전체를 읽었다. +- 매칭 profile로 `core-smoke`, `contracts-smoke`, `workspace-ops-smoke`를 읽었다. 이번 변경은 Core 런타임 orchestration이며 Flutter/mobile 검증은 대상이 아니다. +- 적용 명령: + - 중간: `cd services/core && go test -count=1 ./internal/roadmapsync ./internal/storage` + - 중간: `cd services/core && go test -count=1 ./internal/scheduler ./internal/http ./internal/adapters/plane` + - 최종: `cd services/core && go test -count=1 ./...` + - 최종: `cd services/core && go vet ./...` + - 최종: `git diff --check` +- fresh 실행이 중요하므로 최종 `go test`는 cache 출력이 아니라 `-count=1`을 사용한다. +- 기존 검토 중 `cd services/core && go test ./...`, `cd services/core && go vet ./...`, `git diff --check`는 PASS였지만 helper 미연결을 잡지 못했으므로 이 계획의 PASS 근거로 충분하지 않다. + +### 테스트 커버리지 공백 + +- `MatchDevelopMilestone`, `ParseMilestoneIdentity`, `ProjectPlaneTodo`, `FormatPlaneProjection`, `ReconcileCreationCycle`, storage identity/step ledger는 helper 또는 storage 단위 테스트가 있다. +- `workitempipeline`과 `scheduler` 테스트는 Plane trigger gate와 authoring bridge route를 다룬다. +- 공백: develop scan 결과가 identity upsert, completed-step ledger, actor/revision guard, Plane comment/body/status projection을 단계별로 호출한다는 orchestrator/caller 테스트가 없다. +- 공백: `StepOriginalCommentPreserved`, `StepPlaneBodyUpdated`, `StepPlaneTodoMoved`가 provider 성공 직후 각각 기록되고 재시도에서 건너뛰는지 검증하는 테스트가 없다. +- 공백: `scheduler.New` 또는 server wiring에서 roadmap creation sync worker/service가 등록되어 실제 runtime path가 생기는지 검증하는 테스트가 없다. + +### 심볼 참조 + +- 이름 변경 또는 제거 예정 심볼: 없음. +- 새 symbol을 추가한다. 기존 `ProjectPlaneTodo`, `ReconcileCreationCycle`, `UpsertRoadmapSyncIdentity`, `CompletedSteps`, `MarkStepCompleted`, `MatchDevelopMilestone`, `ParseMilestoneIdentity`는 유지한다. +- 현재 `rg --sort path` 결과 주요 helper는 package test와 storage wrapper 외 runtime caller가 없다. 이 계획의 핵심은 호출부 추가다. + +### 분할 판단 + +- split decision policy를 계획 파일 선택 전에 평가했다. +- 이 작업은 `m-milestone-work-item-creation-sync/11_creation_sync_orchestrator` 한 subtask의 단일 plan으로 둔다. +- foundation과 call-site가 자연스럽게 보이지만, helper API만 먼저 끝내면 마일스톤을 막은 문제인 "실제 호출 경로 없음"이 해소되지 않는다. provider/store fake 기반 orchestrator 테스트와 scheduler/server wiring 테스트를 한 리뷰 단위에서 보는 편이 더 안전하다. +- ownership은 Core sync runtime 하나이며, 새 DB migration이나 public HTTP/contract 변경을 기본 범위로 잡지 않는다. +- 선행 subtask dependency는 없다. `agent-task/archive/**`는 이번 plan 작성에 필요한 predecessor 확인 대상이 아니므로 읽지 않았다. + +### 범위 결정 근거 + +- Plane-origin 생성 sync만 다룬다. Jira, mobile UI, project settings UI, `Todo -> In Progress` 실행 lifecycle은 제외한다. +- 새 public HTTP API는 기본 범위에서 제외한다. develop scan producer가 반드시 process boundary를 넘어야 해서 public route가 필요해지면 contracts/domain rule을 다시 확인하고 `사용자 리뷰 요청` 또는 별도 follow-up으로 멈춘다. +- 새 DB migration은 기본 범위에서 제외한다. 기존 `roadmap_sync_identities`와 `roadmap_sync_steps`로 identity/retry를 구현한다. +- helper 단위 테스트를 단순히 더 추가하는 것으로 완료 처리하지 않는다. runtime caller 또는 worker/service wiring 테스트가 있어야 한다. + +### 빌드 등급 + +- `cloud-G07`: Core runtime, provider side effect, storage idempotency, scheduler/server wiring을 함께 판단해야 하고 기존 테스트가 helper-level에 치우쳐 있어 larger context와 강한 리뷰가 필요하다. + +## 구현 체크리스트 + +- [ ] [REVIEW_SYNC-1] develop scan candidate를 identity/step ledger와 Plane projection 단계에 연결하는 creation sync orchestrator를 구현한다. +- [ ] [REVIEW_SYNC-2] orchestrator가 dead code로 남지 않도록 scheduler/server runtime wiring 또는 기존 develop scan handoff에 연결하고 테스트로 증명한다. +- [ ] 중간 및 최종 검증 명령을 fresh 실행하고 실제 stdout/stderr를 `CODE_REVIEW-cloud-G07.md`에 붙여 넣는다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## [REVIEW_SYNC-1] Creation Sync Orchestrator + +### 문제 + +- `services/core/internal/roadmapsync/develop_match.go:44`는 develop scan candidate 준비 여부만 판단한다. +- `services/core/internal/roadmapsync/retry.go:105`는 step ledger 기반 next action만 결정한다. +- `services/core/internal/storage/roadmap_sync_identities.go:67`와 `services/core/internal/storage/roadmap_sync_steps.go:50`는 identity/step 저장소 wrapper만 제공한다. +- `services/core/internal/roadmapsync/plane_projection.go:62`는 Plane projection을 한 번에 실행하지만, 현재 코드에는 위 helper들을 묶어 호출하고 각 단계 성공 직후 ledger를 기록하는 orchestrator가 없다. + +현재 before: + +```go +// services/core/internal/roadmapsync/plane_projection.go:77 +if err := p.AppendComment(ctx, workitem.CommentInput{ + Ref: in.Ref, + Body: preserveCommentPrefix + " " + in.OriginalBody, + Format: workitem.CommentFormatHTML, + ExternalSource: in.ExternalSource, + ExternalID: in.ExternalID, +}); err != nil { + return err +} + +if err := p.ProjectBody(ctx, workitem.BodyProjection{ + Ref: in.Ref, + Title: projection.Title, + DescriptionHTML: projection.BodyHTML, +}); err != nil { + return err +} + +return p.ProjectStatus(ctx, workitem.StatusProjection{ + Ref: in.Ref, + ProviderState: in.TodoStateID, +}) +``` + +### 해결 방법 + +- 새 side-effect package를 `services/core/internal/roadmapsyncpipeline/`에 만든다. 기존 `roadmapsync` package는 provider-neutral helper 중심으로 유지한다. +- `Service.SyncCreation(ctx, input)`을 추가한다. input은 expected identity, scan result, work item ref, original Plane body, Todo state id, Milestone markdown, actor/self actor, provider/roadmap revision, external source/id를 가진다. +- `ParseMilestoneIdentity`가 필요한 경우 scan result 구성 전에 사용하거나 service input에서 `ScannedMilestones`가 비어 있을 때만 milestone markdown/path에서 identity를 보강한다. +- 기존 identity를 work item 기준으로 찾고 없으면 upsert한다. 이후 `CompletedSteps`를 읽어 `ReconcileCreationCycle`에 전달한다. +- `ActionSkipSelfMutation`, `ActionConflict`, not-ready candidate는 provider side effect 없이 typed result를 반환한다. +- ready + resume이면 다음 순서로 실행하고 각 provider 성공 직후 ledger를 기록한다. + - `StepDevelopMatched` + - `StepOriginalCommentPreserved` + - `StepPlaneBodyUpdated` + - `StepPlaneTodoMoved` +- `ProjectPlaneTodo`는 기존 공개 동작을 유지한다. orchestrator가 단계별 ledger를 기록할 수 있도록 projection formatting과 provider step 호출을 재사용 가능한 unexported helper로 쪼개거나, 동등한 단계별 호출을 orchestrator 안에 구현한다. 기존 `ProjectPlaneTodo` 테스트는 계속 통과해야 한다. + +목표 after: + +```go +candidate := roadmapsync.MatchDevelopMilestone(roadmapsync.MatchInput{ + Scan: in.Scan, + Expected: in.Expected, +}) +if !candidate.Ready { + return SyncCreationResult{Action: SyncActionNotReady, Reason: candidate.Reason}, nil +} + +identityRow, completed, err := s.loadOrUpsertIdentityAndSteps(ctx, in, candidate) +if err != nil { + return SyncCreationResult{}, err +} +decision := roadmapsync.ReconcileCreationCycle(roadmapsync.ReconcileInput{ + Identity: candidate.Identity, + ProviderRevision: in.ProviderRevision, + RoadmapRevision: in.RoadmapRevision, + Actor: in.Actor, + SelfActor: in.SelfActor, + Existing: toExistingIdentity(identityRow), + CompletedSteps: toRoadmapSyncCompleted(completed), +}) +if decision.Action != roadmapsync.ActionResume { + return resultFromDecision(decision), nil +} + +for _, step := range remainingSteps(decision.NextStep, completed) { + if err := s.runStep(ctx, step, in, candidate); err != nil { + return SyncCreationResult{Action: SyncActionRetryable, NextStep: step}, err + } + if _, err := s.store.MarkStepCompleted(ctx, identityRow.ID, toStorageStep(step)); err != nil { + return SyncCreationResult{}, err + } +} +return SyncCreationResult{Action: SyncActionComplete}, nil +``` + +### 수정 파일 및 체크리스트 + +- [ ] `services/core/internal/roadmapsyncpipeline/service.go`를 추가한다. +- [ ] `services/core/internal/roadmapsyncpipeline/service_test.go`를 추가한다. +- [ ] 필요한 경우 `services/core/internal/roadmapsync/plane_projection.go` 내부 provider step helper를 분리하되 기존 API와 테스트를 유지한다. +- [ ] 필요한 경우 `services/core/internal/roadmapsync/plane_projection_test.go`에 기존 `ProjectPlaneTodo` regression을 보강한다. + +### 테스트 작성 + +- 작성: `services/core/internal/roadmapsyncpipeline/service_test.go` + - `TestSyncCreationNotReadyDoesNotCallProvider`: develop match not-ready면 provider 호출과 ledger mark가 없다. + - `TestSyncCreationReadyRunsStepsAndMarksLedger`: ready scan이면 comment, body, status 순서와 step ledger 기록 순서가 일치한다. + - `TestSyncCreationRetrySkipsCompletedSteps`: completed ledger가 있으면 남은 단계만 실행한다. + - `TestSyncCreationStopsBeforeLaterStepOnProviderError`: 중간 provider 실패 시 이후 provider 호출과 ledger mark가 없다. + - `TestSyncCreationSkipsSelfMutation`: actor guard가 provider 호출을 막는다. + +### 중간 검증 + +```bash +cd services/core && go test -count=1 ./internal/roadmapsync ./internal/storage ./internal/roadmapsyncpipeline +``` + +기대 결과: fresh test PASS. `./internal/roadmapsyncpipeline`가 새 package로 존재하지 않는 중간 상태에서 이 명령을 통과했다고 기록하면 안 된다. + +## [REVIEW_SYNC-2] Runtime Wiring + +### 문제 + +- `services/core/internal/scheduler/river.go:26`의 scheduler constructor는 `TaskWorker`만 등록한다. +- `services/core/cmd/server/main.go:97`은 scheduler를 task lifecycle, notification, agent/model client만으로 구성한다. +- `services/core/internal/scheduler/jobs.go:127`부터의 authoring route는 model bridge 응답 후 `authoring_run_state: "in_progress"` 결과를 반환하고, develop scan/projection orchestration을 호출하지 않는다. +- `services/core/internal/http/router.go:33`부터의 API에는 integrations/tasks/task enqueue만 있고 roadmap sync callback이나 내부 handoff route가 없다. +- 따라서 REVIEW_SYNC-1의 service만 추가하면 여전히 runtime dead code가 된다. + +현재 before: + +```go +// services/core/internal/scheduler/river.go:26 +func New(pool *pgxpool.Pool, lifecycle TaskLifecycle, notifications *notification.Service, agentClient agent.Client, modelClient model.Client, runTimeout time.Duration, logger *slog.Logger) (*Client, error) { + workers := river.NewWorkers() + river.AddWorker(workers, &TaskWorker{ + Lifecycle: lifecycle, + Notifications: notifications, + Agent: agentClient, + Model: modelClient, + RunTimeout: runTimeout, + Logger: logger, + }) +``` + +### 해결 방법 + +- `scheduler`에 `RoadmapCreationSyncJobArgs`와 worker를 추가한다. worker는 REVIEW_SYNC-1 service를 호출하고, provider side effect 없이 complete/conflict/not-ready/skip 결과를 구분해 로그와 error를 안정적으로 반환한다. +- `Client`에 `EnqueueRoadmapCreationSync(ctx, args)`를 추가한다. 이 method는 기존 task enqueue와 별도이며 scan handoff가 호출하는 내부 seam이다. +- `scheduler.New`는 선택적 creation sync service를 받아 worker를 등록한다. nil이면 worker를 등록하지 않아 기존 테스트와 기본 task scheduler 동작을 유지한다. +- `cmd/server/main.go`에서 Plane client와 store로 creation sync service를 구성해 scheduler에 전달한다. Plane-origin 범위만 다루며 Jira는 연결하지 않는다. +- 기존 develop scan producer가 있으면 그 producer에서 `EnqueueRoadmapCreationSync`를 호출한다. 현재 repo에 producer가 끝내 없으면 구현 에이전트는 dead worker만으로 완료하지 말고 `사용자 리뷰 요청`에 "runtime scan producer 범위 결정 필요"를 기록한다. +- public HTTP route는 기본 추가하지 않는다. 구현 중 process boundary가 필요하다고 확인되면 contracts/domain rule을 다시 확인하고 별도 범위로 멈춘다. + +목표 after: + +```go +func New( + pool *pgxpool.Pool, + lifecycle TaskLifecycle, + notifications *notification.Service, + agentClient agent.Client, + modelClient model.Client, + creationSync CreationSyncRunner, + runTimeout time.Duration, + logger *slog.Logger, +) (*Client, error) { + workers := river.NewWorkers() + river.AddWorker(workers, &TaskWorker{ /* existing fields */ }) + if creationSync != nil { + river.AddWorker(workers, &RoadmapCreationSyncWorker{ + Sync: creationSync, + Logger: logger, + }) + } +} +``` + +### 수정 파일 및 체크리스트 + +- [ ] `services/core/internal/scheduler/river.go`에 optional sync worker registration과 enqueue method를 추가한다. +- [ ] `services/core/internal/scheduler/jobs.go` 또는 새 `roadmap_sync_jobs.go`에 job args/worker를 추가한다. +- [ ] `services/core/cmd/server/main.go`에서 `roadmapsyncpipeline.Service`를 구성해 scheduler에 주입한다. +- [ ] 기존 develop scan handoff가 확인되면 해당 caller를 연결한다. 없으면 `사용자 리뷰 요청`을 작성하고 dead-code-only PASS를 금지한다. +- [ ] public HTTP route를 추가하지 않은 상태로 끝낼 수 있는지 확인한다. 추가가 필요하면 contracts follow-up 없이 몰래 확장하지 않는다. + +### 테스트 작성 + +- 작성 또는 갱신: `services/core/internal/scheduler/jobs_test.go` 또는 `services/core/internal/scheduler/river_test.go` + - `TestRoadmapCreationSyncWorkerCallsService`: job args가 service input으로 전달된다. + - `TestSchedulerRegistersRoadmapSyncWorkerWhenConfigured`: service가 있을 때 worker/enqueue seam이 작동한다. + - `TestSchedulerWithoutRoadmapSyncKeepsTaskWorkerOnlyBehavior`: nil service에서도 기존 task enqueue 동작이 깨지지 않는다. +- 필요 시 `services/core/cmd/server`는 package test가 없으므로 `go test -count=1 ./...`와 `go vet ./...`로 compile wiring을 검증한다. + +### 중간 검증 + +```bash +cd services/core && go test -count=1 ./internal/scheduler ./internal/http ./internal/adapters/plane +``` + +기대 결과: fresh test PASS. scheduler nil-service regression과 sync worker route가 모두 검증되어야 한다. + +## 수정 파일 요약 + +| 파일 | 항목 | +|------|------| +| `services/core/internal/roadmapsyncpipeline/service.go` | REVIEW_SYNC-1 | +| `services/core/internal/roadmapsyncpipeline/service_test.go` | REVIEW_SYNC-1 | +| `services/core/internal/roadmapsync/plane_projection.go` | REVIEW_SYNC-1 | +| `services/core/internal/roadmapsync/plane_projection_test.go` | REVIEW_SYNC-1 | +| `services/core/internal/scheduler/river.go` | REVIEW_SYNC-2 | +| `services/core/internal/scheduler/jobs.go` 또는 `services/core/internal/scheduler/roadmap_sync_jobs.go` | REVIEW_SYNC-2 | +| `services/core/internal/scheduler/jobs_test.go` 또는 `services/core/internal/scheduler/river_test.go` | REVIEW_SYNC-2 | +| `services/core/cmd/server/main.go` | REVIEW_SYNC-2 | + +## 최종 검증 + +```bash +cd services/core && go test -count=1 ./... +``` + +기대 결과: fresh test PASS. + +```bash +cd services/core && go vet ./... +``` + +기대 결과: PASS. + +```bash +git diff --check +``` + +기대 결과: 출력 없음. + +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/services/core/cmd/server/main.go b/services/core/cmd/server/main.go index 283c506..b9fa754 100644 --- a/services/core/cmd/server/main.go +++ b/services/core/cmd/server/main.go @@ -21,6 +21,7 @@ import ( apphttp "github.com/nomadcode/nomadcode-core/internal/http" "github.com/nomadcode/nomadcode-core/internal/notification" "github.com/nomadcode/nomadcode-core/internal/protosocket" + "github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline" "github.com/nomadcode/nomadcode-core/internal/scheduler" "github.com/nomadcode/nomadcode-core/internal/storage" "github.com/nomadcode/nomadcode-core/internal/workflow" @@ -94,7 +95,14 @@ func run(logger *slog.Logger) error { lifecycle := workflow.NewLifecycle(store, logger) - taskScheduler, err := scheduler.New(pool, lifecycle, notificationService, agentClient, modelClient, time.Duration(cfg.WorkflowTaskTimeoutSec)*time.Second, logger) + // Plane-origin Milestone creation sync orchestrator. Jira is intentionally + // not wired here; this slice handles the Plane projection path only. The + // self-actor is left empty until a dedicated NomadCode Plane account id is + // configured, so the reconcile's self-mutation guard stays a no-op rather + // than mis-skipping a real trigger. + creationSync := roadmapsyncpipeline.NewService(store, planeClient, "") + + taskScheduler, err := scheduler.New(pool, lifecycle, notificationService, agentClient, modelClient, creationSync, time.Duration(cfg.WorkflowTaskTimeoutSec)*time.Second, logger) if err != nil { return err } diff --git a/services/core/internal/roadmapsync/plane_projection.go b/services/core/internal/roadmapsync/plane_projection.go index 142bc3d..6b90a17 100644 --- a/services/core/internal/roadmapsync/plane_projection.go +++ b/services/core/internal/roadmapsync/plane_projection.go @@ -60,6 +60,66 @@ type ProjectPlaneTodoInput struct { // without calling the provider, so a Plane Todo is never produced for an // unmatched or unpushed Milestone. func ProjectPlaneTodo(ctx context.Context, p PlaneTodoProvider, in ProjectPlaneTodoInput) error { + if err := ValidateProjectionInput(p, in); err != nil { + return err + } + + // 1. Preserve the original Plane body as a comment before anything mutates it. + if err := PreserveOriginalComment(ctx, p, in); err != nil { + return err + } + + // 2. Replace title/body with the develop Milestone projection. + if err := UpdatePlaneBody(ctx, p, in); err != nil { + return err + } + + // 3. Move the work item to the Todo state. + return MovePlaneTodo(ctx, p, in) +} + +// PreserveOriginalComment is step 1 of the Plane Todo projection: it preserves +// the original Plane body as a `사용자 요청:` comment before any later step +// overwrites the title/body. It is split out of ProjectPlaneTodo so an +// orchestrator can record a per-step ledger mark right after the provider call +// succeeds and skip the step on a resume. Inputs are not re-validated here; +// ProjectPlaneTodo and the orchestrator validate before calling. +func PreserveOriginalComment(ctx context.Context, p workitem.Commenter, in ProjectPlaneTodoInput) error { + return p.AppendComment(ctx, workitem.CommentInput{ + Ref: in.Ref, + Body: preserveCommentPrefix + " " + in.OriginalBody, + Format: workitem.CommentFormatHTML, + ExternalSource: in.ExternalSource, + ExternalID: in.ExternalID, + }) +} + +// UpdatePlaneBody is step 2 of the Plane Todo projection: it replaces the work +// item title/body with the develop Milestone projection. Split out for the same +// per-step ledger reason as PreserveOriginalComment. +func UpdatePlaneBody(ctx context.Context, p workitem.BodyProjector, in ProjectPlaneTodoInput) error { + projection := FormatPlaneProjection(in.Candidate.Identity.RoadmapMilestonePath, in.MilestoneMarkdown) + return p.ProjectBody(ctx, workitem.BodyProjection{ + Ref: in.Ref, + Title: projection.Title, + DescriptionHTML: projection.BodyHTML, + }) +} + +// MovePlaneTodo is step 3 of the Plane Todo projection: it moves the work item +// to the Todo state. Split out for the same per-step ledger reason as +// PreserveOriginalComment. +func MovePlaneTodo(ctx context.Context, p workitem.StatusProjector, in ProjectPlaneTodoInput) error { + return p.ProjectStatus(ctx, workitem.StatusProjection{ + Ref: in.Ref, + ProviderState: in.TodoStateID, + }) +} + +// ValidateProjectionInput reports the same input guard ProjectPlaneTodo +// enforces, so an orchestrator that drives the steps individually rejects the +// same missing-input cases before calling any provider facet. +func ValidateProjectionInput(p PlaneTodoProvider, in ProjectPlaneTodoInput) error { if !in.Candidate.Ready { return ErrNotReady } @@ -71,32 +131,5 @@ func ProjectPlaneTodo(ctx context.Context, p PlaneTodoProvider, in ProjectPlaneT strings.TrimSpace(in.MilestoneMarkdown) == "" { return ErrInvalidProjectionInput } - - projection := FormatPlaneProjection(in.Candidate.Identity.RoadmapMilestonePath, in.MilestoneMarkdown) - - // 1. Preserve the original Plane body as a comment before anything mutates it. - if err := p.AppendComment(ctx, workitem.CommentInput{ - Ref: in.Ref, - Body: preserveCommentPrefix + " " + in.OriginalBody, - Format: workitem.CommentFormatHTML, - ExternalSource: in.ExternalSource, - ExternalID: in.ExternalID, - }); err != nil { - return err - } - - // 2. Replace title/body with the develop Milestone projection. - if err := p.ProjectBody(ctx, workitem.BodyProjection{ - Ref: in.Ref, - Title: projection.Title, - DescriptionHTML: projection.BodyHTML, - }); err != nil { - return err - } - - // 3. Move the work item to the Todo state. - return p.ProjectStatus(ctx, workitem.StatusProjection{ - Ref: in.Ref, - ProviderState: in.TodoStateID, - }) + return nil } diff --git a/services/core/internal/roadmapsyncpipeline/service.go b/services/core/internal/roadmapsyncpipeline/service.go new file mode 100644 index 0000000..7f23d05 --- /dev/null +++ b/services/core/internal/roadmapsyncpipeline/service.go @@ -0,0 +1,319 @@ +// Package roadmapsyncpipeline owns the runtime side-effect orchestration that +// drives a Plane-origin Milestone creation sync from a develop scan result all +// the way to the projected Plane Todo. The provider-neutral decisions live in +// internal/roadmapsync (match, reconcile, projection formatting) and the +// persisted identity/step ledger lives in internal/storage; this package is the +// caller that wires them together and records a per-step ledger mark right after +// each provider side effect succeeds, so a retry resumes only the remaining +// steps. +package roadmapsyncpipeline + +import ( + "context" + "errors" + + "github.com/nomadcode/nomadcode-core/internal/db" + "github.com/nomadcode/nomadcode-core/internal/roadmapsync" + "github.com/nomadcode/nomadcode-core/internal/storage" + "github.com/nomadcode/nomadcode-core/internal/workitem" +) + +// SyncAction is the typed outcome of a single SyncCreation call. It mirrors the +// reconcile decision and the develop match gate so a worker can log and branch +// without re-deriving the cause. +type SyncAction string + +const ( + // SyncActionNotReady means the develop scan did not match the expected work + // item identity, so no provider side effect ran. + SyncActionNotReady SyncAction = "not_ready" + // SyncActionComplete means every creation-cycle step is already recorded; + // nothing was projected this call. + SyncActionComplete SyncAction = "complete" + // SyncActionConflict means the trigger disagreed with the persisted identity + // (revision or identity-key mismatch); a human/upstream decision is required + // rather than a silent overwrite, so no provider side effect ran. + SyncActionConflict SyncAction = "conflict" + // SyncActionSkipSelfMutation means the trigger was NomadCode's own mutation, + // so it is not treated as a new authoring trigger and no side effect ran. + SyncActionSkipSelfMutation SyncAction = "skip_self_mutation" + // SyncActionProjected means the remaining steps ran and each was recorded in + // the ledger. + SyncActionProjected SyncAction = "projected" +) + +// Store is the persistence seam SyncCreation needs: find or upsert the identity +// row, read the completed-step set, and mark a step completed. It is satisfied +// by *storage.Store and by test fakes so the orchestrator stays unit-testable +// without a database. +type Store interface { + GetRoadmapSyncIdentityByWorkItem(ctx context.Context, args db.GetRoadmapSyncIdentityByWorkItemParams) (db.RoadmapSyncIdentity, error) + UpsertRoadmapSyncIdentity(ctx context.Context, args db.UpsertRoadmapSyncIdentityByWorkItemParams) (db.RoadmapSyncIdentity, error) + CompletedSteps(ctx context.Context, identityID int64) (map[storage.RoadmapSyncStep]bool, error) + MarkStepCompleted(ctx context.Context, identityID int64, step storage.RoadmapSyncStep) (db.RoadmapSyncStep, error) +} + +// Service drives the creation sync orchestration against a Store and the Plane +// Todo provider facets. +type Service struct { + store Store + provider roadmapsync.PlaneTodoProvider + // selfActor identifies NomadCode's own provider account so a mutation it made + // is not re-interpreted as a fresh user trigger. + selfActor string +} + +// NewService builds the orchestrator. provider is the Plane adapter (or a fake) +// that satisfies the comment/body/status facets; selfActor is the provider +// account NomadCode mutates as. +func NewService(store Store, provider roadmapsync.PlaneTodoProvider, selfActor string) *Service { + return &Service{store: store, provider: provider, selfActor: selfActor} +} + +// SyncCreationInput carries everything one creation sync needs without reaching +// into provider DTOs: the develop scan result and the expected work item +// identity (the develop match inputs), the Plane work item ref, the original +// Plane body to preserve, the Todo state id to move to, the develop Milestone +// markdown to project, the actor that caused the trigger, the provider/roadmap +// revisions the trigger observed, and the external source/id tagging the +// preservation comment. +type SyncCreationInput struct { + Scan roadmapsync.ScanResult + Expected roadmapsync.Identity + + Ref workitem.Ref + OriginalBody string + TodoStateID string + MilestoneMarkdown string + + Actor string + ProviderRevision string + RoadmapRevision string + + ExternalSource string + ExternalID string +} + +// SyncCreationResult is the typed outcome a worker logs and branches on. Reason +// is always populated so a non-projected result is never silent. NextStep is set +// only for SyncActionProjected and names the last step the orchestrator +// recorded. +type SyncCreationResult struct { + Action SyncAction + Reason string + NextStep roadmapsync.Step +} + +// SyncCreation runs one creation sync cycle: gate on the develop match, load or +// upsert the identity row, reconcile against the persisted step ledger, and — +// only when the reconcile says resume — run the remaining provider steps in +// order, recording a ledger mark right after each provider call succeeds. A +// not-ready/conflict/self-mutation/complete decision returns without any +// provider side effect. +func (s *Service) SyncCreation(ctx context.Context, in SyncCreationInput) (SyncCreationResult, error) { + candidate := roadmapsync.MatchDevelopMilestone(roadmapsync.MatchInput{ + Scan: in.Scan, + Expected: in.Expected, + }) + if !candidate.Ready { + return SyncCreationResult{Action: SyncActionNotReady, Reason: candidate.Reason}, nil + } + + projectionInput := roadmapsync.ProjectPlaneTodoInput{ + Candidate: candidate, + Ref: in.Ref, + OriginalBody: in.OriginalBody, + TodoStateID: in.TodoStateID, + MilestoneMarkdown: in.MilestoneMarkdown, + ExternalSource: in.ExternalSource, + ExternalID: in.ExternalID, + } + // Reject missing projection inputs before any identity write or provider call, + // matching ProjectPlaneTodo's guard. + if err := roadmapsync.ValidateProjectionInput(s.provider, projectionInput); err != nil { + return SyncCreationResult{}, err + } + + identityRow, completed, err := s.loadOrUpsertIdentityAndSteps(ctx, in, candidate) + if err != nil { + return SyncCreationResult{}, err + } + + decision := roadmapsync.ReconcileCreationCycle(roadmapsync.ReconcileInput{ + Identity: candidate.Identity, + ProviderRevision: in.ProviderRevision, + RoadmapRevision: in.RoadmapRevision, + Actor: in.Actor, + SelfActor: s.selfActor, + Existing: existingFromRow(identityRow), + CompletedSteps: toReconcileSteps(completed), + }) + + switch decision.Action { + case roadmapsync.ActionComplete: + return SyncCreationResult{Action: SyncActionComplete, Reason: decision.Reason}, nil + case roadmapsync.ActionConflict: + return SyncCreationResult{Action: SyncActionConflict, Reason: decision.Reason}, nil + case roadmapsync.ActionSkipSelfMutation: + return SyncCreationResult{Action: SyncActionSkipSelfMutation, Reason: decision.Reason}, nil + case roadmapsync.ActionResume: + // fall through to step execution below. + default: + return SyncCreationResult{}, errors.New("roadmapsyncpipeline: unexpected reconcile action " + string(decision.Action)) + } + + lastStep, err := s.runRemainingSteps(ctx, identityRow.ID, decision.NextStep, completed, projectionInput) + if err != nil { + return SyncCreationResult{}, err + } + return SyncCreationResult{Action: SyncActionProjected, Reason: decision.Reason, NextStep: lastStep}, nil +} + +// loadOrUpsertIdentityAndSteps finds the identity row by provider work item and +// upserts a fresh row when none exists, then reads its completed-step set. The +// upsert reuses storage's atomic conflict handling so a concurrent re-process +// 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 + + 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 !errors.Is(err, storage.ErrRoadmapSyncIdentityNotFound) { + return db.RoadmapSyncIdentity{}, nil, err + } + row, err = s.store.UpsertRoadmapSyncIdentity(ctx, upsertParams(identity, in)) + if err != nil { + return db.RoadmapSyncIdentity{}, nil, err + } + } + + completed, err := s.store.CompletedSteps(ctx, row.ID) + if err != nil { + return db.RoadmapSyncIdentity{}, nil, err + } + return row, completed, nil +} + +// runRemainingSteps executes the creation-cycle steps from nextStep onward, +// skipping any already in the completed set, and records a ledger mark right +// after each provider call succeeds. A provider failure stops the sequence so a +// later step never runs on an unpreserved/unupdated ticket, and the ledger mark +// is never written for a step whose provider call did not succeed. It returns +// the last step it recorded. +func (s *Service) runRemainingSteps(ctx context.Context, identityID int64, nextStep roadmapsync.Step, completed map[storage.RoadmapSyncStep]bool, projectionInput roadmapsync.ProjectPlaneTodoInput) (roadmapsync.Step, error) { + var last roadmapsync.Step + resuming := false + for _, step := range roadmapsync.OrderedSteps { + if step == nextStep { + resuming = true + } + if !resuming { + continue + } + if completed[toStorageStep(step)] { + continue + } + if err := s.runStep(ctx, step, projectionInput); err != nil { + return "", err + } + if _, err := s.store.MarkStepCompleted(ctx, identityID, toStorageStep(step)); err != nil { + return "", err + } + last = step + } + return last, nil +} + +// runStep performs the provider side effect for a single creation-cycle step. +// StepDevelopMatched has no provider side effect — it only records that the +// develop match gate passed — so it is a no-op here and the caller still records +// its ledger mark. +func (s *Service) runStep(ctx context.Context, step roadmapsync.Step, in roadmapsync.ProjectPlaneTodoInput) error { + switch step { + case roadmapsync.StepDevelopMatched: + return nil + case roadmapsync.StepOriginalCommentPreserved: + return roadmapsync.PreserveOriginalComment(ctx, s.provider, in) + case roadmapsync.StepPlaneBodyUpdated: + return roadmapsync.UpdatePlaneBody(ctx, s.provider, in) + case roadmapsync.StepPlaneTodoMoved: + return roadmapsync.MovePlaneTodo(ctx, s.provider, in) + default: + return errors.New("roadmapsyncpipeline: unknown creation cycle step " + string(step)) + } +} + +// upsertParams maps the matched identity and trigger revisions to the storage +// 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. +func upsertParams(identity roadmapsync.Identity, in SyncCreationInput) db.UpsertRoadmapSyncIdentityByWorkItemParams { + return db.UpsertRoadmapSyncIdentityByWorkItemParams{ + 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, + ProviderRevision: nullableString(in.ProviderRevision), + RoadmapRevision: nullableString(in.RoadmapRevision), + } +} + +// existingFromRow maps a persisted identity row to the reconcile's +// provider-neutral existing-identity view. +func existingFromRow(row db.RoadmapSyncIdentity) roadmapsync.ExistingIdentity { + return roadmapsync.ExistingIdentity{ + Found: row.ID != 0, + Identity: roadmapsync.Identity{ + Shape: roadmapsync.Shape(row.Shape), + RoadmapMilestonePath: row.RoadmapMilestonePath, + RoadmapItemID: row.RoadmapItemID, + Provider: workitem.ProviderID(row.Provider), + Tenant: row.Tenant, + Project: row.Project, + WorkItemID: row.WorkItemID, + ParentWorkItemID: row.ParentWorkItemID, + }, + ProviderRevision: derefString(row.ProviderRevision), + RoadmapRevision: derefString(row.RoadmapRevision), + } +} + +// toReconcileSteps maps the storage step set to the reconcile step set. +func toReconcileSteps(completed map[storage.RoadmapSyncStep]bool) map[roadmapsync.Step]bool { + out := make(map[roadmapsync.Step]bool, len(completed)) + for step, done := range completed { + if done { + out[roadmapsync.Step(step)] = true + } + } + return out +} + +// toStorageStep maps a reconcile/domain step to the storage step. The two enums +// share the same string values; the helper keeps the conversion explicit at the +// package boundary. +func toStorageStep(step roadmapsync.Step) storage.RoadmapSyncStep { + return storage.RoadmapSyncStep(step) +} + +func nullableString(v string) *string { + if v == "" { + return nil + } + return &v +} + +func derefString(v *string) string { + if v == nil { + return "" + } + return *v +} diff --git a/services/core/internal/roadmapsyncpipeline/service_test.go b/services/core/internal/roadmapsyncpipeline/service_test.go new file mode 100644 index 0000000..78f0d5e --- /dev/null +++ b/services/core/internal/roadmapsyncpipeline/service_test.go @@ -0,0 +1,308 @@ +package roadmapsyncpipeline + +import ( + "context" + "errors" + "testing" + + "github.com/nomadcode/nomadcode-core/internal/db" + "github.com/nomadcode/nomadcode-core/internal/roadmapsync" + "github.com/nomadcode/nomadcode-core/internal/storage" + "github.com/nomadcode/nomadcode-core/internal/workitem" +) + +const ( + testMilestonePath = "agent-roadmap/phase/p1/milestones/m1.md" + testRevision = "rev-abc" +) + +// fakeProvider records the order of provider facet calls and can fail a chosen +// step so a stop-before-later-step test is possible. +type fakeProvider struct { + calls []string + failOn string + failErr error +} + +func (f *fakeProvider) AppendComment(_ context.Context, _ workitem.CommentInput) error { + f.calls = append(f.calls, "comment") + if f.failOn == "comment" { + return f.failErr + } + return nil +} + +func (f *fakeProvider) ProjectBody(_ context.Context, _ workitem.BodyProjection) error { + f.calls = append(f.calls, "body") + if f.failOn == "body" { + return f.failErr + } + return nil +} + +func (f *fakeProvider) ProjectStatus(_ context.Context, _ workitem.StatusProjection) error { + f.calls = append(f.calls, "status") + if f.failOn == "status" { + return f.failErr + } + return nil +} + +// fakeStore is an in-memory Store. identity is the single persisted row (id 0 +// means "not found"); marked records the steps MarkStepCompleted was called +// with, in order. +type fakeStore struct { + identity db.RoadmapSyncIdentity + found bool + completed map[storage.RoadmapSyncStep]bool + marked []storage.RoadmapSyncStep + upserted bool + markErr error +} + +func newFakeStore() *fakeStore { + return &fakeStore{completed: map[storage.RoadmapSyncStep]bool{}} +} + +func (f *fakeStore) GetRoadmapSyncIdentityByWorkItem(_ context.Context, _ db.GetRoadmapSyncIdentityByWorkItemParams) (db.RoadmapSyncIdentity, error) { + if !f.found { + return db.RoadmapSyncIdentity{}, storage.ErrRoadmapSyncIdentityNotFound + } + return f.identity, nil +} + +func (f *fakeStore) UpsertRoadmapSyncIdentity(_ context.Context, args db.UpsertRoadmapSyncIdentityByWorkItemParams) (db.RoadmapSyncIdentity, error) { + f.upserted = true + f.identity = db.RoadmapSyncIdentity{ + ID: 1, + Shape: args.Shape, + RoadmapMilestonePath: args.RoadmapMilestonePath, + Provider: args.Provider, + Tenant: args.Tenant, + Project: args.Project, + WorkItemID: args.WorkItemID, + ProviderRevision: args.ProviderRevision, + RoadmapRevision: args.RoadmapRevision, + } + f.found = true + return f.identity, nil +} + +func (f *fakeStore) CompletedSteps(_ context.Context, _ int64) (map[storage.RoadmapSyncStep]bool, error) { + out := make(map[storage.RoadmapSyncStep]bool, len(f.completed)) + for k, v := range f.completed { + out[k] = v + } + return out, nil +} + +func (f *fakeStore) MarkStepCompleted(_ context.Context, _ int64, step storage.RoadmapSyncStep) (db.RoadmapSyncStep, error) { + if f.markErr != nil { + return db.RoadmapSyncStep{}, f.markErr + } + f.marked = append(f.marked, step) + f.completed[step] = true + return db.RoadmapSyncStep{Step: string(step)}, nil +} + +func readyInput() SyncCreationInput { + identity := roadmapsync.Identity{ + Shape: roadmapsync.ShapeMilestone, + RoadmapMilestonePath: testMilestonePath, + Provider: "plane", + Tenant: "general", + Project: "nomad", + WorkItemID: "NOMAD-1", + } + return SyncCreationInput{ + Scan: roadmapsync.ScanResult{ + Revision: roadmapsync.Revision{Branch: "develop", Revision: testRevision}, + ChangedFiles: []string{testMilestonePath}, + ScannedMilestones: []roadmapsync.ScannedMilestone{ + {Path: testMilestonePath, Identity: identity}, + }, + }, + Expected: identity, + Ref: workitem.Ref{Provider: "plane", Tenant: "general", Project: "nomad", ID: "NOMAD-1"}, + OriginalBody: "

user request

", + TodoStateID: "todo-state", + MilestoneMarkdown: "# Milestone\n\nbody", + ProviderRevision: testRevision, + RoadmapRevision: testRevision, + ExternalSource: "nomadcode", + ExternalID: "sync-1", + } +} + +func TestSyncCreationNotReadyDoesNotCallProvider(t *testing.T) { + store := newFakeStore() + provider := &fakeProvider{} + svc := NewService(store, provider, "") + + in := readyInput() + // Break the develop match: no changed milestone file. + in.Scan.ChangedFiles = nil + + res, err := svc.SyncCreation(context.Background(), in) + if err != nil { + t.Fatalf("SyncCreation returned error: %v", err) + } + if res.Action != SyncActionNotReady { + t.Fatalf("expected not_ready, got %s (%s)", res.Action, res.Reason) + } + if len(provider.calls) != 0 { + t.Fatalf("expected no provider calls, got %v", provider.calls) + } + if len(store.marked) != 0 || store.upserted { + t.Fatalf("expected no ledger marks or upsert, marked=%v upserted=%v", store.marked, store.upserted) + } +} + +func TestSyncCreationReadyRunsStepsAndMarksLedger(t *testing.T) { + store := newFakeStore() + 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 != SyncActionProjected { + t.Fatalf("expected projected, got %s (%s)", res.Action, res.Reason) + } + + wantProviderOrder := []string{"comment", "body", "status"} + if !equalStrings(provider.calls, wantProviderOrder) { + t.Fatalf("provider call order = %v, want %v", provider.calls, wantProviderOrder) + } + + wantLedger := []storage.RoadmapSyncStep{ + storage.StepDevelopMatched, + storage.StepOriginalCommentPreserved, + storage.StepPlaneBodyUpdated, + storage.StepPlaneTodoMoved, + } + if !equalSteps(store.marked, wantLedger) { + t.Fatalf("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 TestSyncCreationRetrySkipsCompletedSteps(t *testing.T) { + store := newFakeStore() + // Identity already exists with the first two steps recorded. + store.found = true + store.identity = db.RoadmapSyncIdentity{ + ID: 1, + Shape: string(roadmapsync.ShapeMilestone), + RoadmapMilestonePath: testMilestonePath, + Provider: "plane", + Tenant: "general", + Project: "nomad", + WorkItemID: "NOMAD-1", + } + store.completed[storage.StepDevelopMatched] = true + store.completed[storage.StepOriginalCommentPreserved] = true + + 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 != SyncActionProjected { + t.Fatalf("expected projected, got %s (%s)", res.Action, res.Reason) + } + if store.upserted { + t.Fatal("expected no upsert when identity already exists") + } + + // Comment was already preserved; only body+status should run again. + wantProviderOrder := []string{"body", "status"} + if !equalStrings(provider.calls, wantProviderOrder) { + t.Fatalf("provider call order = %v, want %v", provider.calls, wantProviderOrder) + } + wantLedger := []storage.RoadmapSyncStep{ + storage.StepPlaneBodyUpdated, + storage.StepPlaneTodoMoved, + } + if !equalSteps(store.marked, wantLedger) { + t.Fatalf("ledger marks = %v, want %v", store.marked, wantLedger) + } +} + +func TestSyncCreationStopsBeforeLaterStepOnProviderError(t *testing.T) { + store := newFakeStore() + bodyErr := errors.New("plane body projection failed") + provider := &fakeProvider{failOn: "body", failErr: bodyErr} + svc := NewService(store, provider, "") + + _, err := svc.SyncCreation(context.Background(), readyInput()) + if !errors.Is(err, bodyErr) { + t.Fatalf("expected body error, got %v", err) + } + + // Comment ran and was marked; body ran but failed; status must not run. + wantProviderOrder := []string{"comment", "body"} + if !equalStrings(provider.calls, wantProviderOrder) { + t.Fatalf("provider call order = %v, want %v", provider.calls, wantProviderOrder) + } + // develop_matched + original_comment_preserved recorded; body never marked. + wantLedger := []storage.RoadmapSyncStep{ + storage.StepDevelopMatched, + storage.StepOriginalCommentPreserved, + } + if !equalSteps(store.marked, wantLedger) { + t.Fatalf("ledger marks = %v, want %v", store.marked, wantLedger) + } +} + +func TestSyncCreationSkipsSelfMutation(t *testing.T) { + store := newFakeStore() + provider := &fakeProvider{} + svc := NewService(store, provider, "nomadcode-bot") + + in := readyInput() + in.Actor = "nomadcode-bot" + + res, err := svc.SyncCreation(context.Background(), in) + if err != nil { + t.Fatalf("SyncCreation returned error: %v", err) + } + if res.Action != SyncActionSkipSelfMutation { + t.Fatalf("expected skip_self_mutation, got %s (%s)", res.Action, res.Reason) + } + if len(provider.calls) != 0 { + t.Fatalf("expected no provider calls on self mutation, got %v", provider.calls) + } + if len(store.marked) != 0 { + t.Fatalf("expected no ledger marks on self mutation, got %v", store.marked) + } +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func equalSteps(a, b []storage.RoadmapSyncStep) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/services/core/internal/scheduler/river.go b/services/core/internal/scheduler/river.go index 164912a..a8f5024 100644 --- a/services/core/internal/scheduler/river.go +++ b/services/core/internal/scheduler/river.go @@ -23,7 +23,11 @@ type Client struct { logger *slog.Logger } -func New(pool *pgxpool.Pool, lifecycle TaskLifecycle, notifications *notification.Service, agentClient agent.Client, modelClient model.Client, runTimeout time.Duration, logger *slog.Logger) (*Client, error) { +// New builds the scheduler client. creationSync is the optional Plane-origin +// Milestone creation sync orchestrator: when non-nil its worker is registered so +// EnqueueRoadmapCreationSync has a runtime worker; when nil only the task worker +// is registered, preserving the default task scheduler behavior. +func New(pool *pgxpool.Pool, lifecycle TaskLifecycle, notifications *notification.Service, agentClient agent.Client, modelClient model.Client, creationSync CreationSyncRunner, runTimeout time.Duration, logger *slog.Logger) (*Client, error) { workers := river.NewWorkers() river.AddWorker(workers, &TaskWorker{ Lifecycle: lifecycle, @@ -33,6 +37,12 @@ func New(pool *pgxpool.Pool, lifecycle TaskLifecycle, notifications *notificatio RunTimeout: runTimeout, Logger: logger, }) + if creationSync != nil { + river.AddWorker(workers, &RoadmapCreationSyncWorker{ + Sync: creationSync, + Logger: logger, + }) + } driver := riverpgxv5.New(pool) riverClient, err := river.NewClient[pgx.Tx](driver, &river.Config{ @@ -85,3 +95,12 @@ func (c *Client) EnqueueTask(ctx context.Context, taskID string) error { _, err := c.river.Insert(ctx, TaskJobArgs{TaskID: taskID}, nil) return err } + +// EnqueueRoadmapCreationSync enqueues a Plane-origin Milestone creation sync +// job. It is the internal seam the develop-scan handoff calls to hand a matched +// scan result to the orchestrator worker; it is intentionally separate from +// EnqueueTask and is not exposed through a public HTTP route. +func (c *Client) EnqueueRoadmapCreationSync(ctx context.Context, args RoadmapCreationSyncJobArgs) error { + _, err := c.river.Insert(ctx, args, nil) + return err +} diff --git a/services/core/internal/scheduler/river_test.go b/services/core/internal/scheduler/river_test.go new file mode 100644 index 0000000..e58b1e8 --- /dev/null +++ b/services/core/internal/scheduler/river_test.go @@ -0,0 +1,120 @@ +package scheduler + +import ( + "context" + "errors" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/riverqueue/river" + + "github.com/nomadcode/nomadcode-core/internal/roadmapsync" + "github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline" + "github.com/nomadcode/nomadcode-core/internal/workitem" +) + +// fakeCreationSyncRunner records the input it was driven with so a worker test +// can assert the job args were mapped into the orchestrator input. +type fakeCreationSyncRunner struct { + called bool + input roadmapsyncpipeline.SyncCreationInput + result roadmapsyncpipeline.SyncCreationResult + err error +} + +func (f *fakeCreationSyncRunner) SyncCreation(_ context.Context, in roadmapsyncpipeline.SyncCreationInput) (roadmapsyncpipeline.SyncCreationResult, error) { + f.called = true + f.input = in + return f.result, f.err +} + +func sampleCreationSyncArgs() RoadmapCreationSyncJobArgs { + identity := roadmapsync.Identity{ + Shape: roadmapsync.ShapeMilestone, + RoadmapMilestonePath: "agent-roadmap/phase/p1/milestones/m1.md", + Provider: "plane", + Tenant: "general", + Project: "nomad", + WorkItemID: "NOMAD-1", + } + return RoadmapCreationSyncJobArgs{ + Expected: identity, + Ref: workitem.Ref{Provider: "plane", ID: "NOMAD-1"}, + OriginalBody: "

body

", + TodoStateID: "todo-state", + MilestoneMarkdown: "# m", + ProviderRevision: "rev-1", + RoadmapRevision: "rev-1", + } +} + +func TestRoadmapCreationSyncWorkerCallsService(t *testing.T) { + runner := &fakeCreationSyncRunner{ + result: roadmapsyncpipeline.SyncCreationResult{Action: roadmapsyncpipeline.SyncActionProjected}, + } + worker := &RoadmapCreationSyncWorker{Sync: runner} + + args := sampleCreationSyncArgs() + err := worker.Work(context.Background(), &river.Job[RoadmapCreationSyncJobArgs]{Args: args}) + if err != nil { + t.Fatalf("Work returned error: %v", err) + } + if !runner.called { + t.Fatal("expected orchestrator to be called") + } + if runner.input.Expected.WorkItemID != "NOMAD-1" { + t.Errorf("unexpected work item id: %q", runner.input.Expected.WorkItemID) + } + if runner.input.MilestoneMarkdown != "# m" || runner.input.TodoStateID != "todo-state" { + t.Errorf("job args not mapped into orchestrator input: %#v", runner.input) + } +} + +func TestRoadmapCreationSyncWorkerReturnsServiceError(t *testing.T) { + wantErr := errors.New("provider failed") + runner := &fakeCreationSyncRunner{err: wantErr} + worker := &RoadmapCreationSyncWorker{Sync: runner} + + err := worker.Work(context.Background(), &river.Job[RoadmapCreationSyncJobArgs]{Args: sampleCreationSyncArgs()}) + if !errors.Is(err, wantErr) { + t.Fatalf("expected service error to propagate (so River retries), got %v", err) + } +} + +// lazyPool builds a pgxpool without dialing. pgxpool.New is lazy and only +// connects on first acquire, so it is safe for constructing the scheduler in a +// unit test that never starts the client. +func lazyPool(t *testing.T) *pgxpool.Pool { + t.Helper() + pool, err := pgxpool.New(context.Background(), "postgres://user:pass@127.0.0.1:1/db") + if err != nil { + t.Fatalf("build lazy pool: %v", err) + } + t.Cleanup(pool.Close) + return pool +} + +func TestSchedulerRegistersRoadmapSyncWorkerWhenConfigured(t *testing.T) { + runner := &fakeCreationSyncRunner{} + client, err := New(lazyPool(t), nil, nil, nil, nil, runner, 0, nil) + if err != nil { + t.Fatalf("New with creation sync runner: %v", err) + } + if client == nil { + t.Fatal("expected non-nil client") + } + // The enqueue seam exists regardless of registration; the worker registration + // is exercised by New succeeding with a non-nil runner (River rejects an + // unknown job kind only at insert/work time, so construction succeeding plus + // the worker unit test above cover the wiring without a live DB). +} + +func TestSchedulerWithoutRoadmapSyncKeepsTaskWorkerOnlyBehavior(t *testing.T) { + client, err := New(lazyPool(t), nil, nil, nil, nil, nil, 0, nil) + if err != nil { + t.Fatalf("New without creation sync runner: %v", err) + } + if client == nil { + t.Fatal("expected non-nil client") + } +} diff --git a/services/core/internal/scheduler/roadmap_sync_jobs.go b/services/core/internal/scheduler/roadmap_sync_jobs.go new file mode 100644 index 0000000..b98590e --- /dev/null +++ b/services/core/internal/scheduler/roadmap_sync_jobs.go @@ -0,0 +1,88 @@ +package scheduler + +import ( + "context" + "log/slog" + + "github.com/riverqueue/river" + + "github.com/nomadcode/nomadcode-core/internal/roadmapsync" + "github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline" + "github.com/nomadcode/nomadcode-core/internal/workitem" +) + +// RoadmapCreationSyncJobArgs is the River job that drives one Plane-origin +// Milestone creation sync cycle. It carries the develop scan result and the +// expected work item identity (the develop match inputs) plus the projection +// inputs, so the worker can build the orchestrator input without reaching into +// provider DTOs. It is enqueued by the internal develop-scan handoff via +// Client.EnqueueRoadmapCreationSync, not by a public API. +type RoadmapCreationSyncJobArgs struct { + Scan roadmapsync.ScanResult `json:"scan"` + Expected roadmapsync.Identity `json:"expected"` + + Ref workitem.Ref `json:"ref"` + OriginalBody string `json:"original_body"` + TodoStateID string `json:"todo_state_id"` + MilestoneMarkdown string `json:"milestone_markdown"` + + Actor string `json:"actor,omitempty"` + ProviderRevision string `json:"provider_revision,omitempty"` + RoadmapRevision string `json:"roadmap_revision,omitempty"` + + ExternalSource string `json:"external_source,omitempty"` + ExternalID string `json:"external_id,omitempty"` +} + +func (RoadmapCreationSyncJobArgs) Kind() string { + return "roadmap_creation_sync" +} + +// CreationSyncRunner is the orchestrator seam the worker drives. It is satisfied +// by *roadmapsyncpipeline.Service and by test fakes, so the worker stays +// testable without a Plane client or database. +type CreationSyncRunner interface { + SyncCreation(ctx context.Context, in roadmapsyncpipeline.SyncCreationInput) (roadmapsyncpipeline.SyncCreationResult, error) +} + +// RoadmapCreationSyncWorker runs a creation sync job by delegating to the +// orchestrator. Not-ready/conflict/self-mutation/complete are normal terminal +// outcomes the orchestrator already guards with no provider side effect, so the +// worker logs them and returns nil rather than failing and triggering a River +// retry. Only an actual error (provider/storage failure) is returned so River +// retries it. +type RoadmapCreationSyncWorker struct { + river.WorkerDefaults[RoadmapCreationSyncJobArgs] + + Sync CreationSyncRunner + Logger *slog.Logger +} + +func (w *RoadmapCreationSyncWorker) Work(ctx context.Context, job *river.Job[RoadmapCreationSyncJobArgs]) error { + args := job.Args + res, err := w.Sync.SyncCreation(ctx, roadmapsyncpipeline.SyncCreationInput{ + Scan: args.Scan, + Expected: args.Expected, + Ref: args.Ref, + OriginalBody: args.OriginalBody, + TodoStateID: args.TodoStateID, + MilestoneMarkdown: args.MilestoneMarkdown, + Actor: args.Actor, + ProviderRevision: args.ProviderRevision, + RoadmapRevision: args.RoadmapRevision, + ExternalSource: args.ExternalSource, + ExternalID: args.ExternalID, + }) + if err != nil { + return err + } + if w.Logger != nil { + w.Logger.Info("roadmap creation sync completed", + "work_item_id", args.Expected.WorkItemID, + "milestone_path", args.Expected.RoadmapMilestonePath, + "action", string(res.Action), + "reason", res.Reason, + ) + } + return nil +}