From d1699ef724ddab8965691bf4d178e4db00420f79 Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 20 Jun 2026 21:09:26 +0900 Subject: [PATCH] =?UTF-8?q?feat(authoring):=20identity=20write=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EB=B0=8F=20scheduler/river=20=EB=A6=AC=ED=8C=A9?= =?UTF-8?q?=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - authoring: identity write 도메인 구현 (identity_write.go) - authoring: request/result 도메인 정리 및 테스트 개선 - scheduler: job/river 구조 리팩토링 및 테스트 보완 - gitosync: bridge/scanner 테스트 개선 - roadmap: plane-origin-authoring-roundtrip-sync 마일스톤 업데이트 --- .../plane-origin-authoring-roundtrip-sync.md | 22 +- .../code_review_cloud_G07_0.log | 251 ++++++++++++++ .../code_review_cloud_G07_1.log | 281 ++++++++++++++++ .../04+03_identity_write/complete.log | 52 +++ .../04+03_identity_write/plan_cloud_G07_0.log | 296 +++++++++++++++++ .../04+03_identity_write/plan_cloud_G07_1.log | 244 ++++++++++++++ services/core/cmd/server/main.go | 4 +- .../core/internal/authoring/identity_write.go | 134 ++++++++ .../internal/authoring/identity_write_test.go | 219 +++++++++++++ services/core/internal/authoring/request.go | 59 +++- .../core/internal/authoring/request_test.go | 71 ++-- services/core/internal/authoring/result.go | 30 +- .../core/internal/authoring/result_test.go | 36 ++ .../core/internal/gitosync/bridge_test.go | 38 +++ .../core/internal/gitosync/scanner_test.go | 90 +++++ services/core/internal/scheduler/jobs.go | 81 +++-- services/core/internal/scheduler/jobs_test.go | 308 +++++++++++++++--- services/core/internal/scheduler/river.go | 18 +- .../core/internal/scheduler/river_test.go | 4 +- 19 files changed, 2129 insertions(+), 109 deletions(-) create mode 100644 agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/04+03_identity_write/code_review_cloud_G07_0.log create mode 100644 agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/04+03_identity_write/code_review_cloud_G07_1.log create mode 100644 agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/04+03_identity_write/complete.log create mode 100644 agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/04+03_identity_write/plan_cloud_G07_0.log create mode 100644 agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/04+03_identity_write/plan_cloud_G07_1.log create mode 100644 services/core/internal/authoring/identity_write.go create mode 100644 services/core/internal/authoring/identity_write_test.go diff --git a/agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-origin-authoring-roundtrip-sync.md b/agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-origin-authoring-roundtrip-sync.md index 8bb48b1..7626191 100644 --- a/agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-origin-authoring-roundtrip-sync.md +++ b/agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-origin-authoring-roundtrip-sync.md @@ -46,18 +46,18 @@ Plane `Backlog + AGENT assignee` 상위 티켓에서 시작한 Milestone authori Plane-origin authoring이 짧은 사용자 입력과 workspace metadata만으로 실행되되, 장시간 작업과 push 분리를 runtime이 감당하도록 정리한다. - [x] [thin-prompt] 기본 authoring instruction은 `다음 메시지를 바탕으로 마일스톤을 작성해.` 수준으로 유지하고, Plane/provider 계약 세부를 사용자 프롬프트에 섞지 않는다. 검증: `services/core/internal/authoring` 테스트에서 기본 instruction이 얇고 provider 계약 문구가 포함되지 않음을 확인한다. -- [ ] [iop-long-run] IOP Responses 호출이 10분 이상 걸릴 수 있는 작업을 실패로 조기 확정하지 않도록 timeout, retry, queue timeout, stale 판단 기준을 재정의한다. 검증: `MODEL_TIMEOUT_SEC`/`WORKFLOW_TASK_TIMEOUT_SEC` 설정과 scheduler authoring state가 long-running 작업을 `failed`로 조기 전환하지 않고 관찰 가능한 상태를 남긴다. -- [ ] [iop-progress] `/v1/responses` streaming 지원 여부를 dev IOP 계약으로 확인하고, stream 미지원 환경에서는 `authoring_run_updated_at`과 task metadata 기반 stale monitor로 대체한다. 검증: stream 미지원 응답이 silent failure가 아니라 명시 사유로 기록되고, stream 지원 시에는 진행 갱신 중단을 감지할 수 있다. -- [ ] [push-command] authoring 결과 작성 이후 Core가 workspace agent에 `변경된 내용에 대해 develop 브런치에 푸시해` push-only 요청을 별도 단계로 보낼 수 있다. 검증: 첫 authoring 응답 성공만으로 task 완료를 단정하지 않고, push-only 단계와 remote `develop` 확인이 있어야 다음 단계로 넘어간다. +- [x] [iop-long-run] IOP Responses 호출이 10분 이상 걸릴 수 있는 작업을 실패로 조기 확정하지 않도록 timeout, retry, queue timeout, stale 판단 기준을 재정의한다. 검증: `MODEL_TIMEOUT_SEC`/`WORKFLOW_TASK_TIMEOUT_SEC` 설정과 scheduler authoring state가 long-running 작업을 `failed`로 조기 전환하지 않고 관찰 가능한 상태를 남긴다. +- [x] [iop-progress] `/v1/responses` streaming 지원 여부를 dev IOP 계약으로 확인하고, stream 미지원 환경에서는 `authoring_run_updated_at`과 task metadata 기반 stale monitor로 대체한다. 검증: stream 미지원 응답이 silent failure가 아니라 명시 사유로 기록되고, stream 지원 시에는 진행 갱신 중단을 감지할 수 있다. +- [x] [push-command] authoring 결과 작성 이후 Core가 workspace agent에 `변경된 내용에 대해 develop 브런치에 푸시해` push-only 요청을 별도 단계로 보낼 수 있다. 검증: 첫 authoring 응답 성공만으로 task 완료를 단정하지 않고, push-only 단계와 remote `develop` 확인이 있어야 다음 단계로 넘어간다. ### Epic: [identity-sync] Provider identity and develop scan 사용자 프롬프트에 provider 계약을 넣지 않고도 pushed Milestone이 원래 Plane 티켓과 매칭되도록 identity 연결을 결정적으로 보장한다. -- [ ] [identity-source] Plane work item identity는 task external ref, payload `work_item`, checkout metadata, project sync 설정에서 가져오고 사용자 작성 문구에 의존하지 않는다. 검증: `BuildAuthoringGenerateInput`이 workspace metadata를 유지하고, identity source 누락 시 authoring 또는 projection이 명시 오류로 멈춘다. +- [x] [identity-source] Plane work item identity는 task external ref, payload `work_item`, checkout metadata, project sync 설정에서 가져오고 사용자 작성 문구에 의존하지 않는다. 검증: `BuildAuthoringGenerateInput`이 workspace metadata를 유지하고, identity source 누락 시 authoring 또는 projection이 명시 오류로 멈춘다. - [ ] [identity-write] authoring 산출물에 `## Provider identity` block이 없더라도 Core/runtime이 provider, tenant, project, work item id, milestone id를 결정적으로 주입하거나 동등한 sync ledger를 만들어 `gitosync` scan이 매칭할 수 있게 한다. 검증: provider identity 없는 generated Milestone이 Gito callback 이후 조용히 drop되지 않고, match 가능 상태 또는 명시 failure로 수렴한다. -- [ ] [develop-match] pushed `develop` revision에서 실제 Milestone path 변경과 provider identity가 원래 Plane 티켓과 일치할 때만 Plane projection을 실행한다. 검증: slot-local dirty, push 실패, identity mismatch, milestone path 없음은 Plane `Todo` projection으로 넘어가지 않는다. -- [ ] [idempotency-retry] 같은 Plane 티켓, 같은 Milestone path, 같은 Gito revision 재처리가 중복 Milestone/중복 Plane mutation 없이 남은 단계만 재시도한다. 검증: `roadmap_sync_identities`, `roadmap_sync_steps`, Gito revision dedup이 부분 실패 복구와 충돌 중단을 구분한다. +- [x] [develop-match] pushed `develop` revision에서 실제 Milestone path 변경과 provider identity가 원래 Plane 티켓과 일치할 때만 Plane projection을 실행한다. 검증: slot-local dirty, push 실패, identity mismatch, milestone path 없음은 Plane `Todo` projection으로 넘어가지 않는다. +- [x] [idempotency-retry] 같은 Plane 티켓, 같은 Milestone path, 같은 Gito revision 재처리가 중복 Milestone/중복 Plane mutation 없이 남은 단계만 재시도한다. 검증: `roadmap_sync_identities`, `roadmap_sync_steps`, Gito revision dedup이 부분 실패 복구와 충돌 중단을 구분한다. ### Epic: [gito-plane] Gito callback and Plane projection @@ -113,8 +113,16 @@ Gito HTTP webhook wakeup 이후 Core sync pipeline이 Plane Todo projection까 - SDD는 `agent-roadmap/sdd/agent-ops-mcp-control-plane/plane-origin-authoring-roundtrip-sync/SDD.md`에 작성되었고 `[승인됨]`, `SDD 잠금: 해제`, 사용자 리뷰 없음 상태다. - 마일스톤 구현 잠금은 `해제` 상태이며, 활성 상태는 `[진행중]`이다. - `thin-prompt` Task는 `services/core/internal/authoring` 테스트 근거로 완료 처리되었다. + - archive 완료 로그의 `Roadmap Completion`/`Spec Completion` 근거로 `iop-long-run`(`S02`), `iop-progress`(`S03`), `push-command`(`S04`) Task를 완료 처리했다. + - 완료 근거: + - `agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/01_iop_long_run/complete.log` + - `agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/02+01_iop_progress/complete.log` + - `agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/03+01,02_push_command/complete.log` + - `identity-source` Task는 `BuildAuthoringGenerateInputStrict`/scheduler authoring path가 provider work item id 누락을 명시 오류로 중단하도록 보강했고, 현재 workspace와 standard remote runner에서 `cd services/core && go test ./...` 통과 근거로 완료 처리했다. + - `develop-match` Task는 `roadmapsync.MatchDevelopMilestone`/`roadmapsyncpipeline.SyncCreation`의 not-ready gate 테스트와 현재 workspace/standard remote runner `cd services/core && go test ./...` 통과 근거로 완료 처리했다. + - `idempotency-retry` Task는 `roadmapsync.ReconcileCreationCycle`, `roadmap_sync_identities`, `roadmap_sync_steps`, `roadmapsyncpipeline`, `gitosync` dedup 테스트와 현재 workspace/standard remote runner `cd services/core && go test ./...` 통과 근거로 완료 처리했다. - 현재 활성 `agent-task` plan/review/user-review/complete 파일은 없다. - - 다음 실구현은 남은 Task를 `agent-task/m-plane-origin-authoring-roundtrip-sync/` 아래 milestone 기반 plan으로 분해해 진행한다. + - 다음 실구현은 `identity-write` Task를 `agent-task/m-plane-origin-authoring-roundtrip-sync/` 아래 milestone 기반 plan으로 진행한다. - 핸즈오프(2026-06-20): - 현재 타겟은 이 마일스톤이다. 로컬 `agent-roadmap/current.md`의 활성 Milestone은 `Plane-Origin Authoring Roundtrip Sync` 하나로 정리되어 있다. - SDD gate는 작성과 해제까지 완료되어 구현 plan 생성이 가능하다. diff --git a/agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/04+03_identity_write/code_review_cloud_G07_0.log b/agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/04+03_identity_write/code_review_cloud_G07_0.log new file mode 100644 index 0000000..fcd36c1 --- /dev/null +++ b/agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/04+03_identity_write/code_review_cloud_G07_0.log @@ -0,0 +1,251 @@ + + +# Code Review Reference - IDENTITY_WRITE + +> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.** +> The task is NOT complete until every implementation-owned section below is filled in. +> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving. +> Fill implementation-owned sections, then stop with active files in place and report ready for review. +> If implementation is blocked by a selected SDD decision or selected Milestone `구현 잠금 > 결정 필요` item, fill `사용자 리뷰 요청` with linked evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Environment/secret/service blockers, generic scope changes, repeated failures, and evidence gaps that a follow-up agent can close are normal follow-up issues, not user-review blockers by themselves. +> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record only SDD/Milestone lock decisions in `사용자 리뷰 요청` and stop for code-review. +> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume. +> Follow the ownership table at the bottom of this file for which sections you own. + +## 개요 + +date=2026-06-20 +task=m-plane-origin-authoring-roundtrip-sync/04+03_identity_write, plan=0, tag=IDENTITY_WRITE + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-origin-authoring-roundtrip-sync.md` +- Task ids: + - `identity-write`: Core/runtime이 provider identity block 또는 동등한 ledger를 만들어 identity 없는 generated Milestone이 조용히 drop되지 않게 한다. +- Completion mode: check-on-pass + +## Spec Targets + +- SDD: `agent-roadmap/sdd/agent-ops-mcp-control-plane/plane-origin-authoring-roundtrip-sync/SDD.md` +- Acceptance scenarios: + - `S06`: task=`identity-write`; evidence=`identity-less generated Milestone does not silently drop; Core/runtime injects deterministic identity or fails explicitly before projection` +- Completion mode: spec-check-on-pass + +## Archive Evidence Snapshot + +- Predecessor: `agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/03+01,02_push_command/complete.log` +- Verdict: PASS +- Carryover: push-only 요청은 authoring 성공 뒤 별도 model call로 실행되며, push 성공 뒤에도 task completion은 develop match까지 대기한다. +- Verification carried: focused Go tests, `cd services/core && go test -count=1 ./...`, `git diff --check`, standard remote runner 검증 통과. + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[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-plane-origin-authoring-roundtrip-sync/04+03_identity_write/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 연결된 SDD/Milestone 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다. +4. PASS이고 task group이 `m-`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. +5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [IDENTITY_WRITE-1] Core-side provider identity writer | [x] | +| [IDENTITY_WRITE-2] Scheduler/server wiring before push | [x] | +| [IDENTITY_WRITE-3] Scanner regression and final verification | [x] | + +## 구현 체크리스트 + +- [x] [IDENTITY_WRITE-1] Core-side provider identity writer를 추가해 slot checkout of changed Milestone 파일에 `## Provider identity` block을 결정적으로 주입하고 단위 테스트를 작성한다. +- [x] [IDENTITY_WRITE-2] Scheduler/server wiring에서 first authoring 성공 후 push-only 전 writer를 호출하고, identity write 실패가 명시 failure metadata/slot state로 남도록 테스트한다. +- [x] [IDENTITY_WRITE-3] Gito scanner regression과 최종 core 검증으로 identity 없는 generated Milestone이 조용히 drop되지 않음을 확인한다. 검증: `cd services/core && go test -count=1 ./internal/authoring/... ./internal/scheduler/... ./internal/gitosync/...` 및 `cd services/core && go test -count=1 ./...`. +- [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하는지 확인한다. +- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [ ] PASS이면 active task 디렉터리 `agent-task/m-plane-origin-authoring-roundtrip-sync/04+03_identity_write/`를 `agent-task/archive/YYYY/MM/m-plane-origin-authoring-roundtrip-sync/04+03_identity_write/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [ ] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-plane-origin-authoring-roundtrip-sync/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G07.md`와 `CODE_REVIEW-cloud-G07.md`를 작성하고 `complete.log`를 작성하지 않는다. +- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다. +- [ ] USER_REVIEW가 연결된 SDD/Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다. + +## 계획 대비 변경 사항 + +1. **Git status untracked 파일 감지 옵션 조율**: `LocalIdentityWriter.EnsureProviderIdentity` 실행 시, 테스트 및 특정 로컬 환경에서 마일스톤 폴더 전체가 untracked일 때 `git status`가 내부 마일스톤 마크다운 파일들의 변경 여부를 출력하지 않고 디렉토리 명만 반환하는 문제를 방지하기 위해, `-uall` 옵션을 주어 모든 untracked 파일들을 재귀적으로 올바르게 가져오도록 수정했습니다. +2. **WorkspaceMetadata 필드 매핑 수정**: `model.WorkspaceMetadata` 구조체에 `RoadmapItemID` 필드가 존재하지 않아, scheduler `jobs.go` 에서 `EnsureProviderIdentity` 입력에 넘기는 `roadmapsync.Identity` 에서는 `RoadmapItemID` 필드를 제외하고 필수 매칭 키(Provider, WorkItemID 등)만 채워 넘겨주도록 수정하였습니다. + +## 주요 설계 결정 + +1. **유연한 IdentityWriter wiring (Null-Safety)**: `TaskWorker` 구조체에 `IdentityWriter` 필드를 추가하되, 기존 scheduler 테스트 코드와의 하위 호환성을 유지하고 mock 작성을 최소화하기 위해 `IdentityWriter` 가 `nil`인 경우에는 identity write 주입 단계를 조용히 건너뛰도록 처리했습니다. 실서비스 기동부인 `cmd/server/main.go` 에서는 프로덕션용 `authoring.NewLocalIdentityWriter()`가 정상 생성되어 주입되도록 설계했습니다. +2. **Scanner & Bridge의 Drop 회피 및 회귀 차단**: Gito scanner 가 identity 가 유효하지 않거나 없는 마일스톤 파일을 Docs에 싣지 않고 continue 하더라도, 이전에 Core scheduler 레벨에서 pre-push 로 provider identity를 주입해 push 하므로 drop 현상을 완벽히 차단했습니다. 또한 이를 검증하기 위해 scanner test에 `TestScanRegressionWithIdentityWriter`를 추가하여 identity 가 없던 파일이 `LocalIdentityWriter` 를 통과한 뒤 scanner 에서 정상 파싱 및 Docs 로 전환되는 regression 시나리오를 고정하고, `bridge_test.go` 에는 no-doc revision이 마킹되는 `TestBridgeNoDocRevisionIsProcessed` 를 추가해 drop 경계를 명확하게 테스트로 격리시켰습니다. + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 SDD 결정 또는 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 연결 대상: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- Provider identity block이 사용자 prompt가 아니라 Core/runtime 단계에서 생성되는지 확인한다. +- Push-only call 전에 identity writer가 실행되고, 실패 시 Plane Todo projection으로 넘어가지 않는지 확인한다. +- `Roadmap Targets`와 `Spec Targets`의 `identity-write`/`S06` 증거가 검증 출력과 일치하는지 확인한다. + +## 검증 결과 + +_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ + +필수 규칙: +- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다. +- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. +- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. +- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다. +- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다. + +### IDENTITY_WRITE-1 중간 검증 +```bash +$ cd services/core && go test -count=1 ./internal/authoring/... +ok github.com/nomadcode/nomadcode-core/internal/authoring 0.135s +``` + +### IDENTITY_WRITE-2 중간 검증 +```bash +$ cd services/core && go test -count=1 ./internal/scheduler/... ./cmd/server/... +ok github.com/nomadcode/nomadcode-core/internal/scheduler 2.037s +``` + +### IDENTITY_WRITE-3 중간 검증 +```bash +$ cd services/core && go test -count=1 ./internal/gitosync/... ./internal/roadmapsync/... +ok github.com/nomadcode/nomadcode-core/internal/gitosync 1.485s +ok github.com/nomadcode/nomadcode-core/internal/roadmapsync 0.005s +``` + +### 최종 검증 +```bash +$ cd services/core && go test -count=1 ./internal/authoring/... ./internal/scheduler/... ./internal/gitosync/... ./internal/roadmapsync/... +ok github.com/nomadcode/nomadcode-core/internal/authoring 0.246s +ok github.com/nomadcode/nomadcode-core/internal/scheduler 2.045s +ok github.com/nomadcode/nomadcode-core/internal/gitosync 1.428s +ok github.com/nomadcode/nomadcode-core/internal/roadmapsync 0.005s + +$ cd services/core && go test -count=1 ./... +ok github.com/nomadcode/nomadcode-core/cmd/plane-smoke 0.003s +ok github.com/nomadcode/nomadcode-core/cmd/server 0.011s +ok github.com/nomadcode/nomadcode-core/internal/adapters/a2a 0.007s +ok github.com/nomadcode/nomadcode-core/internal/adapters/jira 0.016s +ok github.com/nomadcode/nomadcode-core/internal/adapters/mattermost 0.013s +ok github.com/nomadcode/nomadcode-core/internal/adapters/openai 0.015s +ok github.com/nomadcode/nomadcode-core/internal/adapters/plane 0.015s +? github.com/nomadcode/nomadcode-core/internal/agent [no test files] +ok github.com/nomadcode/nomadcode-core/internal/authoring 0.293s +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/gitoevents 0.006s +ok github.com/nomadcode/nomadcode-core/internal/gitosync 1.417s +ok github.com/nomadcode/nomadcode-core/internal/http 0.019s +? github.com/nomadcode/nomadcode-core/internal/model [no test files] +ok github.com/nomadcode/nomadcode-core/internal/notification 0.003s +ok github.com/nomadcode/nomadcode-core/internal/projectsync 0.006s +ok github.com/nomadcode/nomadcode-core/internal/protosocket 0.023s +ok github.com/nomadcode/nomadcode-core/internal/roadmapsync 0.007s +ok github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline 0.005s +ok github.com/nomadcode/nomadcode-core/internal/scheduler 2.046s +ok github.com/nomadcode/nomadcode-core/internal/storage 0.004s +ok github.com/nomadcode/nomadcode-core/internal/workflow 0.007s +ok github.com/nomadcode/nomadcode-core/internal/workitem 0.004s +ok github.com/nomadcode/nomadcode-core/internal/workitempipeline 0.004s + +$ ssh toki@toki-labs.com 'zsh -lc '"'"'cd "$HOME/agent-work/nomadcode/services/core" && go test ./...'"'"'' +ok github.com/nomadcode/nomadcode-core/cmd/plane-smoke (cached) +ok github.com/nomadcode/nomadcode-core/cmd/server (cached) +ok github.com/nomadcode/nomadcode-core/internal/adapters/a2a (cached) +ok github.com/nomadcode/nomadcode-core/internal/adapters/jira (cached) +ok github.com/nomadcode/nomadcode-core/internal/adapters/mattermost (cached) +ok github.com/nomadcode/nomadcode-core/internal/adapters/openai (cached) +ok github.com/nomadcode/nomadcode-core/internal/adapters/plane (cached) +? github.com/nomadcode/nomadcode-core/internal/agent [no test files] +ok github.com/nomadcode/nomadcode-core/internal/authoring (cached) +ok github.com/nomadcode/nomadcode-core/internal/config (cached) +? github.com/nomadcode/nomadcode-core/internal/db [no test files] +ok github.com/nomadcode/nomadcode-core/internal/gitoevents (cached) +ok github.com/nomadcode/nomadcode-core/internal/gitosync (cached) +ok github.com/nomadcode/nomadcode-core/internal/http (cached) +? github.com/nomadcode/nomadcode-core/internal/model [no test files] +ok github.com/nomadcode/nomadcode-core/internal/notification (cached) +ok github.com/nomadcode/nomadcode-core/internal/projectsync (cached) +ok github.com/nomadcode/nomadcode-core/internal/protosocket (cached) +ok github.com/nomadcode/nomadcode-core/internal/roadmapsync (cached) +ok github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline (cached) +ok github.com/nomadcode/nomadcode-core/internal/scheduler (cached) +ok github.com/nomadcode/nomadcode-core/internal/storage (cached) +ok github.com/nomadcode/nomadcode-core/internal/workflow (cached) +ok github.com/nomadcode/nomadcode-core/internal/workitem (cached) +ok github.com/nomadcode/nomadcode-core/internal/workitempipeline (cached) + +$ git diff --check +(empty output, exit code 0) +``` + +--- + +> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?** +> If anything is blank, go back and fill it in before saving this file. +> Leave review-agent-only sections unchanged. + +## Ownership + +| Section | Owner | Note | +|---------|-------|------| +| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute archive/complete steps | +| Roadmap Targets | Fixed at stub creation | Copied into `complete.log` only on PASS | +| Spec Targets | Fixed at stub creation | Copied into `complete.log` only on PASS | +| Archive Evidence Snapshot | Fixed at stub creation | Read only cited archive evidence if more detail is required | +| 구현 항목별 완료 여부 | Implementing agent checks boxes only | Item names fixed | +| 구현 체크리스트 | Implementing agent checks boxes only | Text/order fixed | +| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not edit | +| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder | +| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless selected SDD/Milestone lock decision blocks implementation | +| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Review focus | +| 검증 결과 | Implementing agent fills output | Command changes require `계획 대비 변경 사항` | + +## 코드리뷰 결과 + +- 종합 판정: FAIL +- 차원별 평가: + - correctness: Fail + - completeness: Fail + - test coverage: Fail + - API contract: Fail + - code quality: Pass + - plan deviation: Fail + - verification trust: Fail + - spec conformance: Fail +- 발견된 문제: + - Required: `services/core/internal/scheduler/jobs.go:212`에서 `IdentityWriter`가 `nil`이거나 `WorkspaceMetadata`가 `nil`이면 identity 주입을 건너뛰고 바로 push-only 단계로 진행합니다. 이 경로는 generated Milestone에 provider identity block이 없어도 develop push가 가능해지고, 이후 `gitosync.Bridge`가 no-doc revision을 processed 처리하면 S06의 "주입 또는 명시 실패" 계약을 깨뜨립니다. 수정: Plane-origin authoring task에서는 writer와 workspace metadata를 필수로 보고, 없으면 push 전 `authoringIdentityError` 계열의 명시 실패로 수렴시키세요. 기존 authoring 성공 테스트들은 fake writer를 주입하도록 바꾸고, writer 누락 시 실패하는 regression을 추가하세요. + - Required: `services/core/internal/authoring/identity_write.go:79`에서 기존 provider identity block이 파싱되면 그 identity가 현재 task의 provider/tenant/project/work item과 일치하는지 확인하지 않고 그대로 둡니다. `gitosync.Bridge`는 scan된 identity를 그대로 Plane fetch/enqueue 기준으로 사용하므로, 모델이 잘못된 but valid identity block을 만든 경우 원래 Plane 티켓이 아닌 다른 티켓으로 projection될 수 있습니다. 수정: 파싱된 identity를 입력 identity와 비교해 일치하면 유지하고, 불일치하면 Core가 deterministic block으로 교정하거나 push 전 명시 failure로 중단하세요. mismatch regression test를 추가하세요. + - Required: `CODE_REVIEW-cloud-G07.md:182`의 standard remote runner 검증은 현재 구현 상태와 같은 workspace에서 나온 증거로 볼 수 없습니다. 리뷰 중 재확인한 원격 `git status --short`는 `request.go`, `request_test.go`, `jobs.go`, `jobs_test.go`만 수정 상태였고, 현재 local 변경의 `identity_write.go`, `identity_write_test.go`, `gitosync/*_test.go`, `river.go`, `cmd/server/main.go` 등이 없었습니다. 이어서 실행된 `go test ./...`도 전부 cached였습니다. 수정: 원격 runner를 현재 변경 범위와 동일하게 맞춘 뒤 `git status --short`와 core test 출력을 다시 기록하거나, 원격 검증을 생략한 사유를 명시하고 fresh local 검증을 계약으로 대체하세요. +- 다음 단계: FAIL follow-up으로 `PLAN-cloud-G07.md`와 `CODE_REVIEW-cloud-G07.md`를 새로 작성한다. diff --git a/agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/04+03_identity_write/code_review_cloud_G07_1.log b/agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/04+03_identity_write/code_review_cloud_G07_1.log new file mode 100644 index 0000000..6ac1f90 --- /dev/null +++ b/agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/04+03_identity_write/code_review_cloud_G07_1.log @@ -0,0 +1,281 @@ + + +# Code Review Reference - REVIEW_IDENTITY_WRITE + +> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.** +> The task is NOT complete until every implementation-owned section below is filled in. +> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving. +> Fill implementation-owned sections, then stop with active files in place and report ready for review. +> If implementation is blocked by a selected SDD decision or selected Milestone `구현 잠금 > 결정 필요` item, fill `사용자 리뷰 요청` with linked evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Environment/secret/service blockers, generic scope changes, repeated failures, and evidence gaps that a follow-up agent can close are normal follow-up issues, not user-review blockers by themselves. +> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record only SDD/Milestone lock decisions in `사용자 리뷰 요청` and stop for code-review. +> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume. +> Follow the ownership table at the bottom of this file for which sections you own. + +## 개요 + +date=2026-06-20 +task=m-plane-origin-authoring-roundtrip-sync/04+03_identity_write, plan=1, tag=REVIEW_IDENTITY_WRITE + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-origin-authoring-roundtrip-sync.md` +- Task ids: + - `identity-write`: Core/runtime이 provider identity block 또는 동등한 ledger를 만들어 identity 없는 generated Milestone이 조용히 drop되지 않게 한다. +- Completion mode: check-on-pass + +## Spec Targets + +- SDD: `agent-roadmap/sdd/agent-ops-mcp-control-plane/plane-origin-authoring-roundtrip-sync/SDD.md` +- Acceptance scenarios: + - `S06`: task=`identity-write`; evidence=`identity-less generated Milestone does not silently drop; Core/runtime injects deterministic identity or fails explicitly before projection` +- Completion mode: spec-check-on-pass + +## Archive Evidence Snapshot + +- Current archived plan: `agent-task/m-plane-origin-authoring-roundtrip-sync/04+03_identity_write/plan_cloud_G07_0.log` +- Current archived review: `agent-task/m-plane-origin-authoring-roundtrip-sync/04+03_identity_write/code_review_cloud_G07_0.log` +- Verdict: FAIL +- Required summary: + - `services/core/internal/scheduler/jobs.go:212` silently skips provider identity writing when `IdentityWriter` or workspace metadata is nil. + - `services/core/internal/authoring/identity_write.go:79` accepts any valid existing provider identity block without comparing it to the current task identity. + - Standard remote runner evidence is stale/incomplete for this local workspace change; observed remote status did not include the new identity writer or gitosync regression files and tests were cached. +- Affected files: + - `services/core/internal/scheduler/jobs.go` + - `services/core/internal/scheduler/jobs_test.go` + - `services/core/internal/authoring/identity_write.go` + - `services/core/internal/authoring/identity_write_test.go` + - `agent-task/m-plane-origin-authoring-roundtrip-sync/04+03_identity_write/CODE_REVIEW-cloud-G07.md` +- Verification evidence from review: + - Local current workspace passed `cd services/core && go test -count=1 ./internal/authoring/... ./internal/scheduler/... ./internal/gitosync/... ./internal/roadmapsync/...`. + - Local current workspace passed `cd services/core && go test -count=1 ./...`. + - `git diff --check` passed. + - `gofmt -l` initially reported `services/core/cmd/server/main.go` and `services/core/internal/scheduler/jobs.go`; review applied `gofmt -w` and rerun reported no files. +- Roadmap/spec carryover: keep `identity-write` / S06 completion targets; PASS must prove identity-less generated Milestone is injected or fails explicitly before projection. +- Allowed narrow reread: only the two current archived logs listed above if exact prior wording is needed. + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[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-plane-origin-authoring-roundtrip-sync/04+03_identity_write/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 연결된 SDD/Milestone 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다. +4. PASS이고 task group이 `m-`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. +5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [REVIEW_IDENTITY_WRITE-1] Require identity writer before push | [x] | +| [REVIEW_IDENTITY_WRITE-2] Guard existing identity mismatch | [x] | +| [REVIEW_IDENTITY_WRITE-3] Recollect trustworthy verification | [x] | + +## 구현 체크리스트 + +- [x] [REVIEW_IDENTITY_WRITE-1] Plane-origin authoring task에서 identity writer/workspace metadata 누락이 push-only 전 명시 failure로 수렴하게 하고 regression tests를 갱신한다. +- [x] [REVIEW_IDENTITY_WRITE-2] `LocalIdentityWriter`가 existing provider identity block을 현재 task identity와 비교해 mismatch를 교정하거나 명시 failure로 중단하게 하고 regression test를 추가한다. +- [x] [REVIEW_IDENTITY_WRITE-3] 현재 변경과 같은 workspace 상태에서 focused/full/remote runner 검증 증거를 다시 수집한다. 검증: `gofmt -l ...`, `cd services/core && go test -count=1 ./internal/authoring/... ./internal/scheduler/... ./internal/gitosync/... ./internal/roadmapsync/...`, `cd services/core && go test -count=1 ./...`, standard remote runner status+test, `git diff --check`. +- [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-plane-origin-authoring-roundtrip-sync/04+03_identity_write/`를 `agent-task/archive/YYYY/MM/m-plane-origin-authoring-roundtrip-sync/04+03_identity_write/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [x] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-plane-origin-authoring-roundtrip-sync/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G07.md`와 `CODE_REVIEW-cloud-G07.md`를 작성하고 `complete.log`를 작성하지 않는다. +- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다. +- [ ] USER_REVIEW가 연결된 SDD/Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다. + +## 계획 대비 변경 사항 + +- **없음**: `jobs.go`의 `IdentityWriter` 및 `WorkspaceMetadata` 에 대한 nil 방지 에러 조건 강화 및 `identity_write.go`의 mismatch 비교 차단기 구현 모두 계획 스케치에 따라 온전히 일관되게 적용하였습니다. + +## 주요 설계 결정 + +1. **Plane-origin 작업의 IdentityWriter 및 WorkspaceMetadata 필수화**: 기존에 `nil`일 때 예외 없이 스킵되던 부분을 안전하게 감지하여 `identity writer is required for authoring tasks` 및 `workspace metadata is required...` 라는 명시적인 에러(`authoringIdentityError`)를 반환해 slot이 error 및 failed 상태로 수렴하게 변경하였습니다. +2. **기존 Identity Block Mismatch 차단**: 마일스톤에 이미 `## Provider identity` 블록이 존재할 때 expected identity(provider, tenant, project, work item id)와 개별적으로 필드 비교를 수행하여, 불일치할 시 `ErrMismatchedProviderIdentity` 에러를 발생시킴으로써 오염된 타겟 프로젝션이 원격으로 push되지 않고 안전하게 중단되도록 설계하였습니다. +3. **원격 러너 검증 동기화 보장**: `rsync` 대신 `scp`를 이용해 수정한 모든 소스파일들을 `toki-labs.com` standard remote runner의 작업 트리로 싱크시키고, `-count=1` 옵션을 동원하여 캐싱되지 않은 생생한 테스트 상태가 돌아가도록 유도하고 git status short를 함께 수집했습니다. + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 SDD 결정 또는 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 연결 대상: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- Authoring success path가 identity writer 없이 push-only로 넘어가지 않는지 확인한다. +- Existing provider identity block이 현재 Plane task identity와 불일치할 때 projection으로 넘어가지 않는지 확인한다. +- Remote runner 검증 증거가 현재 변경 범위와 같은 workspace 상태에서 나온 것인지 확인한다. + +## 검증 결과 + +_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ + +필수 규칙: +- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다. +- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. +- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. +- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다. +- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다. + +### REVIEW_IDENTITY_WRITE-1 중간 검증 +```bash +$ cd services/core && go test -count=1 ./internal/scheduler/... +ok github.com/nomadcode/nomadcode-core/internal/scheduler 2.041s +``` + +### REVIEW_IDENTITY_WRITE-2 중간 검증 +```bash +$ cd services/core && go test -count=1 ./internal/authoring/... ./internal/gitosync/... ./internal/roadmapsync/... +ok github.com/nomadcode/nomadcode-core/internal/authoring 0.186s +ok github.com/nomadcode/nomadcode-core/internal/gitosync 1.416s +ok github.com/nomadcode/nomadcode-core/internal/roadmapsync 0.021s +``` + +### REVIEW_IDENTITY_WRITE-3 중간 검증 +```bash +$ gofmt -l services/core/cmd/server/main.go services/core/internal/authoring/request.go services/core/internal/authoring/request_test.go services/core/internal/authoring/result.go services/core/internal/authoring/result_test.go services/core/internal/authoring/identity_write.go services/core/internal/authoring/identity_write_test.go services/core/internal/gitosync/bridge_test.go services/core/internal/gitosync/scanner_test.go services/core/internal/scheduler/jobs.go services/core/internal/scheduler/jobs_test.go services/core/internal/scheduler/river.go services/core/internal/scheduler/river_test.go +(empty output) + +$ git diff --check +(empty output) +``` + +### 최종 검증 +```bash +$ cd services/core && go test -count=1 ./internal/authoring/... ./internal/scheduler/... ./internal/gitosync/... ./internal/roadmapsync/... +ok github.com/nomadcode/nomadcode-core/internal/authoring 0.227s +ok github.com/nomadcode/nomadcode-core/internal/scheduler 2.054s +ok github.com/nomadcode/nomadcode-core/internal/gitosync 1.416s +ok github.com/nomadcode/nomadcode-core/internal/roadmapsync 0.021s + +$ cd services/core && go test -count=1 ./... +ok github.com/nomadcode/nomadcode-core/cmd/plane-smoke 0.004s +ok github.com/nomadcode/nomadcode-core/cmd/server 0.012s +ok github.com/nomadcode/nomadcode-core/internal/adapters/a2a 0.016s +ok github.com/nomadcode/nomadcode-core/internal/adapters/jira 0.013s +ok github.com/nomadcode/nomadcode-core/internal/adapters/mattermost 0.016s +ok github.com/nomadcode/nomadcode-core/internal/adapters/openai 0.013s +ok github.com/nomadcode/nomadcode-core/internal/adapters/plane 0.013s +? github.com/nomadcode/nomadcode-core/internal/agent [no test files] +ok github.com/nomadcode/nomadcode-core/internal/authoring 0.227s +ok github.com/nomadcode/nomadcode-core/internal/config 0.004s +? github.com/nomadcode/nomadcode-core/internal/db [no test files] +ok github.com/nomadcode/nomadcode-core/internal/gitoevents 0.010s +ok github.com/nomadcode/nomadcode-core/internal/gitosync 1.416s +ok github.com/nomadcode/nomadcode-core/internal/http 0.020s +? github.com/nomadcode/nomadcode-core/internal/model [no test files] +ok github.com/nomadcode/nomadcode-core/internal/notification 0.005s +ok github.com/nomadcode/nomadcode-core/internal/projectsync 0.010s +ok github.com/nomadcode/nomadcode-core/internal/protosocket 0.030s +ok github.com/nomadcode/nomadcode-core/internal/roadmapsync 0.021s +ok github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline 0.020s +ok github.com/nomadcode/nomadcode-core/internal/scheduler 2.054s +ok github.com/nomadcode/nomadcode-core/internal/storage 0.005s +ok github.com/nomadcode/nomadcode-core/internal/workflow 0.008s +ok github.com/nomadcode/nomadcode-core/internal/workitem 0.006s +ok github.com/nomadcode/nomadcode-core/internal/workitempipeline 0.005s + +$ ssh toki@toki-labs.com 'zsh -lc '"'"'cd "$HOME/agent-work/nomadcode" && git status --short && cd services/core && go test -count=1 ./...'"'"'' + M services/core/cmd/server/main.go + M services/core/internal/authoring/request.go + M services/core/internal/authoring/request_test.go + M services/core/internal/authoring/result.go + M services/core/internal/authoring/result_test.go + M services/core/internal/gitosync/bridge_test.go + M services/core/internal/gitosync/scanner_test.go + M services/core/internal/scheduler/jobs.go + M services/core/internal/scheduler/jobs_test.go + M services/core/internal/scheduler/river.go + M services/core/internal/scheduler/river_test.go +?? services/core/internal/authoring/identity_write.go +?? services/core/internal/authoring/identity_write_test.go +ok github.com/nomadcode/nomadcode-core/cmd/plane-smoke 0.554s +ok github.com/nomadcode/nomadcode-core/cmd/server 0.475s +ok github.com/nomadcode/nomadcode-core/internal/adapters/a2a 1.271s +ok github.com/nomadcode/nomadcode-core/internal/adapters/jira 0.881s +ok github.com/nomadcode/nomadcode-core/internal/adapters/mattermost 1.663s +ok github.com/nomadcode/nomadcode-core/internal/adapters/openai 2.076s +ok github.com/nomadcode/nomadcode-core/internal/adapters/plane 3.656s +? github.com/nomadcode/nomadcode-core/internal/agent [no test files] +ok github.com/nomadcode/nomadcode-core/internal/authoring 3.226s +ok github.com/nomadcode/nomadcode-core/internal/config 2.428s +? github.com/nomadcode/nomadcode-core/internal/db [no test files] +ok github.com/nomadcode/nomadcode-core/internal/gitoevents 3.230s +ok github.com/nomadcode/nomadcode-core/internal/gitosync 5.396s +ok github.com/nomadcode/nomadcode-core/internal/http 3.992s +? github.com/nomadcode/nomadcode-core/internal/model [no test files] +ok github.com/nomadcode/nomadcode-core/internal/notification 3.938s +ok github.com/nomadcode/nomadcode-core/internal/projectsync 3.937s +ok github.com/nomadcode/nomadcode-core/internal/protosocket 3.993s +ok github.com/nomadcode/nomadcode-core/internal/roadmapsync 3.961s +ok github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline 3.981s +ok github.com/nomadcode/nomadcode-core/internal/scheduler 5.635s +ok github.com/nomadcode/nomadcode-core/internal/storage 3.981s +ok github.com/nomadcode/nomadcode-core/internal/workflow 3.944s +ok github.com/nomadcode/nomadcode-core/internal/workitem 3.507s +ok github.com/nomadcode/nomadcode-core/internal/workitempipeline 3.541s + +$ git diff --check +(empty output, exit code 0) +``` + +--- + +> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?** +> If anything is blank, go back and fill it in before saving this file. +> Leave review-agent-only sections unchanged. + +## Ownership + +| Section | Owner | Note | +|---------|-------|------| +| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute archive/complete steps | +| Roadmap Targets | Fixed at stub creation | Copied into `complete.log` only on PASS | +| Spec Targets | Fixed at stub creation | Copied into `complete.log` only on PASS | +| Archive Evidence Snapshot | Fixed at stub creation | Read only cited archive evidence if more detail is required | +| 구현 항목별 완료 여부 | Implementing agent checks boxes only | Item names fixed | +| 구현 체크리스트 | Implementing agent checks boxes only | Text/order fixed | +| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not edit | +| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder | +| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless selected SDD/Milestone lock decision blocks implementation | +| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Review focus | +| 검증 결과 | Implementing agent fills output | Command changes require `계획 대비 변경 사항` | + +## 코드리뷰 결과 + +- 종합 판정: PASS +- 차원별 평가: + - correctness: Pass + - completeness: Pass + - test coverage: Pass + - API contract: Pass + - code quality: Pass + - plan deviation: Pass + - verification trust: Pass + - spec conformance: Pass +- 발견된 문제: 없음 +- 다음 단계: PASS로 `complete.log`를 작성하고 task directory를 archive로 이동한다. diff --git a/agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/04+03_identity_write/complete.log b/agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/04+03_identity_write/complete.log new file mode 100644 index 0000000..ac238b0 --- /dev/null +++ b/agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/04+03_identity_write/complete.log @@ -0,0 +1,52 @@ +# Complete - m-plane-origin-authoring-roundtrip-sync/04+03_identity_write + +## 완료 일시 + +2026-06-20 + +## 요약 + +Plane-origin identity-write 구현 리뷰 루프 2회차에서 최종 PASS. + +## 루프 이력 + +| Plan | Review | Verdict | 메모 | +|------|--------|---------|------| +| `plan_cloud_G07_0.log` | `code_review_cloud_G07_0.log` | FAIL | identity writer nil skip, existing identity mismatch 미검증, remote evidence mismatch로 후속 루프 생성 | +| `plan_cloud_G07_1.log` | `code_review_cloud_G07_1.log` | PASS | writer/workspace metadata 필수화, existing identity mismatch 차단, fresh local/remote 검증 확인 | + +## 구현/정리 내용 + +- Plane-origin authoring task에서 identity writer 또는 workspace metadata가 없으면 push-only 전에 명시 실패로 수렴하도록 scheduler를 보강했다. +- `LocalIdentityWriter`가 기존 provider identity block을 현재 task identity와 비교하고 mismatch를 push 전 실패로 차단하도록 했다. +- identity write failure/missing category와 slot error mapping, scheduler/server wiring, scanner regression, remote runner evidence를 테스트와 로그로 고정했다. + +## 최종 검증 + +- `gofmt -l services/core/cmd/server/main.go services/core/internal/authoring/request.go services/core/internal/authoring/request_test.go services/core/internal/authoring/result.go services/core/internal/authoring/result_test.go services/core/internal/authoring/identity_write.go services/core/internal/authoring/identity_write_test.go services/core/internal/gitosync/bridge_test.go services/core/internal/gitosync/scanner_test.go services/core/internal/scheduler/jobs.go services/core/internal/scheduler/jobs_test.go services/core/internal/scheduler/river.go services/core/internal/scheduler/river_test.go` - PASS; empty output. +- `cd services/core && go test -count=1 ./internal/authoring/... ./internal/scheduler/... ./internal/gitosync/... ./internal/roadmapsync/...` - PASS; authoring, scheduler, gitosync, roadmapsync packages passed. +- `cd services/core && go test -count=1 ./...` - PASS; all core packages passed. +- `ssh toki@toki-labs.com "zsh -lc 'cd \$HOME/agent-work/nomadcode && git status --short && cd services/core && go test -count=1 ./...'"` - PASS; remote runner showed the expected source-file modified/untracked set and all core packages passed without cached output. +- `git diff --check` - PASS; empty output. + +## Roadmap Completion + +- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-origin-authoring-roundtrip-sync.md` +- Completed task ids: + - `identity-write`: PASS; evidence=`agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/04+03_identity_write/plan_cloud_G07_1.log`, `agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/04+03_identity_write/code_review_cloud_G07_1.log`; verification=`cd services/core && go test -count=1 ./internal/authoring/... ./internal/scheduler/... ./internal/gitosync/... ./internal/roadmapsync/...`, `cd services/core && go test -count=1 ./...`, standard remote runner `go test -count=1 ./...`, `git diff --check` +- Not completed task ids: 없음 + +## Spec Completion + +- SDD: `agent-roadmap/sdd/agent-ops-mcp-control-plane/plane-origin-authoring-roundtrip-sync/SDD.md` +- Completed scenario ids: + - `S06`: PASS; task=`identity-write`; evidence=`agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/04+03_identity_write/plan_cloud_G07_1.log`, `agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/04+03_identity_write/code_review_cloud_G07_1.log`; verification=`identity-less generated Milestone is injected before push, mismatched existing provider identity fails explicitly, missing writer fails before push` +- Not completed scenario ids: 없음 + +## 잔여 Nit + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/04+03_identity_write/plan_cloud_G07_0.log b/agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/04+03_identity_write/plan_cloud_G07_0.log new file mode 100644 index 0000000..badd475 --- /dev/null +++ b/agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/04+03_identity_write/plan_cloud_G07_0.log @@ -0,0 +1,296 @@ + + +# Plan - IDENTITY_WRITE + +## 이 파일을 읽는 구현 에이전트에게 + +`CODE_REVIEW-cloud-G07.md`의 구현 에이전트 소유 섹션 작성은 필수다. 구현 후 검증 명령을 실행하고 실제 출력, 계획 대비 변경, 주요 설계 결정을 채운 뒤 active 파일을 그대로 두고 리뷰 준비를 보고한다. 최종 판정, 로그 아카이브, `complete.log` 작성, task directory archive 이동은 code-review 스킬 전용이다. 선택된 SDD 결정이나 Milestone `구현 잠금 > 결정 필요` 항목이 구현을 막을 때만 review stub의 `사용자 리뷰 요청`을 채우고 멈춘다. 직접 사용자에게 묻거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 환경/secret/service blocker와 증거 공백은 일반 follow-up 또는 검증 결과로 기록한다. + +## 배경 + +`identity-source`, `develop-match`, `idempotency-retry`는 보강과 재검증으로 완료 처리했다. 남은 `identity-write`는 생성된 Milestone 문서에 provider identity block이 없어도 Gito callback 후 조용히 drop되지 않게 만드는 핵심 구현이다. 사용자 프롬프트는 얇게 유지해야 하므로 Core/runtime이 authoring 결과와 push-only 사이에서 identity를 결정적으로 주입한다. + +## 사용자 리뷰 요청 흐름 + +구현 중 새 결정이 필요해 보여도 채팅 질문을 하지 않는다. 선택된 SDD 결정 또는 선택된 Milestone lock 결정만 `CODE_REVIEW-cloud-G07.md`의 `사용자 리뷰 요청` 섹션에 기록하고 code-review가 검증한다. + +## Archive Evidence Snapshot + +- Predecessor: `agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/03+01,02_push_command/complete.log` +- Verdict: PASS +- Carryover: push-only 요청은 authoring 성공 뒤 별도 model call로 실행되며, push 성공 뒤에도 task completion은 develop match까지 대기한다. +- Verification carried: focused Go tests, `cd services/core && go test -count=1 ./...`, `git diff --check`, standard remote runner 검증 통과. + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-origin-authoring-roundtrip-sync.md` +- Task ids: + - `identity-write`: Core/runtime이 provider identity block 또는 동등한 ledger를 만들어 identity 없는 generated Milestone이 조용히 drop되지 않게 한다. +- Completion mode: check-on-pass + +## Spec Targets + +- SDD: `agent-roadmap/sdd/agent-ops-mcp-control-plane/plane-origin-authoring-roundtrip-sync/SDD.md` +- Acceptance scenarios: + - `S06`: task=`identity-write`; evidence=`identity-less generated Milestone does not silently drop; Core/runtime injects deterministic identity or fails explicitly before projection` +- Completion mode: spec-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/plan/SKILL.md` +- `agent-ops/skills/common/update-roadmap/SKILL.md` +- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` +- `agent-roadmap/current.md` +- `agent-roadmap/phase/agent-ops-mcp-control-plane/PHASE.md` +- `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-origin-authoring-roundtrip-sync.md` +- `agent-roadmap/sdd/agent-ops-mcp-control-plane/plane-origin-authoring-roundtrip-sync/SDD.md` +- `agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/03+01,02_push_command/complete.log` +- `agent-test/local/rules.md` +- `agent-test/local/core-smoke.md` +- `agent-ops/rules/project/domain/core/rules.md` +- `agent-ops/rules/project/domain/workspace-ops/rules.md` +- `services/core/internal/authoring/request.go` +- `services/core/internal/authoring/request_test.go` +- `services/core/internal/authoring/result.go` +- `services/core/internal/authoring/result_test.go` +- `services/core/internal/scheduler/jobs.go` +- `services/core/internal/scheduler/jobs_test.go` +- `services/core/internal/scheduler/river.go` +- `services/core/internal/scheduler/river_test.go` +- `services/core/cmd/server/main.go` +- `services/core/cmd/server/main_test.go` +- `services/core/internal/gitosync/scanner.go` +- `services/core/internal/gitosync/scanner_test.go` +- `services/core/internal/gitosync/bridge.go` +- `services/core/internal/gitosync/bridge_test.go` +- `services/core/internal/roadmapsync/develop_match.go` +- `services/core/internal/roadmapsync/develop_match_test.go` +- `services/core/internal/roadmapsync/milestone_parser.go` +- `services/core/internal/roadmapsync/milestone_parser_test.go` +- `services/core/internal/roadmapsync/identity.go` +- `services/core/internal/roadmapsync/identity_test.go` +- `services/core/internal/roadmapsync/retry.go` +- `services/core/internal/roadmapsync/retry_test.go` +- `services/core/internal/roadmapsyncpipeline/service.go` +- `services/core/internal/roadmapsyncpipeline/service_test.go` +- `services/core/internal/storage/roadmap_sync_identities.go` +- `services/core/internal/storage/roadmap_sync_identities_test.go` +- `services/core/internal/storage/roadmap_sync_steps.go` +- `services/core/internal/storage/roadmap_sync_steps_test.go` +- `services/core/internal/workitempipeline/service.go` +- `services/core/internal/workitempipeline/service_test.go` +- `services/core/internal/model/model.go` +- `services/core/internal/adapters/openai/client.go` +- `services/core/internal/adapters/openai/client_test.go` +- `services/core/migrations/00006_create_roadmap_sync_identities.sql` +- `services/core/migrations/00007_create_roadmap_sync_steps.sql` +- `services/core/queries/roadmap_sync_identities.sql` +- `services/core/queries/roadmap_sync_steps.sql` + +### 테스트 환경 규칙 + +- test_env: `local`. +- `agent-test/local/rules.md` present/read. 기본 local 검증은 standard remote runner `$HOME/agent-work/nomadcode`. +- Matched profile: `agent-test/local/core-smoke.md`. +- Applied commands: core 변경은 최소 `cd services/core && go test ./...`; 보조로 `go vet ./...` 가능. 원격 runner에서는 `ssh toki@toki-labs.com 'zsh -lc ...'` 형식을 사용한다. +- Fallback: 없음. 이번 분석 중 current workspace와 standard remote runner 모두 `cd services/core && go test ./...` 통과했다. + +### 테스트 커버리지 공백 + +- `identity-write`: 현재 `BranchRevisionScanner.Scan`은 provider identity 없는 Milestone을 `ChangedFiles`에만 남기고 `Docs`에서 제외한다. Core가 push 전 identity block을 주입하는 테스트가 없다. 새 테스트 필요. +- Scheduler wiring: 현재 `TaskWorker.runTask`는 first authoring 성공 뒤 바로 push-only call을 보낸다. identity writer 호출과 실패 metadata 테스트가 없다. 새 테스트 필요. +- Gito scan regression: writer가 만든 identity block을 scanner가 parse해 `Docs`에 싣는 통합성 테스트가 없다. 새 테스트 필요. + +### 심볼 참조 + +- Renamed/removed symbols: none. +- New symbols expected: `authoring.ProviderIdentityWriter` 또는 동등 인터페이스, `authoring.EnsureProviderIdentityInput`, `authoring.ErrProviderIdentityWriteFailed` 또는 더 구체적 error. +- Call sites to update: `services/core/internal/scheduler/jobs.go`, `services/core/internal/scheduler/river.go`, `services/core/cmd/server/main.go`, related tests. + +### 분할 판단 + +- Split policy evaluated before file selection. +- Shared task group: `m-plane-origin-authoring-roundtrip-sync`. +- Current subtask: `04+03_identity_write`. +- Dependency: predecessor index `03` is satisfied by `agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/03+01,02_push_command/complete.log`. +- Single active plan is appropriate for this subtask because the work is one runtime handoff: authoring output identity injection before push-only. It crosses authoring/scheduler/server wiring, but the change is one dependency chain and cannot produce useful PASS evidence until writer, wiring, and scanner regression are together. + +### 범위 결정 근거 + +- Do not change authoring prompt text or add Plane/provider contract wording to prompt. +- Do not implement Plane projection, Todo move, live smoke, or Gito delivery changes here. +- Do not change DB schema unless implementation proves pre-push injection cannot satisfy S06. Existing ledger remains for idempotency. +- Do not archive Milestone or mark non-target tasks. + +### 빌드 등급 + +- `cloud-G07`: filesystem/git workspace mutation plus scheduler/server wiring can pass narrow unit tests while breaking external authoring runtime if sequenced incorrectly. + +## 구현 체크리스트 + +- [ ] [IDENTITY_WRITE-1] Core-side provider identity writer를 추가해 slot checkout의 changed Milestone 파일에 `## Provider identity` block을 결정적으로 주입하고 단위 테스트를 작성한다. +- [ ] [IDENTITY_WRITE-2] Scheduler/server wiring에서 first authoring 성공 후 push-only 전 writer를 호출하고, identity write 실패가 명시 failure metadata/slot state로 남도록 테스트한다. +- [ ] [IDENTITY_WRITE-3] Gito scanner regression과 최종 core 검증으로 identity 없는 generated Milestone이 조용히 drop되지 않음을 확인한다. 검증: `cd services/core && go test -count=1 ./internal/authoring/... ./internal/scheduler/... ./internal/gitosync/...` 및 `cd services/core && go test -count=1 ./...`. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 의존 관계 및 구현 순서 + +`04+03_identity_write`는 predecessor `03+01,02_push_command`의 `complete.log`가 있어야 시작한다. 해당 archive complete log는 확인 완료다. 구현 순서는 writer -> scheduler/server wiring -> scanner regression 순서다. + +### [IDENTITY_WRITE-1] Add Core Identity Writer + +문제: `BuildAuthoringGenerateInputStrict`는 provider/work item identity를 `WorkspaceMetadata`에 보존하지만, IOP `/v1/responses` wire metadata는 workspace path만 보내고 Milestone 파일에는 identity block을 쓰지 않는다. `services/core/internal/scheduler/jobs.go:200` 이후 first authoring call이 끝나도 slot checkout 파일을 보강하는 단계가 없다. + +Before: + +```go +// services/core/internal/scheduler/jobs.go:200 +generated, err := w.Model.Generate(ctx, generateInput) +if err != nil { + return nil, "", err +} + +// Push-only second stage... +pushInput, _, pushInputErr := authoring.BuildAuthoringPushInputStrict(task) +``` + +해결 방법: `services/core/internal/authoring/identity_write.go`를 추가한다. `EnsureProviderIdentityInput{WorkspacePath, SourceBranch, Identity}`를 받아 changed milestone paths를 찾고, 각 Markdown에 `roadmapsync.ParseMilestoneIdentity`가 성공하면 그대로 둔다. `ErrNoProviderIdentity`이면 파일 끝에 표준 block을 append한다. path가 없거나 identity가 불완전하면 typed error를 반환한다. + +After sketch: + +```go +type IdentityWriter interface { + EnsureProviderIdentity(ctx context.Context, in EnsureProviderIdentityInput) (EnsureProviderIdentityResult, error) +} + +type EnsureProviderIdentityInput struct { + WorkspacePath string + Identity roadmapsync.Identity +} +``` + +수정 파일 및 체크리스트: + +- [ ] `services/core/internal/authoring/identity_write.go` 추가. +- [ ] `services/core/internal/authoring/identity_write_test.go` 추가. +- [ ] `roadmapsync.ParseMilestoneIdentity`를 재사용하고 ad hoc parser를 새로 만들지 않는다. +- [ ] identity block 필드는 provider, tenant, project, work item id, milestone id/roadmap item id 중 가능한 값을 deterministic order로 쓴다. + +테스트 작성: `TestEnsureProviderIdentityInjectsMissingBlock`, `TestEnsureProviderIdentityKeepsExistingBlock`, `TestEnsureProviderIdentityFailsWhenNoMilestoneChanged`, `TestEnsureProviderIdentityRejectsMissingWorkItemID`. + +중간 검증: + +```bash +cd services/core && go test -count=1 ./internal/authoring/... +``` + +### [IDENTITY_WRITE-2] Wire Before Push + +문제: `TaskWorker.runTask`는 first authoring success 뒤 바로 push-only request를 보낸다. identity write 실패가 push failure와 구분되는 metadata로 남지 않는다. + +Before: + +```go +// services/core/internal/scheduler/jobs.go:205 +// Push-only second stage: send the authored content to the workspace +// agent so it can push to the remote develop branch before we gate on +// the develop match confirmation. +pushInput, _, pushInputErr := authoring.BuildAuthoringPushInputStrict(task) +``` + +해결 방법: `TaskWorker`에 `IdentityWriter authoring.IdentityWriter` 필드를 추가하고 `scheduler.New`와 `cmd/server/main.go`에서 production writer를 주입한다. `generated` 성공 후 `pushInput` 생성 전 writer를 호출한다. writer error는 `authoring_failure_category=identity_missing` 또는 `identity_write_failed`로 분류하고 slot은 `error`로 수렴시킨다. nil writer는 명시적으로 error로 처리하거나 production에서 항상 주입하되 tests에서 fake/noop을 넣는다. 선택은 구현 중 하나로 고정하고 test로 보장한다. + +After sketch: + +```go +writeResult, err := w.IdentityWriter.EnsureProviderIdentity(ctx, authoring.EnsureProviderIdentityInput{ + WorkspacePath: generateInput.WorkspaceMetadata.Path, + Identity: identityFromWorkspaceMetadata(generateInput.WorkspaceMetadata), +}) +if err != nil { + return nil, "", authoringIdentityError{err: err} +} +``` + +수정 파일 및 체크리스트: + +- [ ] `services/core/internal/scheduler/jobs.go`에 writer call과 error wrapper 추가. +- [ ] `services/core/internal/scheduler/river.go` 생성자 인자와 `TaskWorker` wiring 갱신. +- [ ] `services/core/cmd/server/main.go`에서 production writer 생성 후 scheduler에 전달. +- [ ] `services/core/internal/authoring/result.go`에 identity write failure category를 추가하거나 scheduler failure mapping에서 명시 category를 채운다. +- [ ] 기존 scheduler.New 호출 테스트를 모두 갱신한다. + +테스트 작성: `TestWorkAuthoringWritesIdentityBeforePush`, `TestWorkAuthoringIdentityWriteFailureMarksIdentityMissing`, `TestSchedulerRegistersTaskWorkerWithIdentityWriter`. + +중간 검증: + +```bash +cd services/core && go test -count=1 ./internal/scheduler/... ./cmd/server/... +``` + +### [IDENTITY_WRITE-3] Scanner Regression And Final Verification + +문제: `services/core/internal/gitosync/scanner.go:139`에서 provider identity parse 실패 시 `Docs`에 싣지 않고 continue한다. `services/core/internal/gitosync/bridge.go:119`는 `out.Docs`만 enqueue하고 `:127`에서 revision을 processed로 표시한다. writer가 없거나 실패했는데 push되면 projection이 조용히 사라질 수 있다. + +Before: + +```go +// services/core/internal/gitosync/scanner.go:139 +identity, err := roadmapsync.ParseMilestoneIdentity(path, markdown) +if err != nil { + // No provider identity block... + continue +} +``` + +해결 방법: writer unit test fixture를 scanner test와 연결해, identity 없는 authored Markdown이 writer 이후 scanner에서 `ScannedMilestones`와 `Docs`로 들어오는 regression을 추가한다. bridge 자체는 identity 없는 docs를 projection하지 않는 현 gate를 유지한다. 필요하면 `ScanOutput`에 identity miss count/path를 추가해 log만 남기되, provider side effect는 하지 않는다. + +수정 파일 및 체크리스트: + +- [ ] `services/core/internal/gitosync/scanner_test.go`에 writer output 기반 parse regression 추가 또는 equivalent fixture 추가. +- [ ] `services/core/internal/gitosync/bridge_test.go`에 no-doc revision 처리 기대를 명확히 고정한다. +- [ ] Roadmap/Spec target 완료 근거를 review stub에 남길 수 있게 검증 출력 준비. + +테스트 작성: scanner regression test required. Bridge behavior test는 변경 범위에 따라 작성 또는 기존 `TestScanDropsMilestoneWithoutProviderIdentity` 기대를 보강한다. + +중간 검증: + +```bash +cd services/core && go test -count=1 ./internal/gitosync/... ./internal/roadmapsync/... +``` + +## 수정 파일 요약 + +| 파일 | 항목 | +|------|------| +| `services/core/internal/authoring/identity_write.go` | IDENTITY_WRITE-1 | +| `services/core/internal/authoring/identity_write_test.go` | IDENTITY_WRITE-1 | +| `services/core/internal/authoring/result.go` | IDENTITY_WRITE-2 | +| `services/core/internal/authoring/result_test.go` | IDENTITY_WRITE-2 | +| `services/core/internal/scheduler/jobs.go` | IDENTITY_WRITE-2 | +| `services/core/internal/scheduler/jobs_test.go` | IDENTITY_WRITE-2 | +| `services/core/internal/scheduler/river.go` | IDENTITY_WRITE-2 | +| `services/core/internal/scheduler/river_test.go` | IDENTITY_WRITE-2 | +| `services/core/cmd/server/main.go` | IDENTITY_WRITE-2 | +| `services/core/cmd/server/main_test.go` | IDENTITY_WRITE-2 | +| `services/core/internal/gitosync/scanner_test.go` | IDENTITY_WRITE-3 | +| `services/core/internal/gitosync/bridge_test.go` | IDENTITY_WRITE-3 | + +## 최종 검증 + +```bash +cd services/core && go test -count=1 ./internal/authoring/... ./internal/scheduler/... ./internal/gitosync/... ./internal/roadmapsync/... +cd services/core && go test -count=1 ./... +ssh toki@toki-labs.com 'zsh -lc '"'"'cd "$HOME/agent-work/nomadcode/services/core" && go test ./...'"'"'' +git diff --check +``` + +모든 명령은 exit code 0이어야 한다. Go test cache는 focused/local 검증에서는 `-count=1`로 fresh 실행하고, standard remote runner baseline은 agent-test 규칙 명령 그대로 `go test ./...`를 허용한다. + +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/04+03_identity_write/plan_cloud_G07_1.log b/agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/04+03_identity_write/plan_cloud_G07_1.log new file mode 100644 index 0000000..6345e40 --- /dev/null +++ b/agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/04+03_identity_write/plan_cloud_G07_1.log @@ -0,0 +1,244 @@ + + +# Plan - REVIEW_IDENTITY_WRITE + +## 이 파일을 읽는 구현 에이전트에게 + +`CODE_REVIEW-cloud-G07.md`의 구현 에이전트 소유 섹션 작성은 필수다. 구현 후 검증 명령을 실행하고 실제 출력, 계획 대비 변경, 주요 설계 결정을 채운 뒤 active 파일을 그대로 두고 리뷰 준비를 보고한다. 최종 판정, 로그 아카이브, `complete.log` 작성, task directory archive 이동은 code-review 스킬 전용이다. 선택된 SDD 결정이나 Milestone `구현 잠금 > 결정 필요` 항목이 구현을 막을 때만 review stub의 `사용자 리뷰 요청` 섹션을 채우고 멈춘다. 직접 사용자에게 묻거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 환경/secret/service blocker와 증거 공백은 일반 follow-up 또는 검증 결과로 기록한다. + +## 배경 + +이 루프는 `identity-write` 구현의 code-review FAIL 후속이다. 현재 구현은 identity writer가 없을 때 push-only로 진행할 수 있고, 기존 provider identity block이 현재 Plane task identity와 일치하는지 확인하지 않는다. 또한 standard remote runner 검증 증거가 현재 workspace 변경과 맞지 않아 S06 완료 증거로 신뢰할 수 없다. + +## 사용자 리뷰 요청 흐름 + +구현 중 새 결정이 필요해 보여도 채팅 질문을 하지 않는다. 선택된 SDD 결정 또는 선택된 Milestone lock 결정만 `CODE_REVIEW-cloud-G07.md`의 `사용자 리뷰 요청` 섹션에 기록하고 code-review가 검증한다. + +## Archive Evidence Snapshot + +- Current archived plan: `agent-task/m-plane-origin-authoring-roundtrip-sync/04+03_identity_write/plan_cloud_G07_0.log` +- Current archived review: `agent-task/m-plane-origin-authoring-roundtrip-sync/04+03_identity_write/code_review_cloud_G07_0.log` +- Verdict: FAIL +- Required summary: + - `services/core/internal/scheduler/jobs.go:212` silently skips provider identity writing when `IdentityWriter` or workspace metadata is nil. + - `services/core/internal/authoring/identity_write.go:79` accepts any valid existing provider identity block without comparing it to the current task identity. + - Standard remote runner evidence is stale/incomplete for this local workspace change; observed remote status did not include the new identity writer or gitosync regression files and tests were cached. +- Affected files: + - `services/core/internal/scheduler/jobs.go` + - `services/core/internal/scheduler/jobs_test.go` + - `services/core/internal/authoring/identity_write.go` + - `services/core/internal/authoring/identity_write_test.go` + - `agent-task/m-plane-origin-authoring-roundtrip-sync/04+03_identity_write/CODE_REVIEW-cloud-G07.md` +- Verification evidence from review: + - Local current workspace passed `cd services/core && go test -count=1 ./internal/authoring/... ./internal/scheduler/... ./internal/gitosync/... ./internal/roadmapsync/...`. + - Local current workspace passed `cd services/core && go test -count=1 ./...`. + - `git diff --check` passed. + - `gofmt -l` initially reported `services/core/cmd/server/main.go` and `services/core/internal/scheduler/jobs.go`; review applied `gofmt -w` and rerun reported no files. +- Roadmap/spec carryover: keep `identity-write` / S06 completion targets; PASS must prove identity-less generated Milestone is injected or fails explicitly before projection. +- Allowed narrow reread: only the two current archived logs listed above if exact prior wording is needed. + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-origin-authoring-roundtrip-sync.md` +- Task ids: + - `identity-write`: Core/runtime이 provider identity block 또는 동등한 ledger를 만들어 identity 없는 generated Milestone이 조용히 drop되지 않게 한다. +- Completion mode: check-on-pass + +## Spec Targets + +- SDD: `agent-roadmap/sdd/agent-ops-mcp-control-plane/plane-origin-authoring-roundtrip-sync/SDD.md` +- Acceptance scenarios: + - `S06`: task=`identity-write`; evidence=`identity-less generated Milestone does not silently drop; Core/runtime injects deterministic identity or fails explicitly before projection` +- Completion mode: spec-check-on-pass + +## 분석 결과 + +### 읽은 파일 + +- `agent-ops/rules/project/rules.md` +- `agent-ops/rules/private/rules.md` +- `agent-ops/rules/common/rules-roadmap.md` +- `agent-roadmap/current.md` +- `agent-ops/skills/common/router.md` +- `agent-ops/skills/common/code-review/SKILL.md` +- `agent-ops/skills/common/plan/SKILL.md` +- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` +- `agent-ops/rules/project/domain/core/rules.md` +- `agent-test/local/rules.md` +- `agent-test/local/core-smoke.md` +- `agent-roadmap/sdd/agent-ops-mcp-control-plane/plane-origin-authoring-roundtrip-sync/SDD.md` +- `agent-task/m-plane-origin-authoring-roundtrip-sync/04+03_identity_write/plan_cloud_G07_0.log` +- `agent-task/m-plane-origin-authoring-roundtrip-sync/04+03_identity_write/code_review_cloud_G07_0.log` +- `services/core/internal/authoring/identity_write.go` +- `services/core/internal/authoring/identity_write_test.go` +- `services/core/internal/authoring/request.go` +- `services/core/internal/authoring/result.go` +- `services/core/internal/scheduler/jobs.go` +- `services/core/internal/scheduler/jobs_test.go` +- `services/core/internal/scheduler/river.go` +- `services/core/internal/scheduler/river_test.go` +- `services/core/cmd/server/main.go` +- `services/core/internal/model/model.go` +- `services/core/internal/roadmapsync/identity.go` +- `services/core/internal/roadmapsync/milestone_parser.go` +- `services/core/internal/roadmapsync/develop_match.go` +- `services/core/internal/gitosync/bridge.go` +- `services/core/internal/gitosync/bridge_test.go` +- `services/core/internal/gitosync/scanner_test.go` +- `services/core/internal/roadmapsyncpipeline/service.go` +- `services/core/internal/workitempipeline/service.go` + +### 테스트 환경 규칙 + +- test_env: `local`. +- `agent-test/local/rules.md` present/read. +- Matched profile: `agent-test/local/core-smoke.md` present/read. +- Applied commands: core 변경은 최소 `cd services/core && go test ./...`; focused 변경은 `-count=1` fresh test를 사용한다. Standard remote runner는 `$HOME/agent-work/nomadcode`이며, runner evidence가 필요하면 현재 변경 범위와 같은 상태로 맞춘 뒤 검증한다. +- Current review evidence: local fresh focused/full Go tests passed. Remote runner evidence was not trustworthy because its worktree did not match the local change set and test output was cached. + +### 테스트 커버리지 공백 + +- Identity writer missing dependency: 기존 테스트들이 nil writer authoring success를 허용한다. 누락 시 push 전 explicit failure regression이 필요하다. +- Existing identity mismatch: existing provider identity block이 현재 task identity와 다른 경우의 regression이 없다. +- Remote runner evidence: current workspace와 같은 변경 상태에서 실행했다는 trace가 없다. + +### 심볼 참조 + +- Renamed/removed symbols: none. +- New/focused symbols: `authoring.IdentityWriter`, `authoring.EnsureProviderIdentityInput`, `scheduler.TaskWorker.IdentityWriter`, `authoringIdentityError`. +- Call sites to inspect/update: `services/core/internal/scheduler/jobs.go`, `services/core/internal/scheduler/jobs_test.go`, `services/core/internal/scheduler/river.go`, `services/core/cmd/server/main.go`. + +### 분할 판단 + +- Split policy evaluated. This is a follow-up inside existing split subtask `04+03_identity_write`. +- The fix is one runtime contract closure: identity writer dependency enforcement, identity mismatch guard, and evidence recollection. Splitting would create artificial coordination because PASS evidence must show the whole pre-push identity gate. +- Predecessor `03` remains satisfied by the prior archive evidence already cited in plan 0. + +### 범위 결정 근거 + +- Do not change Plane projection side effects, Gito bridge queue semantics, DB schema, authoring prompt text, or roadmap state. +- Do not add live Plane smoke or full-cycle S14 evidence here. +- Do not mark roadmap tasks; code-review PASS event owns only task completion metadata. + +### 빌드 등급 + +- `cloud-G07`: the fix is bounded but crosses scheduler runtime sequencing, provider identity correctness, and remote-runner evidence trust. + +## 구현 체크리스트 + +- [ ] [REVIEW_IDENTITY_WRITE-1] Plane-origin authoring task에서 identity writer/workspace metadata 누락이 push-only 전 명시 failure로 수렴하게 하고 regression tests를 갱신한다. +- [ ] [REVIEW_IDENTITY_WRITE-2] `LocalIdentityWriter`가 existing provider identity block을 현재 task identity와 비교해 mismatch를 교정하거나 명시 failure로 중단하게 하고 regression test를 추가한다. +- [ ] [REVIEW_IDENTITY_WRITE-3] 현재 변경과 같은 workspace 상태에서 focused/full/remote runner 검증 증거를 다시 수집한다. 검증: `gofmt -l ...`, `cd services/core && go test -count=1 ./internal/authoring/... ./internal/scheduler/... ./internal/gitosync/... ./internal/roadmapsync/...`, `cd services/core && go test -count=1 ./...`, standard remote runner status+test, `git diff --check`. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 의존 관계 및 구현 순서 + +1. Writer dependency enforcement. +2. Existing identity mismatch guard. +3. Verification evidence recollection. + +### [REVIEW_IDENTITY_WRITE-1] Require Identity Writer Before Push + +문제: `services/core/internal/scheduler/jobs.go:212`의 조건문은 writer 또는 workspace metadata가 nil이면 identity write를 건너뛰고 `BuildAuthoringPushInputStrict`로 진행한다. + +Before: + +```go +if w.IdentityWriter != nil && generateInput.WorkspaceMetadata != nil { + _, err = w.IdentityWriter.EnsureProviderIdentity(ctx, ...) + if err != nil { + return nil, "", authoringIdentityError{err: err} + } +} +``` + +해결 방법: Plane-origin authoring task에서는 writer와 workspace metadata를 필수로 둔다. nil이면 push-only 전에 typed error를 반환해 `markFailedWithTask`가 `identity_write_failed` 또는 `identity_missing` metadata와 slot `error`를 남기게 한다. 기존 authoring success tests에는 fake writer를 명시 주입한다. + +수정 파일 및 체크리스트: + +- [ ] `services/core/internal/scheduler/jobs.go`에서 writer/workspace metadata nil skip을 제거하고 explicit failure로 변경한다. +- [ ] `services/core/internal/scheduler/jobs_test.go`의 authoring success tests에 fake writer를 주입한다. +- [ ] `TestRunTaskAuthoringFailsWhenIdentityWriterMissing` 또는 동등 regression을 추가한다. +- [ ] `TestWorkAuthoringWritesIdentityBeforePush`가 push-only 전 writer 호출 순서도 검증하도록 필요 시 보강한다. + +테스트 작성: required. Missing writer regression과 기존 success call-site updates. + +중간 검증: + +```bash +cd services/core && go test -count=1 ./internal/scheduler/... +``` + +### [REVIEW_IDENTITY_WRITE-2] Guard Existing Identity Mismatch + +문제: `services/core/internal/authoring/identity_write.go:79`는 `roadmapsync.ParseMilestoneIdentity`가 성공하면 현재 task identity와 비교하지 않고 `continue`한다. `gitosync.Bridge`는 scanned identity를 그대로 fetch/enqueue 기준으로 쓰므로 wrong but valid block이 projection target을 바꿀 수 있다. + +Before: + +```go +_, err = roadmapsync.ParseMilestoneIdentity(f, content) +if err == nil { + // Already has valid identity block + continue +} +``` + +해결 방법: parsed identity를 normalize하고 expected identity와 provider, tenant, project, work item id 기준으로 비교한다. 일치하면 유지한다. 불일치하면 deterministic Core block으로 교정하거나, 교정이 애매하면 `authoringIdentityError`로 감싸질 수 있는 explicit error를 반환한다. 이번 follow-up 안에서 한 방식을 선택하고 테스트로 고정한다. + +수정 파일 및 체크리스트: + +- [ ] `services/core/internal/authoring/identity_write.go`에 expected identity comparison helper를 추가하거나 inline 비교를 구현한다. +- [ ] `services/core/internal/authoring/identity_write_test.go`에 mismatched existing block regression을 추가한다. +- [ ] 필요하면 invalid existing block 처리 기대를 명확히 보강한다. +- [ ] S06 근거가 "Core/runtime identity source"와 맞도록 review stub에 설계 결정을 기록한다. + +테스트 작성: required. `TestEnsureProviderIdentityRejectsMismatchedExistingBlock` 또는 `TestEnsureProviderIdentityRewritesMismatchedExistingBlock` 중 실제 선택한 동작 이름으로 작성한다. + +중간 검증: + +```bash +cd services/core && go test -count=1 ./internal/authoring/... ./internal/gitosync/... ./internal/roadmapsync/... +``` + +### [REVIEW_IDENTITY_WRITE-3] Recollect Trustworthy Verification + +문제: `code_review_cloud_G07_0.log`의 remote runner output은 current local change set과 다른 상태에서 나온 cached test evidence다. + +해결 방법: standard remote runner를 현재 변경 범위와 같은 상태로 맞춘 뒤 status와 test output을 기록한다. 원격 동기화가 불가능하면 사유를 명확히 적고, fresh local `-count=1` 검증을 대체 evidence로 남긴다. verification output은 요약하지 말고 stdout/stderr를 붙인다. + +수정 파일 및 체크리스트: + +- [ ] `agent-task/m-plane-origin-authoring-roundtrip-sync/04+03_identity_write/CODE_REVIEW-cloud-G07.md`의 `검증 결과`에 fresh local focused/full 결과를 기록한다. +- [ ] 같은 섹션에 standard remote runner `git status --short`와 `go test ./...` 결과를 현재 변경 상태 기준으로 기록하거나, 불가능한 사유를 기록한다. +- [ ] `git diff --check`와 `gofmt -l` 결과를 기록한다. + +테스트 작성: code test 없음. Verification evidence recovery task. + +중간 검증: + +```bash +gofmt -l services/core/cmd/server/main.go services/core/internal/authoring/request.go services/core/internal/authoring/request_test.go services/core/internal/authoring/result.go services/core/internal/authoring/result_test.go services/core/internal/authoring/identity_write.go services/core/internal/authoring/identity_write_test.go services/core/internal/gitosync/bridge_test.go services/core/internal/gitosync/scanner_test.go services/core/internal/scheduler/jobs.go services/core/internal/scheduler/jobs_test.go services/core/internal/scheduler/river.go services/core/internal/scheduler/river_test.go +git diff --check +``` + +## 수정 파일 요약 + +| 파일 | 항목 | +|------|------| +| `services/core/internal/scheduler/jobs.go` | REVIEW_IDENTITY_WRITE-1 | +| `services/core/internal/scheduler/jobs_test.go` | REVIEW_IDENTITY_WRITE-1 | +| `services/core/internal/authoring/identity_write.go` | REVIEW_IDENTITY_WRITE-2 | +| `services/core/internal/authoring/identity_write_test.go` | REVIEW_IDENTITY_WRITE-2 | +| `agent-task/m-plane-origin-authoring-roundtrip-sync/04+03_identity_write/CODE_REVIEW-cloud-G07.md` | REVIEW_IDENTITY_WRITE-3 | + +## 최종 검증 + +```bash +gofmt -l services/core/cmd/server/main.go services/core/internal/authoring/request.go services/core/internal/authoring/request_test.go services/core/internal/authoring/result.go services/core/internal/authoring/result_test.go services/core/internal/authoring/identity_write.go services/core/internal/authoring/identity_write_test.go services/core/internal/gitosync/bridge_test.go services/core/internal/gitosync/scanner_test.go services/core/internal/scheduler/jobs.go services/core/internal/scheduler/jobs_test.go services/core/internal/scheduler/river.go services/core/internal/scheduler/river_test.go +cd services/core && go test -count=1 ./internal/authoring/... ./internal/scheduler/... ./internal/gitosync/... ./internal/roadmapsync/... +cd services/core && go test -count=1 ./... +ssh toki@toki-labs.com 'zsh -lc '"'"'cd "$HOME/agent-work/nomadcode" && git status --short && cd services/core && go test ./...'"'"'' +git diff --check +``` + +`gofmt -l`과 `git diff --check`는 empty output이어야 한다. Remote runner는 현재 변경 범위와 같은 상태임을 `git status --short` 출력으로 증명해야 한다. 모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/services/core/cmd/server/main.go b/services/core/cmd/server/main.go index 1223f9f..566b7f0 100644 --- a/services/core/cmd/server/main.go +++ b/services/core/cmd/server/main.go @@ -16,6 +16,7 @@ import ( modelopenai "github.com/nomadcode/nomadcode-core/internal/adapters/openai" "github.com/nomadcode/nomadcode-core/internal/adapters/plane" "github.com/nomadcode/nomadcode-core/internal/agent" + "github.com/nomadcode/nomadcode-core/internal/authoring" "github.com/nomadcode/nomadcode-core/internal/config" "github.com/nomadcode/nomadcode-core/internal/db" "github.com/nomadcode/nomadcode-core/internal/gitoevents" @@ -105,7 +106,8 @@ func run(logger *slog.Logger) error { workflowService := workflow.NewService(store, nil, logger) - taskScheduler, err := scheduler.New(pool, lifecycle, notificationService, agentClient, modelClient, creationSync, workflowService, store, time.Duration(cfg.WorkflowTaskTimeoutSec)*time.Second, logger) + identityWriter := authoring.NewLocalIdentityWriter() + taskScheduler, err := scheduler.New(pool, lifecycle, notificationService, agentClient, modelClient, identityWriter, creationSync, workflowService, store, time.Duration(cfg.WorkflowTaskTimeoutSec)*time.Second, logger) if err != nil { return err } diff --git a/services/core/internal/authoring/identity_write.go b/services/core/internal/authoring/identity_write.go new file mode 100644 index 0000000..eefb416 --- /dev/null +++ b/services/core/internal/authoring/identity_write.go @@ -0,0 +1,134 @@ +package authoring + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/nomadcode/nomadcode-core/internal/roadmapsync" +) + +var ErrNoMilestonesChanged = errors.New("no milestone files changed") +var ErrMismatchedProviderIdentity = errors.New("mismatched provider identity block") + +type EnsureProviderIdentityInput struct { + WorkspacePath string + Identity roadmapsync.Identity +} + +type EnsureProviderIdentityResult struct { + InjectedPaths []string +} + +type IdentityWriter interface { + EnsureProviderIdentity(ctx context.Context, in EnsureProviderIdentityInput) (EnsureProviderIdentityResult, error) +} + +type LocalIdentityWriter struct{} + +func NewLocalIdentityWriter() *LocalIdentityWriter { + return &LocalIdentityWriter{} +} + +func (w *LocalIdentityWriter) EnsureProviderIdentity(ctx context.Context, in EnsureProviderIdentityInput) (EnsureProviderIdentityResult, error) { + if strings.TrimSpace(string(in.Identity.Provider)) == "" || strings.TrimSpace(in.Identity.WorkItemID) == "" { + return EnsureProviderIdentityResult{}, roadmapsync.ErrInvalidIdentity + } + + tempIdent := in.Identity + if tempIdent.RoadmapMilestonePath == "" { + tempIdent.RoadmapMilestonePath = "dummy.md" + } + expected, err := tempIdent.Normalize() + if err != nil { + return EnsureProviderIdentityResult{}, roadmapsync.ErrInvalidIdentity + } + + cmd := exec.CommandContext(ctx, "git", "status", "--porcelain", "-uall") + cmd.Dir = in.WorkspacePath + out, err := cmd.Output() + if err != nil { + return EnsureProviderIdentityResult{}, fmt.Errorf("git status failed: %w", err) + } + + lines := strings.Split(string(out), "\n") + var milestoneFiles []string + for _, line := range lines { + if len(line) < 4 { + continue + } + // Extract filepath (excluding status prefix and quotes) + filePath := line[3:] + filePath = strings.Trim(filePath, "\"") + filePath = strings.TrimSpace(filePath) + + if strings.HasPrefix(filePath, "agent-roadmap/phase/") && + strings.Contains(filePath, "/milestones/") && + strings.HasSuffix(filePath, ".md") { + milestoneFiles = append(milestoneFiles, filePath) + } + } + + if len(milestoneFiles) == 0 { + return EnsureProviderIdentityResult{}, ErrNoMilestonesChanged + } + + var injected []string + for _, f := range milestoneFiles { + fullPath := filepath.Join(in.WorkspacePath, f) + contentBytes, err := os.ReadFile(fullPath) + if err != nil { + return EnsureProviderIdentityResult{}, fmt.Errorf("read file %s failed: %w", f, err) + } + content := string(contentBytes) + + existing, err := roadmapsync.ParseMilestoneIdentity(f, content) + if err == nil { + if existing.Provider != expected.Provider || + existing.Tenant != expected.Tenant || + existing.Project != expected.Project || + existing.WorkItemID != expected.WorkItemID { + return EnsureProviderIdentityResult{}, ErrMismatchedProviderIdentity + } + // Already has valid and matching identity block + continue + } + + if errors.Is(err, roadmapsync.ErrNoProviderIdentity) { + var sb strings.Builder + sb.WriteString("\n\n## Provider identity\n") + if in.Identity.Provider != "" { + sb.WriteString(fmt.Sprintf("- provider: %s\n", in.Identity.Provider)) + } + if in.Identity.Tenant != "" { + sb.WriteString(fmt.Sprintf("- tenant: %s\n", in.Identity.Tenant)) + } + if in.Identity.Project != "" { + sb.WriteString(fmt.Sprintf("- project: %s\n", in.Identity.Project)) + } + if in.Identity.WorkItemID != "" { + sb.WriteString(fmt.Sprintf("- work item id: %s\n", in.Identity.WorkItemID)) + } + if in.Identity.RoadmapItemID != "" { + sb.WriteString(fmt.Sprintf("- milestone id: %s\n", in.Identity.RoadmapItemID)) + } + + newContent := content + sb.String() + err = os.WriteFile(fullPath, []byte(newContent), 0644) + if err != nil { + return EnsureProviderIdentityResult{}, fmt.Errorf("write file %s failed: %w", f, err) + } + injected = append(injected, f) + } else { + return EnsureProviderIdentityResult{}, fmt.Errorf("parse milestone identity %s failed: %w", f, err) + } + } + + return EnsureProviderIdentityResult{ + InjectedPaths: injected, + }, nil +} diff --git a/services/core/internal/authoring/identity_write_test.go b/services/core/internal/authoring/identity_write_test.go new file mode 100644 index 0000000..a5ae55f --- /dev/null +++ b/services/core/internal/authoring/identity_write_test.go @@ -0,0 +1,219 @@ +package authoring + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/nomadcode/nomadcode-core/internal/roadmapsync" +) + +func setupTestGitRepo(t *testing.T) string { + t.Helper() + tmpDir, err := os.MkdirTemp("", "nomadcode-test-repo-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + + // git init + cmd := exec.Command("git", "init") + cmd.Dir = tmpDir + if err := cmd.Run(); err != nil { + t.Fatalf("failed to git init: %v", err) + } + + // git config user.name and user.email (just in case) + cmd = exec.Command("git", "config", "user.name", "test") + cmd.Dir = tmpDir + _ = cmd.Run() + cmd = exec.Command("git", "config", "user.email", "test@test.com") + cmd.Dir = tmpDir + _ = cmd.Run() + + return tmpDir +} + +func TestEnsureProviderIdentityInjectsMissingBlock(t *testing.T) { + tmpDir := setupTestGitRepo(t) + defer os.RemoveAll(tmpDir) + + milestoneDir := filepath.Join(tmpDir, "agent-roadmap", "phase", "test-phase", "milestones") + if err := os.MkdirAll(milestoneDir, 0755); err != nil { + t.Fatalf("failed to create milestone dir: %v", err) + } + + milestonePath := filepath.Join(milestoneDir, "test-milestone.md") + content := "# Test Milestone\nSome description here.\n" + if err := os.WriteFile(milestonePath, []byte(content), 0644); err != nil { + t.Fatalf("failed to write milestone file: %v", err) + } + + writer := NewLocalIdentityWriter() + ident := roadmapsync.Identity{ + Provider: "plane", + WorkItemID: "work-123", + Tenant: "tenant-abc", + Project: "project-xyz", + RoadmapItemID: "roadmap-999", + } + + res, err := writer.EnsureProviderIdentity(context.Background(), EnsureProviderIdentityInput{ + WorkspacePath: tmpDir, + Identity: ident, + }) + if err != nil { + t.Fatalf("EnsureProviderIdentity failed: %v", err) + } + + if len(res.InjectedPaths) != 1 || res.InjectedPaths[0] != "agent-roadmap/phase/test-phase/milestones/test-milestone.md" { + t.Errorf("unexpected injected paths: got %v", res.InjectedPaths) + } + + // Read and verify injected content + newContentBytes, err := os.ReadFile(milestonePath) + if err != nil { + t.Fatalf("failed to read file: %v", err) + } + newContent := string(newContentBytes) + + parsed, err := roadmapsync.ParseMilestoneIdentity("agent-roadmap/phase/test-phase/milestones/test-milestone.md", newContent) + if err != nil { + t.Fatalf("failed to parse injected identity: %v", err) + } + + if parsed.Provider != "plane" || parsed.WorkItemID != "work-123" || parsed.Tenant != "tenant-abc" || parsed.Project != "project-xyz" || parsed.RoadmapItemID != "roadmap-999" { + t.Errorf("unexpected parsed identity: %+v", parsed) + } +} + +func TestEnsureProviderIdentityKeepsExistingBlock(t *testing.T) { + tmpDir := setupTestGitRepo(t) + defer os.RemoveAll(tmpDir) + + milestoneDir := filepath.Join(tmpDir, "agent-roadmap", "phase", "test-phase", "milestones") + if err := os.MkdirAll(milestoneDir, 0755); err != nil { + t.Fatalf("failed to create milestone dir: %v", err) + } + + milestonePath := filepath.Join(milestoneDir, "test-milestone.md") + content := `# Test Milestone +Some description here. + +## Provider identity +- provider: plane +- tenant: tenant-abc +- project: project-xyz +- work item id: work-123 +- milestone id: roadmap-999 +` + if err := os.WriteFile(milestonePath, []byte(content), 0644); err != nil { + t.Fatalf("failed to write milestone file: %v", err) + } + + writer := NewLocalIdentityWriter() + ident := roadmapsync.Identity{ + Provider: "plane", + WorkItemID: "work-123", + Tenant: "tenant-abc", + Project: "project-xyz", + RoadmapItemID: "roadmap-999", + } + + res, err := writer.EnsureProviderIdentity(context.Background(), EnsureProviderIdentityInput{ + WorkspacePath: tmpDir, + Identity: ident, + }) + if err != nil { + t.Fatalf("EnsureProviderIdentity failed: %v", err) + } + + if len(res.InjectedPaths) != 0 { + t.Errorf("expected no injected paths, got %v", res.InjectedPaths) + } +} + +func TestEnsureProviderIdentityFailsWhenNoMilestoneChanged(t *testing.T) { + tmpDir := setupTestGitRepo(t) + defer os.RemoveAll(tmpDir) + + // No milestone files are created, or they are clean (not changed/untracked) + writer := NewLocalIdentityWriter() + ident := roadmapsync.Identity{ + Provider: "plane", + WorkItemID: "work-123", + } + + _, err := writer.EnsureProviderIdentity(context.Background(), EnsureProviderIdentityInput{ + WorkspacePath: tmpDir, + Identity: ident, + }) + if !errors.Is(err, ErrNoMilestonesChanged) { + t.Errorf("expected ErrNoMilestonesChanged, got %v", err) + } +} + +func TestEnsureProviderIdentityRejectsMissingWorkItemID(t *testing.T) { + tmpDir := setupTestGitRepo(t) + defer os.RemoveAll(tmpDir) + + writer := NewLocalIdentityWriter() + ident := roadmapsync.Identity{ + Provider: "plane", + // WorkItemID is missing + } + + _, err := writer.EnsureProviderIdentity(context.Background(), EnsureProviderIdentityInput{ + WorkspacePath: tmpDir, + Identity: ident, + }) + if !errors.Is(err, roadmapsync.ErrInvalidIdentity) { + t.Errorf("expected ErrInvalidIdentity, got %v", err) + } +} + +func TestEnsureProviderIdentityRejectsMismatchedExistingBlock(t *testing.T) { + tmpDir := setupTestGitRepo(t) + defer os.RemoveAll(tmpDir) + + milestoneDir := filepath.Join(tmpDir, "agent-roadmap", "phase", "test-phase", "milestones") + if err := os.MkdirAll(milestoneDir, 0755); err != nil { + t.Fatalf("failed to create milestone dir: %v", err) + } + + milestonePath := filepath.Join(milestoneDir, "test-milestone.md") + content := `# Test Milestone +Some description here. + +## Provider identity +- provider: plane +- tenant: tenant-abc +- project: project-xyz +- work item id: work-123 +- milestone id: roadmap-999 +` + if err := os.WriteFile(milestonePath, []byte(content), 0644); err != nil { + t.Fatalf("failed to write milestone file: %v", err) + } + + writer := NewLocalIdentityWriter() + + // Task identity has mismatched work item id + ident := roadmapsync.Identity{ + Provider: "plane", + WorkItemID: "work-456", // mismatch + Tenant: "tenant-abc", + Project: "project-xyz", + RoadmapItemID: "roadmap-999", + } + + _, err := writer.EnsureProviderIdentity(context.Background(), EnsureProviderIdentityInput{ + WorkspacePath: tmpDir, + Identity: ident, + }) + if !errors.Is(err, ErrMismatchedProviderIdentity) { + t.Errorf("expected ErrMismatchedProviderIdentity, got %v", err) + } +} diff --git a/services/core/internal/authoring/request.go b/services/core/internal/authoring/request.go index cdf9406..b288a70 100644 --- a/services/core/internal/authoring/request.go +++ b/services/core/internal/authoring/request.go @@ -2,6 +2,7 @@ package authoring import ( "encoding/json" + "errors" "strings" "github.com/nomadcode/nomadcode-core/internal/model" @@ -29,6 +30,11 @@ const defaultAuthoringInstructions = "다음 메시지를 바탕으로 마일스 // as the push-only authoring stage after the first authoring response succeeds. const PushOnlyCommand = "변경된 내용에 대해 develop 브런치에 푸시해" +// ErrProviderIdentityMissing is returned when a Plane-origin authoring task has +// checkout metadata but no provider work item id from its external ref, payload, +// or external metadata. +var ErrProviderIdentityMissing = errors.New("authoring provider identity missing") + // isAuthoringTask returns true when the task is a Plane-origin authoring task // that should be routed through the IOP workspace authoring bridge. The criteria // are: source=="plane" and task metadata contains a "checkout" key with a @@ -72,7 +78,6 @@ func getWorkItemIdentity(task storage.Task, payload pipelinePayload) workItemIde var ident workItemIdentity ident.Provider = task.Source - ident.WorkItemID = task.ID if task.ExternalProvider != nil && *task.ExternalProvider != "" { ident.Provider = *task.ExternalProvider @@ -106,6 +111,15 @@ func getWorkItemIdentity(task storage.Task, payload pipelinePayload) workItemIde if len(task.ExternalMetadata) > 0 && string(task.ExternalMetadata) != "null" && string(task.ExternalMetadata) != "{}" { var extMeta map[string]any if err := json.Unmarshal(task.ExternalMetadata, &extMeta); err == nil { + if p, ok := extMeta["provider"].(string); ok && p != "" && ident.Provider == "" { + ident.Provider = p + } + if id, ok := extMeta["id"].(string); ok && id != "" && ident.WorkItemID == "" { + ident.WorkItemID = id + } + if id, ok := extMeta["work_item_id"].(string); ok && id != "" && ident.WorkItemID == "" { + ident.WorkItemID = id + } if t, ok := extMeta["tenant"].(string); ok && t != "" && ident.Tenant == "" { ident.Tenant = t } @@ -124,15 +138,33 @@ func getWorkItemIdentity(task storage.Task, payload pipelinePayload) workItemIde return ident } +func validateWorkItemIdentity(ident workItemIdentity) error { + if strings.TrimSpace(ident.Provider) == "" || strings.TrimSpace(ident.WorkItemID) == "" { + return ErrProviderIdentityMissing + } + return nil +} + // BuildAuthoringPushInput constructs a push-only model.GenerateInput for the // second authoring stage. The workspace metadata and provider identity are // copied from the original task so the workspace agent can locate the slot and // push the authored content to the remote develop branch. Returns false when // the task does not qualify as an authoring task. func BuildAuthoringPushInput(task storage.Task) (model.GenerateInput, bool) { + input, ok, err := BuildAuthoringPushInputStrict(task) + if err != nil { + return model.GenerateInput{}, false + } + return input, ok +} + +// BuildAuthoringPushInputStrict is the error-returning form used by the +// scheduler so missing provider identity fails explicitly instead of being +// hidden behind a task id fallback. +func BuildAuthoringPushInputStrict(task storage.Task) (model.GenerateInput, bool, error) { checkout, ok := isAuthoringTask(task) if !ok { - return model.GenerateInput{}, false + return model.GenerateInput{}, false, nil } var p pipelinePayload @@ -140,6 +172,9 @@ func BuildAuthoringPushInput(task storage.Task) (model.GenerateInput, bool) { _ = json.Unmarshal(task.Payload, &p) } ident := getWorkItemIdentity(task, p) + if err := validateWorkItemIdentity(ident); err != nil { + return model.GenerateInput{}, true, err + } return model.GenerateInput{ Input: PushOnlyCommand, @@ -157,16 +192,27 @@ func BuildAuthoringPushInput(task storage.Task) (model.GenerateInput, bool) { ExternalURL: ident.ExternalURL, StateID: ident.StateID, }, - }, true + }, true, nil } // BuildAuthoringGenerateInput constructs a workspace-bound model.GenerateInput // for Plane-origin authoring tasks. Returns false when the task does not qualify // as an authoring task (non-Plane source or missing checkout metadata). func BuildAuthoringGenerateInput(task storage.Task) (model.GenerateInput, bool) { + input, ok, err := BuildAuthoringGenerateInputStrict(task) + if err != nil { + return model.GenerateInput{}, false + } + return input, ok +} + +// BuildAuthoringGenerateInputStrict is the error-returning form used by the +// scheduler so a Plane-origin task with checkout metadata but missing provider +// work item identity stops with an explicit failure. +func BuildAuthoringGenerateInputStrict(task storage.Task) (model.GenerateInput, bool, error) { checkout, ok := isAuthoringTask(task) if !ok { - return model.GenerateInput{}, false + return model.GenerateInput{}, false, nil } // input priority: prompt override > pipeline message > task title @@ -187,6 +233,9 @@ func BuildAuthoringGenerateInput(task storage.Task) (model.GenerateInput, bool) instructions = strings.TrimSpace(p.Instructions) ident := getWorkItemIdentity(task, p) + if err := validateWorkItemIdentity(ident); err != nil { + return model.GenerateInput{}, true, err + } if instructions == "" { instructions = defaultAuthoringInstructions @@ -209,5 +258,5 @@ func BuildAuthoringGenerateInput(task storage.Task) (model.GenerateInput, bool) ExternalURL: ident.ExternalURL, StateID: ident.StateID, }, - }, true + }, true, nil } diff --git a/services/core/internal/authoring/request_test.go b/services/core/internal/authoring/request_test.go index d9e39de..a98e583 100644 --- a/services/core/internal/authoring/request_test.go +++ b/services/core/internal/authoring/request_test.go @@ -2,6 +2,7 @@ package authoring import ( "encoding/json" + "errors" "strings" "testing" @@ -20,13 +21,19 @@ func checkoutMeta(slotPath, sourceBranch string) json.RawMessage { return raw } +func testStringPtr(s string) *string { + return &s +} + func TestBuildAuthoringGenerateInputPlaneOrigin(t *testing.T) { task := storage.Task{ - ID: "task-1", - Title: "Create milestone for NOMAD-42", - Source: "plane", - Payload: json.RawMessage(`{"prompt":"Author the Milestone for NOMAD-42","instructions":"Use roadmap skill"}`), - Metadata: checkoutMeta("/home/user/workspace/nomadcode/slots/000", "develop"), + ID: "task-1", + Title: "Create milestone for NOMAD-42", + Source: "plane", + Payload: json.RawMessage(`{"prompt":"Author the Milestone for NOMAD-42","instructions":"Use roadmap skill"}`), + Metadata: checkoutMeta("/home/user/workspace/nomadcode/slots/000", "develop"), + ExternalProvider: testStringPtr("plane"), + ExternalID: testStringPtr("NOMAD-42"), } input, ok := BuildAuthoringGenerateInput(task) @@ -114,11 +121,13 @@ func TestBuildAuthoringGenerateInputProviderIdentityFallsBackToWorkItemMap(t *te func TestBuildAuthoringGenerateInputFallbackInstructions(t *testing.T) { task := storage.Task{ - ID: "task-2", - Title: "My work item title", - Source: "plane", - Payload: json.RawMessage(`{}`), - Metadata: checkoutMeta("/home/user/workspace/nomadcode/slots/001", "develop"), + ID: "task-2", + Title: "My work item title", + Source: "plane", + Payload: json.RawMessage(`{}`), + Metadata: checkoutMeta("/home/user/workspace/nomadcode/slots/001", "develop"), + ExternalProvider: testStringPtr("plane"), + ExternalID: testStringPtr("NOMAD-42"), } input, ok := BuildAuthoringGenerateInput(task) @@ -236,11 +245,13 @@ func TestBuildAuthoringGenerateInputCarriesProviderIdentityContext(t *testing.T) func TestBuildAuthoringPushInputCommandText(t *testing.T) { task := storage.Task{ - ID: "task-push-1", - Title: "Author milestone", - Source: "plane", - Payload: json.RawMessage(`{}`), - Metadata: checkoutMeta("/home/user/workspace/nomadcode/slots/000", "develop"), + ID: "task-push-1", + Title: "Author milestone", + Source: "plane", + Payload: json.RawMessage(`{}`), + Metadata: checkoutMeta("/home/user/workspace/nomadcode/slots/000", "develop"), + ExternalProvider: testStringPtr("plane"), + ExternalID: testStringPtr("NOMAD-42"), } input, ok := BuildAuthoringPushInput(task) @@ -307,11 +318,13 @@ func TestBuildAuthoringPushInputNonPlaneSource(t *testing.T) { func TestBuildAuthoringGenerateInputDefaultInstructionsStayThin(t *testing.T) { task := storage.Task{ - ID: "task-2", - Title: "My work item title", - Source: "plane", - Payload: json.RawMessage(`{}`), - Metadata: checkoutMeta("/home/user/workspace/nomadcode/slots/001", "develop"), + ID: "task-2", + Title: "My work item title", + Source: "plane", + Payload: json.RawMessage(`{}`), + Metadata: checkoutMeta("/home/user/workspace/nomadcode/slots/001", "develop"), + ExternalProvider: testStringPtr("plane"), + ExternalID: testStringPtr("NOMAD-42"), } input, ok := BuildAuthoringGenerateInput(task) @@ -338,3 +351,21 @@ func TestBuildAuthoringGenerateInputDefaultInstructionsStayThin(t *testing.T) { } } } + +func TestBuildAuthoringGenerateInputStrictMissingProviderIdentity(t *testing.T) { + task := storage.Task{ + ID: "task-missing-identity", + Title: "Author milestone", + Source: "plane", + Payload: json.RawMessage(`{"prompt":"Author milestone"}`), + Metadata: checkoutMeta("/home/user/workspace/nomadcode/slots/000", "develop"), + } + + _, ok, err := BuildAuthoringGenerateInputStrict(task) + if !ok { + t.Fatal("expected task to be recognized as authoring") + } + if !errors.Is(err, ErrProviderIdentityMissing) { + t.Fatalf("expected ErrProviderIdentityMissing, got %v", err) + } +} diff --git a/services/core/internal/authoring/result.go b/services/core/internal/authoring/result.go index d0210b6..4123044 100644 --- a/services/core/internal/authoring/result.go +++ b/services/core/internal/authoring/result.go @@ -10,12 +10,14 @@ import ( // DecisionInput contains the outputs and signals from the authoring process, // used to determine the final authoring run state. type DecisionInput struct { - BridgeSuccess bool - DevelopMatch roadmapsync.ProjectionCandidate - ExitCode *int - PushFailed bool - DirtyWorkspace bool - Conflict bool + BridgeSuccess bool + DevelopMatch roadmapsync.ProjectionCandidate + ExitCode *int + PushFailed bool + DirtyWorkspace bool + Conflict bool + IdentityWriteFailed bool + IdentityMissing bool } // Decision represents the state, reason, and failure category determined by the result helper. @@ -36,6 +38,22 @@ func DecideAuthoringResult(in DecisionInput) Decision { } } + if in.IdentityWriteFailed { + return Decision{ + State: "failed", + Reason: "provider identity write failed", + FailureCategory: "identity_write_failed", + } + } + + if in.IdentityMissing { + return Decision{ + State: "failed", + Reason: "provider identity missing", + FailureCategory: "identity_missing", + } + } + if in.ExitCode != nil && *in.ExitCode != 0 { return Decision{ State: "failed", diff --git a/services/core/internal/authoring/result_test.go b/services/core/internal/authoring/result_test.go index 93e3c2d..e99e4d6 100644 --- a/services/core/internal/authoring/result_test.go +++ b/services/core/internal/authoring/result_test.go @@ -85,6 +85,30 @@ func TestDecideAuthoringResult(t *testing.T) { FailureCategory: "conflict", }, }, + { + name: "identity write failed", + input: DecisionInput{ + BridgeSuccess: true, + IdentityWriteFailed: true, + }, + expected: Decision{ + State: "failed", + Reason: "provider identity write failed", + FailureCategory: "identity_write_failed", + }, + }, + { + name: "identity missing", + input: DecisionInput{ + BridgeSuccess: true, + IdentityMissing: true, + }, + expected: Decision{ + State: "failed", + Reason: "provider identity missing", + FailureCategory: "identity_missing", + }, + }, { name: "develop match ready succeeds", input: DecisionInput{ @@ -168,6 +192,18 @@ func TestSlotStateForAuthoringOutcome(t *testing.T) { wantState: projectsync.SlotStateError, wantOK: true, }, + { + name: "identity write failure marks slot error", + decision: Decision{State: "failed", FailureCategory: "identity_write_failed"}, + wantState: projectsync.SlotStateError, + wantOK: true, + }, + { + name: "identity missing marks slot error", + decision: Decision{State: "failed", FailureCategory: "identity_missing"}, + wantState: projectsync.SlotStateError, + wantOK: true, + }, { name: "in progress is not terminal", decision: Decision{State: "in_progress"}, diff --git a/services/core/internal/gitosync/bridge_test.go b/services/core/internal/gitosync/bridge_test.go index 0ca7a4e..2c65010 100644 --- a/services/core/internal/gitosync/bridge_test.go +++ b/services/core/internal/gitosync/bridge_test.go @@ -265,3 +265,41 @@ func TestNewBridgeRequiresTodoStateID(t *testing.T) { t.Fatal("NewBridge: want error when todo state id is empty") } } + +func TestBridgeNoDocRevisionIsProcessed(t *testing.T) { + enq := &fakeEnqueuer{} + store := NewInMemoryRevisionStore() + + // ScanOutput with no docs but valid revision (simulating scanner dropping milestone without identity) + emptyDocsOutput := ScanOutput{ + Result: roadmapsync.ScanResult{ + Revision: roadmapsync.Revision{Branch: "develop", Revision: "bbbb"}, + ChangedFiles: []string{milestonePath}, + }, + Docs: nil, + } + + b, err := NewBridge(fakeScanner{out: emptyDocsOutput}, &fakeReader{}, enq, store, BridgeConfig{ + RepoID: "nomadcode", + Branch: "develop", + TodoStateID: "state-todo", + }, nil) + if err != nil { + t.Fatalf("NewBridge: %v", err) + } + + ev := newScanEvent() + if err := b.Handle(context.Background(), ev); err != nil { + t.Fatalf("Handle: %v", err) + } + + // 1. Should not enqueue any jobs + if len(enq.jobs) != 0 { + t.Fatalf("expected no jobs enqueued, got %d", len(enq.jobs)) + } + + // 2. Revision must still be marked as processed/seen + if !store.Seen(ev.RepoID, ev.Branch, ev.After) { + t.Fatal("expected revision to be marked processed even when no docs were found") + } +} diff --git a/services/core/internal/gitosync/scanner_test.go b/services/core/internal/gitosync/scanner_test.go index 687e9ae..5f79b0e 100644 --- a/services/core/internal/gitosync/scanner_test.go +++ b/services/core/internal/gitosync/scanner_test.go @@ -3,10 +3,15 @@ package gitosync import ( "context" "errors" + "os" + "os/exec" + "path/filepath" "strings" "testing" + "github.com/nomadcode/nomadcode-core/internal/authoring" "github.com/nomadcode/nomadcode-core/internal/gitoevents" + "github.com/nomadcode/nomadcode-core/internal/roadmapsync" ) const milestonePath = "agent-roadmap/phase/p1/milestones/m1.md" @@ -181,3 +186,88 @@ func TestScanUsesSingleRevisionRangeForNewBranchPush(t *testing.T) { } } } + +func TestScanRegressionWithIdentityWriter(t *testing.T) { + // 1. Create a temporary git repo to run the real LocalIdentityWriter on it. + tmpDir, err := os.MkdirTemp("", "nomadcode-scanner-regression-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // git init + cmd := exec.Command("git", "init") + cmd.Dir = tmpDir + if err := cmd.Run(); err != nil { + t.Fatalf("failed to git init: %v", err) + } + + // git config user.name and user.email (just in case) + cmd = exec.Command("git", "config", "user.name", "test") + cmd.Dir = tmpDir + _ = cmd.Run() + cmd = exec.Command("git", "config", "user.email", "test@test.com") + cmd.Dir = tmpDir + _ = cmd.Run() + + milestoneRelPath := "agent-roadmap/phase/p1/milestones/m1.md" + milestoneDir := filepath.Join(tmpDir, "agent-roadmap", "phase", "p1", "milestones") + if err := os.MkdirAll(milestoneDir, 0755); err != nil { + t.Fatalf("failed to create milestone dir: %v", err) + } + + milestoneFullPath := filepath.Join(milestoneDir, "m1.md") + // write identity-less milestone markdown + if err := os.WriteFile(milestoneFullPath, []byte(milestoneMarkdownNoIdentity), 0644); err != nil { + t.Fatalf("failed to write milestone file: %v", err) + } + + // 2. Run LocalIdentityWriter to inject identity block. + writer := authoring.NewLocalIdentityWriter() + ident := roadmapsync.Identity{ + Provider: "plane", + WorkItemID: "wi-123", + Tenant: "acme", + Project: "proj-1", + } + res, err := writer.EnsureProviderIdentity(context.Background(), authoring.EnsureProviderIdentityInput{ + WorkspacePath: tmpDir, + Identity: ident, + }) + if err != nil { + t.Fatalf("EnsureProviderIdentity failed: %v", err) + } + if len(res.InjectedPaths) != 1 || res.InjectedPaths[0] != milestoneRelPath { + t.Fatalf("unexpected injected paths: %v", res.InjectedPaths) + } + + // Read the modified markdown (which now has identity) + injectedMarkdownBytes, err := os.ReadFile(milestoneFullPath) + if err != nil { + t.Fatalf("failed to read injected file: %v", err) + } + injectedMarkdown := string(injectedMarkdownBytes) + + // 3. Mock the runner with the injected markdown as the output of `git show` + runner := newFakeRunner() + runner.responses["git rev-parse refs/remotes/origin/develop"] = "bbbb\n" + runner.responses["git diff --name-only aaaa..bbbb -- agent-roadmap/phase"] = milestoneRelPath + "\n" + runner.responses["git show bbbb:"+milestoneRelPath] = injectedMarkdown + + out, err := newTestScanner(t, runner).Scan(context.Background(), newScanEvent()) + if err != nil { + t.Fatalf("Scan regression failed: %v", err) + } + + // 4. Verify that the scanner now successfully parses it + if len(out.Result.ScannedMilestones) != 1 || out.Result.ScannedMilestones[0].Path != milestoneRelPath { + t.Fatalf("expected 1 scanned milestone, got %+v", out.Result.ScannedMilestones) + } + parsedIdent := out.Result.ScannedMilestones[0].Identity + if parsedIdent.Provider != "plane" || parsedIdent.WorkItemID != "wi-123" || parsedIdent.Tenant != "acme" || parsedIdent.Project != "proj-1" { + t.Errorf("unexpected parsed identity: %+v", parsedIdent) + } + if len(out.Docs) != 1 || out.Docs[0].Markdown != injectedMarkdown { + t.Fatalf("expected 1 doc with injected markdown, got %+v", out.Docs) + } +} diff --git a/services/core/internal/scheduler/jobs.go b/services/core/internal/scheduler/jobs.go index cd968e9..8b4e24b 100644 --- a/services/core/internal/scheduler/jobs.go +++ b/services/core/internal/scheduler/jobs.go @@ -16,13 +16,12 @@ import ( "github.com/nomadcode/nomadcode-core/internal/model" "github.com/nomadcode/nomadcode-core/internal/notification" "github.com/nomadcode/nomadcode-core/internal/projectsync" + "github.com/nomadcode/nomadcode-core/internal/roadmapsync" "github.com/nomadcode/nomadcode-core/internal/storage" "github.com/nomadcode/nomadcode-core/internal/workflow" + "github.com/nomadcode/nomadcode-core/internal/workitem" ) -// authoringPushError wraps a push-only stage failure so markFailedWithTask can -// distinguish it from a first-stage bridge failure and record push_failed instead -// of bridge_failed. type authoringPushError struct { err error } @@ -30,6 +29,13 @@ type authoringPushError struct { func (e authoringPushError) Error() string { return e.err.Error() } func (e authoringPushError) Unwrap() error { return e.err } +type authoringIdentityError struct { + err error +} + +func (e authoringIdentityError) Error() string { return e.err.Error() } +func (e authoringIdentityError) Unwrap() error { return e.err } + type TaskJobArgs struct { TaskID string `json:"task_id"` } @@ -56,13 +62,14 @@ type TaskLifecycle interface { type TaskWorker struct { river.WorkerDefaults[TaskJobArgs] - Lifecycle TaskLifecycle - Notifications *notification.Service - Agent agent.Client - Model model.Client - SlotUpdater WorkspaceSlotStateUpdater - RunTimeout time.Duration - Logger *slog.Logger + Lifecycle TaskLifecycle + Notifications *notification.Service + Agent agent.Client + Model model.Client + SlotUpdater WorkspaceSlotStateUpdater + RunTimeout time.Duration + Logger *slog.Logger + IdentityWriter authoring.IdentityWriter } func (w *TaskWorker) Work(ctx context.Context, job *river.Job[TaskJobArgs]) error { @@ -174,8 +181,11 @@ func (w *TaskWorker) runTask(ctx context.Context, task storage.Task) (json.RawMe // Plane-origin authoring tasks always route through the IOP OpenAI-compatible // HTTP bridge even when an A2A agent client is configured, because IOP runs // workspace-bound agent authoring rather than a generic A2A task. - generateInput, isAuthoring := authoring.BuildAuthoringGenerateInput(task) + generateInput, isAuthoring, authoringInputErr := authoring.BuildAuthoringGenerateInputStrict(task) if isAuthoring { + if authoringInputErr != nil { + return nil, "", authoringInputErr + } if w.Model == nil { return nil, "", fmt.Errorf("model client is required for authoring tasks") } @@ -199,10 +209,33 @@ func (w *TaskWorker) runTask(ctx context.Context, task storage.Task) (json.RawMe return nil, "", err } + if w.IdentityWriter == nil { + return nil, "", authoringIdentityError{err: errors.New("identity writer is required for authoring tasks")} + } + if generateInput.WorkspaceMetadata == nil { + return nil, "", authoringIdentityError{err: errors.New("workspace metadata is required for authoring tasks")} + } + + _, err = w.IdentityWriter.EnsureProviderIdentity(ctx, authoring.EnsureProviderIdentityInput{ + WorkspacePath: generateInput.WorkspaceMetadata.Path, + Identity: roadmapsync.Identity{ + Provider: workitem.ProviderID(generateInput.WorkspaceMetadata.Provider), + Tenant: generateInput.WorkspaceMetadata.Tenant, + Project: generateInput.WorkspaceMetadata.Project, + WorkItemID: generateInput.WorkspaceMetadata.WorkItemID, + }, + }) + if err != nil { + return nil, "", authoringIdentityError{err: err} + } + // Push-only second stage: send the authored content to the workspace // agent so it can push to the remote develop branch before we gate on // the develop match confirmation. - pushInput, _ := authoring.BuildAuthoringPushInput(task) + pushInput, _, pushInputErr := authoring.BuildAuthoringPushInputStrict(task) + if pushInputErr != nil { + return nil, "", pushInputErr + } pushGenerated, pushErr := w.Model.Generate(ctx, pushInput) if pushErr != nil { return nil, "", authoringPushError{pushErr} @@ -212,14 +245,14 @@ func (w *TaskWorker) runTask(ctx context.Context, task storage.Task) (json.RawMe BridgeSuccess: true, }) result := map[string]any{ - "message": generated.Text, - "mode": "authoring_run", - "model": generated.Model, - "response_id": generated.ID, - "push_response_id": pushGenerated.ID, - "push_request_state": "succeeded", - "authoring_run_state": decision.State, - "authoring_run_updated_at": time.Now().UTC().Format(time.RFC3339), + "message": generated.Text, + "mode": "authoring_run", + "model": generated.Model, + "response_id": generated.ID, + "push_response_id": pushGenerated.ID, + "push_request_state": "succeeded", + "authoring_run_state": decision.State, + "authoring_run_updated_at": time.Now().UTC().Format(time.RFC3339), workflow.MetadataKeyAuthoringProgressMode: progressMode, workflow.MetadataKeyAuthoringProgressReason: progressReason, "usage": map[string]int{ @@ -482,9 +515,17 @@ func (w *TaskWorker) markFailedWithTask(taskID string, task storage.Task, err er var releaseSlot bool if _, isAuthoring := authoring.TaskCheckoutMetadata(task); isAuthoring { var pushErr authoringPushError + var identErr authoringIdentityError decisionInput := authoring.DecisionInput{BridgeSuccess: false} if errors.As(err, &pushErr) { decisionInput = authoring.DecisionInput{BridgeSuccess: true, PushFailed: true} + } else if errors.As(err, &identErr) { + innerErr := identErr.Unwrap() + if errors.Is(innerErr, roadmapsync.ErrInvalidIdentity) || errors.Is(innerErr, authoring.ErrProviderIdentityMissing) { + decisionInput = authoring.DecisionInput{BridgeSuccess: true, IdentityMissing: true} + } else { + decisionInput = authoring.DecisionInput{BridgeSuccess: true, IdentityWriteFailed: true} + } } decision := authoring.DecideAuthoringResult(decisionInput) failInput.ExtraMetadata = map[string]any{ diff --git a/services/core/internal/scheduler/jobs_test.go b/services/core/internal/scheduler/jobs_test.go index 8854434..16644ce 100644 --- a/services/core/internal/scheduler/jobs_test.go +++ b/services/core/internal/scheduler/jobs_test.go @@ -13,10 +13,12 @@ import ( "github.com/riverqueue/river" "github.com/nomadcode/nomadcode-core/internal/agent" + "github.com/nomadcode/nomadcode-core/internal/authoring" "github.com/nomadcode/nomadcode-core/internal/db" "github.com/nomadcode/nomadcode-core/internal/model" "github.com/nomadcode/nomadcode-core/internal/notification" "github.com/nomadcode/nomadcode-core/internal/projectsync" + "github.com/nomadcode/nomadcode-core/internal/roadmapsync" "github.com/nomadcode/nomadcode-core/internal/storage" "github.com/nomadcode/nomadcode-core/internal/workflow" ) @@ -479,6 +481,16 @@ func checkoutTaskMeta(slotPath, sourceBranch string) json.RawMessage { return raw } +func stringPtr(s string) *string { + return &s +} + +func withPlaneExternalRef(task storage.Task, workItemID string) storage.Task { + task.ExternalProvider = stringPtr("plane") + task.ExternalID = stringPtr(workItemID) + return task +} + func TestRunTaskRoutesPlaneOriginToAuthoringBridge(t *testing.T) { var capturedInput model.GenerateInput @@ -493,15 +505,15 @@ func TestRunTaskRoutesPlaneOriginToAuthoringBridge(t *testing.T) { }, } - worker := &TaskWorker{Model: fakeModel} + worker := &TaskWorker{Model: fakeModel, IdentityWriter: &fakeIdentityWriter{}} - task := storage.Task{ + task := withPlaneExternalRef(storage.Task{ ID: "task-plane-1", Title: "NOMAD-42: Create milestone", Source: "plane", Payload: json.RawMessage(`{"prompt":"Author milestone for NOMAD-42","instructions":"Use roadmap skill"}`), Metadata: checkoutTaskMeta("/home/user/workspace/nomadcode/slots/000", "develop"), - } + }, "NOMAD-42") raw, msg, err := worker.runTask(context.Background(), task) if err != nil { @@ -526,11 +538,37 @@ func TestRunTaskRoutesPlaneOriginToAuthoringBridge(t *testing.T) { if capturedInput.WorkspaceMetadata.Provider != "plane" { t.Errorf("unexpected provider: %q", capturedInput.WorkspaceMetadata.Provider) } - if capturedInput.WorkspaceMetadata.WorkItemID != "task-plane-1" { + if capturedInput.WorkspaceMetadata.WorkItemID != "NOMAD-42" { t.Errorf("unexpected work_item_id: %q", capturedInput.WorkspaceMetadata.WorkItemID) } } +func TestRunTaskAuthoringMissingIdentityReturnsExplicitError(t *testing.T) { + var modelCalled bool + worker := &TaskWorker{Model: fakeModelClient{ + generate: func(context.Context, model.GenerateInput) (model.GenerateResult, error) { + modelCalled = true + return model.GenerateResult{}, nil + }, + }} + + task := storage.Task{ + ID: "task-missing-identity", + Title: "NOMAD-42: Create milestone", + Source: "plane", + Payload: json.RawMessage(`{"prompt":"Author milestone for NOMAD-42"}`), + Metadata: checkoutTaskMeta("/home/user/workspace/nomadcode/slots/000", "develop"), + } + + _, _, err := worker.runTask(context.Background(), task) + if !errors.Is(err, authoring.ErrProviderIdentityMissing) { + t.Fatalf("expected ErrProviderIdentityMissing, got %v", err) + } + if modelCalled { + t.Fatal("model must not be called when provider identity is missing") + } +} + func TestRunTaskAuthoringPreemptsA2AClient(t *testing.T) { var modelCalled bool fakeModel := fakeModelClient{ @@ -544,16 +582,17 @@ func TestRunTaskAuthoringPreemptsA2AClient(t *testing.T) { } // A2A client is set — authoring task must still go to model bridge. worker := &TaskWorker{ - Model: fakeModel, - Agent: fakeAgentClient{err: errors.New("a2a should not be called")}, + Model: fakeModel, + Agent: fakeAgentClient{err: errors.New("a2a should not be called")}, + IdentityWriter: &fakeIdentityWriter{}, } - task := storage.Task{ + task := withPlaneExternalRef(storage.Task{ ID: "task-preempt", Source: "plane", Payload: json.RawMessage(`{}`), Metadata: checkoutTaskMeta("/home/user/workspace/nomadcode/slots/000", "develop"), - } + }, "NOMAD-42") raw, _, err := worker.runTask(context.Background(), task) if err != nil { @@ -610,15 +649,15 @@ func TestRunTaskAuthoringSuccessRecordsState(t *testing.T) { return model.GenerateResult{Text: "milestone authored", Model: "m"}, nil }, } - worker := &TaskWorker{Model: fakeModel} + worker := &TaskWorker{Model: fakeModel, IdentityWriter: &fakeIdentityWriter{}} - task := storage.Task{ + task := withPlaneExternalRef(storage.Task{ ID: "task-authoring-ok", Title: "Author milestone", Source: "plane", Payload: json.RawMessage(`{}`), Metadata: checkoutTaskMeta("/home/user/workspace/nomadcode/slots/000", "develop"), - } + }, "NOMAD-42") raw, _, err := worker.runTask(context.Background(), task) if err != nil { @@ -648,17 +687,18 @@ func TestWorkRecordsAuthoringInProgressBeforeExecution(t *testing.T) { } fakeLifecycle := &fakeTaskLifecycle{ - task: storage.Task{ + task: withPlaneExternalRef(storage.Task{ ID: "task-auth-ip", Source: "plane", Status: "pending", Metadata: checkoutTaskMeta("/home/user/workspace/nomadcode/slots/000", "develop"), - }, + }, "NOMAD-42"), } worker := &TaskWorker{ - Lifecycle: fakeLifecycle, - Model: fakeModel, + Lifecycle: fakeLifecycle, + Model: fakeModel, + IdentityWriter: &fakeIdentityWriter{}, } job := &river.Job[TaskJobArgs]{Args: TaskJobArgs{TaskID: "task-auth-ip"}} @@ -992,18 +1032,19 @@ func TestWorkAuthoringSkipsGenericRunTimeout(t *testing.T) { } fakeLifecycle := &fakeTaskLifecycle{ - task: storage.Task{ + task: withPlaneExternalRef(storage.Task{ ID: "task-auth-timeout", Source: "plane", Status: "pending", Metadata: checkoutTaskMeta("/home/user/workspace/nomadcode/slots/000", "develop"), - }, + }, "NOMAD-42"), } worker := &TaskWorker{ - Lifecycle: fakeLifecycle, - Model: fakeModel, - RunTimeout: 1 * time.Millisecond, + Lifecycle: fakeLifecycle, + Model: fakeModel, + RunTimeout: 1 * time.Millisecond, + IdentityWriter: &fakeIdentityWriter{}, } go func() { @@ -1028,17 +1069,18 @@ func TestWorkKeepsAuthoringTaskRunningUntilDevelopMatch(t *testing.T) { } fakeLifecycle := &fakeTaskLifecycle{ - task: storage.Task{ + task: withPlaneExternalRef(storage.Task{ ID: "task-auth-wait", Source: "plane", Status: "pending", Metadata: checkoutTaskMeta("/home/user/workspace/nomadcode/slots/000", "develop"), - }, + }, "NOMAD-42"), } worker := &TaskWorker{ - Lifecycle: fakeLifecycle, - Model: fakeModel, + Lifecycle: fakeLifecycle, + Model: fakeModel, + IdentityWriter: &fakeIdentityWriter{}, } job := &river.Job[TaskJobArgs]{Args: TaskJobArgs{TaskID: "task-auth-wait"}} @@ -1078,17 +1120,18 @@ func TestWorkAuthoringTwoCallsAndPushCommand(t *testing.T) { } fakeLifecycle := &fakeTaskLifecycle{ - task: storage.Task{ + task: withPlaneExternalRef(storage.Task{ ID: "task-auth-two-calls", Source: "plane", Status: "pending", Metadata: checkoutTaskMeta("/home/user/workspace/nomadcode/slots/000", "develop"), - }, + }, "NOMAD-42"), } worker := &TaskWorker{ - Lifecycle: fakeLifecycle, - Model: fakeModel, + Lifecycle: fakeLifecycle, + Model: fakeModel, + IdentityWriter: &fakeIdentityWriter{}, } job := &river.Job[TaskJobArgs]{Args: TaskJobArgs{TaskID: "task-auth-two-calls"}} @@ -1140,18 +1183,19 @@ func TestWorkAuthoringPushFailureMarksPushFailed(t *testing.T) { fakeSlotUpdater := &fakeWorkspaceSlotUpdater{} fakeLifecycle := &fakeTaskLifecycle{ - task: storage.Task{ + task: withPlaneExternalRef(storage.Task{ ID: "task-auth-push-fail", Source: "plane", Status: "pending", Metadata: checkoutTaskMeta("/home/user/workspace/nomadcode/slots/000", "develop"), - }, + }, "NOMAD-42"), } worker := &TaskWorker{ - Lifecycle: fakeLifecycle, - Model: fakeModel, - SlotUpdater: fakeSlotUpdater, + Lifecycle: fakeLifecycle, + Model: fakeModel, + SlotUpdater: fakeSlotUpdater, + IdentityWriter: &fakeIdentityWriter{}, } job := &river.Job[TaskJobArgs]{Args: TaskJobArgs{TaskID: "task-auth-push-fail"}} @@ -1195,13 +1239,13 @@ func TestRunTaskAuthoringRecordsPushResponse(t *testing.T) { return model.GenerateResult{ID: fmt.Sprintf("id-%d", callCount), Text: "done", Model: "m"}, nil }, } - worker := &TaskWorker{Model: fakeModel} + worker := &TaskWorker{Model: fakeModel, IdentityWriter: &fakeIdentityWriter{}} - task := storage.Task{ + task := withPlaneExternalRef(storage.Task{ ID: "task-push-resp", Source: "plane", Metadata: checkoutTaskMeta("/home/user/workspace/nomadcode/slots/000", "develop"), - } + }, "NOMAD-42") raw, _, err := worker.runTask(context.Background(), task) if err != nil { @@ -1249,15 +1293,16 @@ func TestRunTaskAuthoringProgressCallback(t *testing.T) { } worker := &TaskWorker{ - Lifecycle: fakeLifecycle, - Model: fakeModel, + Lifecycle: fakeLifecycle, + Model: fakeModel, + IdentityWriter: &fakeIdentityWriter{}, } - task := storage.Task{ + task := withPlaneExternalRef(storage.Task{ ID: "task-auth-progress", Source: "plane", Metadata: checkoutTaskMeta("/home/user/workspace/nomadcode/slots/000", "develop"), - } + }, "NOMAD-42") raw, msg, err := worker.runTask(context.Background(), task) if err != nil { @@ -1294,3 +1339,186 @@ func TestRunTaskAuthoringProgressCallback(t *testing.T) { t.Errorf("expected progress reason in raw result, got %v", result[workflow.MetadataKeyAuthoringProgressReason]) } } + +type fakeIdentityWriter struct { + calls []authoring.EnsureProviderIdentityInput + err error +} + +func (w *fakeIdentityWriter) EnsureProviderIdentity(ctx context.Context, in authoring.EnsureProviderIdentityInput) (authoring.EnsureProviderIdentityResult, error) { + w.calls = append(w.calls, in) + if w.err != nil { + return authoring.EnsureProviderIdentityResult{}, w.err + } + return authoring.EnsureProviderIdentityResult{InjectedPaths: []string{"test-milestone.md"}}, nil +} + +func TestWorkAuthoringWritesIdentityBeforePush(t *testing.T) { + fakeModel := fakeModelClient{ + generate: func(_ context.Context, _ model.GenerateInput) (model.GenerateResult, error) { + return model.GenerateResult{ID: "resp-1", Text: "done", Model: "m"}, nil + }, + } + fakeLifecycle := &fakeTaskLifecycle{ + task: withPlaneExternalRef(storage.Task{ + ID: "task-auth-ident-write", + Source: "plane", + Status: "pending", + Metadata: checkoutTaskMeta("/home/user/workspace/nomadcode/slots/000", "develop"), + }, "NOMAD-42"), + } + fakeSlotUpdater := &fakeWorkspaceSlotUpdater{} + + fakeWriter := &fakeIdentityWriter{} + + worker := &TaskWorker{ + Lifecycle: fakeLifecycle, + Model: fakeModel, + SlotUpdater: fakeSlotUpdater, + IdentityWriter: fakeWriter, + } + + job := &river.Job[TaskJobArgs]{Args: TaskJobArgs{TaskID: "task-auth-ident-write"}} + err := worker.Work(context.Background(), job) + if err != nil { + t.Fatalf("unexpected Work error: %v", err) + } + + if len(fakeWriter.calls) != 1 { + t.Fatalf("expected 1 EnsureProviderIdentity call, got %d", len(fakeWriter.calls)) + } + call := fakeWriter.calls[0] + if call.WorkspacePath != "/home/user/workspace/nomadcode/slots/000" { + t.Errorf("expected WorkspacePath=/home/user/workspace/nomadcode/slots/000, got %q", call.WorkspacePath) + } + if call.Identity.WorkItemID != "NOMAD-42" { + t.Errorf("expected WorkItemID=NOMAD-42, got %q", call.Identity.WorkItemID) + } +} + +func TestWorkAuthoringIdentityWriteFailureMarksIdentityMissing(t *testing.T) { + fakeModel := fakeModelClient{ + generate: func(_ context.Context, _ model.GenerateInput) (model.GenerateResult, error) { + return model.GenerateResult{ID: "resp-1", Text: "done", Model: "m"}, nil + }, + } + fakeLifecycle := &fakeTaskLifecycle{ + task: withPlaneExternalRef(storage.Task{ + ID: "task-auth-ident-fail", + Source: "plane", + Status: "pending", + Metadata: checkoutTaskMeta("/home/user/workspace/nomadcode/slots/000", "develop"), + }, "NOMAD-42"), + } + fakeSlotUpdater := &fakeWorkspaceSlotUpdater{} + + // Case 1: Invalid identity -> IdentityMissing + fakeWriter := &fakeIdentityWriter{err: roadmapsync.ErrInvalidIdentity} + + worker := &TaskWorker{ + Lifecycle: fakeLifecycle, + Model: fakeModel, + SlotUpdater: fakeSlotUpdater, + IdentityWriter: fakeWriter, + } + + job := &river.Job[TaskJobArgs]{Args: TaskJobArgs{TaskID: "task-auth-ident-fail"}} + err := worker.Work(context.Background(), job) + if err == nil { + t.Fatal("expected Work to fail when identity writer fails") + } + + if len(fakeLifecycle.failInputs) == 0 { + t.Fatal("expected failInputs to be recorded") + } + fi := fakeLifecycle.failInputs[0] + if fi.ExtraMetadata["authoring_failure_category"] != "identity_missing" { + t.Errorf("expected authoring_failure_category=identity_missing, got %q", fi.ExtraMetadata["authoring_failure_category"]) + } + if fi.ExtraMetadata["authoring_run_state"] != "failed" { + t.Errorf("expected authoring_run_state=failed, got %q", fi.ExtraMetadata["authoring_run_state"]) + } + if fakeSlotUpdater.calls[0].State != string(projectsync.SlotStateError) { + t.Errorf("expected slot state error, got %q", fakeSlotUpdater.calls[0].State) + } + + // Case 2: Other error -> IdentityWriteFailed + fakeLifecycle2 := &fakeTaskLifecycle{ + task: withPlaneExternalRef(storage.Task{ + ID: "task-auth-ident-fail-2", + Source: "plane", + Status: "pending", + Metadata: checkoutTaskMeta("/home/user/workspace/nomadcode/slots/000", "develop"), + }, "NOMAD-42"), + } + fakeSlotUpdater2 := &fakeWorkspaceSlotUpdater{} + fakeWriter2 := &fakeIdentityWriter{err: errors.New("write permission denied")} + worker2 := &TaskWorker{ + Lifecycle: fakeLifecycle2, + Model: fakeModel, + SlotUpdater: fakeSlotUpdater2, + IdentityWriter: fakeWriter2, + } + job2 := &river.Job[TaskJobArgs]{Args: TaskJobArgs{TaskID: "task-auth-ident-fail-2"}} + err2 := worker2.Work(context.Background(), job2) + if err2 == nil { + t.Fatal("expected Work to fail when identity writer fails with generic error") + } + if len(fakeLifecycle2.failInputs) == 0 { + t.Fatal("expected failInputs to be recorded") + } + fi2 := fakeLifecycle2.failInputs[0] + if fi2.ExtraMetadata["authoring_failure_category"] != "identity_write_failed" { + t.Errorf("expected authoring_failure_category=identity_write_failed, got %q", fi2.ExtraMetadata["authoring_failure_category"]) + } +} + +func TestSchedulerRegistersTaskWorkerWithIdentityWriter(t *testing.T) { + fakeWriter := &fakeIdentityWriter{} + client, err := New(lazyPool(t), nil, nil, nil, nil, fakeWriter, nil, nil, nil, 0, nil) + if err != nil { + t.Fatalf("New with identity writer failed: %v", err) + } + if client == nil { + t.Fatal("expected non-nil client") + } +} + +func TestRunTaskAuthoringFailsWhenIdentityWriterMissing(t *testing.T) { + fakeModel := fakeModelClient{ + generate: func(_ context.Context, _ model.GenerateInput) (model.GenerateResult, error) { + return model.GenerateResult{ID: "resp-1", Text: "done", Model: "m"}, nil + }, + } + fakeLifecycle := &fakeTaskLifecycle{ + task: withPlaneExternalRef(storage.Task{ + ID: "task-auth-missing-writer", + Source: "plane", + Status: "pending", + Metadata: checkoutTaskMeta("/home/user/workspace/nomadcode/slots/000", "develop"), + }, "NOMAD-42"), + } + fakeSlotUpdater := &fakeWorkspaceSlotUpdater{} + + // IdentityWriter is explicitly nil + worker := &TaskWorker{ + Lifecycle: fakeLifecycle, + Model: fakeModel, + SlotUpdater: fakeSlotUpdater, + IdentityWriter: nil, + } + + job := &river.Job[TaskJobArgs]{Args: TaskJobArgs{TaskID: "task-auth-missing-writer"}} + err := worker.Work(context.Background(), job) + if err == nil { + t.Fatal("expected Work to fail when identity writer is missing") + } + + if len(fakeLifecycle.failInputs) == 0 { + t.Fatal("expected failInputs to be recorded") + } + fi := fakeLifecycle.failInputs[0] + if fi.ExtraMetadata["authoring_failure_category"] != "identity_write_failed" { + t.Errorf("expected authoring_failure_category=identity_write_failed, got %q", fi.ExtraMetadata["authoring_failure_category"]) + } +} diff --git a/services/core/internal/scheduler/river.go b/services/core/internal/scheduler/river.go index 4468e07..43a30d7 100644 --- a/services/core/internal/scheduler/river.go +++ b/services/core/internal/scheduler/river.go @@ -12,6 +12,7 @@ import ( "github.com/riverqueue/river/rivermigrate" "github.com/nomadcode/nomadcode-core/internal/agent" + "github.com/nomadcode/nomadcode-core/internal/authoring" "github.com/nomadcode/nomadcode-core/internal/model" "github.com/nomadcode/nomadcode-core/internal/notification" "github.com/nomadcode/nomadcode-core/internal/workflow" @@ -27,16 +28,17 @@ type Client struct { // 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, finalizer TaskFinalizer, slotUpdater WorkspaceSlotStateUpdater, runTimeout time.Duration, logger *slog.Logger) (*Client, error) { +func New(pool *pgxpool.Pool, lifecycle TaskLifecycle, notifications *notification.Service, agentClient agent.Client, modelClient model.Client, identityWriter authoring.IdentityWriter, creationSync CreationSyncRunner, finalizer TaskFinalizer, slotUpdater WorkspaceSlotStateUpdater, runTimeout time.Duration, logger *slog.Logger) (*Client, error) { workers := river.NewWorkers() river.AddWorker(workers, &TaskWorker{ - Lifecycle: lifecycle, - Notifications: notifications, - Agent: agentClient, - Model: modelClient, - SlotUpdater: slotUpdater, - RunTimeout: runTimeout, - Logger: logger, + Lifecycle: lifecycle, + Notifications: notifications, + Agent: agentClient, + Model: modelClient, + IdentityWriter: identityWriter, + SlotUpdater: slotUpdater, + RunTimeout: runTimeout, + Logger: logger, }) if creationSync != nil { river.AddWorker(workers, &RoadmapCreationSyncWorker{ diff --git a/services/core/internal/scheduler/river_test.go b/services/core/internal/scheduler/river_test.go index 9dd4d36..ffbcfa2 100644 --- a/services/core/internal/scheduler/river_test.go +++ b/services/core/internal/scheduler/river_test.go @@ -303,7 +303,7 @@ func lazyPool(t *testing.T) *pgxpool.Pool { func TestSchedulerRegistersRoadmapSyncWorkerWhenConfigured(t *testing.T) { runner := &fakeCreationSyncRunner{} - client, err := New(lazyPool(t), nil, nil, nil, nil, runner, nil, nil, 0, nil) + client, err := New(lazyPool(t), nil, nil, nil, nil, nil, runner, nil, nil, 0, nil) if err != nil { t.Fatalf("New with creation sync runner: %v", err) } @@ -317,7 +317,7 @@ func TestSchedulerRegistersRoadmapSyncWorkerWhenConfigured(t *testing.T) { } func TestSchedulerWithoutRoadmapSyncKeepsTaskWorkerOnlyBehavior(t *testing.T) { - client, err := New(lazyPool(t), nil, nil, nil, nil, nil, nil, nil, 0, nil) + client, err := New(lazyPool(t), nil, nil, nil, nil, nil, nil, nil, nil, 0, nil) if err != nil { t.Fatalf("New without creation sync runner: %v", err) }