feat(authoring): identity write 구현 및 scheduler/river 리팩토링

- authoring: identity write 도메인 구현 (identity_write.go)
- authoring: request/result 도메인 정리 및 테스트 개선
- scheduler: job/river 구조 리팩토링 및 테스트 보완
- gitosync: bridge/scanner 테스트 개선
- roadmap: plane-origin-authoring-roundtrip-sync 마일스톤 업데이트
This commit is contained in:
toki 2026-06-20 21:09:26 +09:00
parent 13ce1ec281
commit d1699ef724
19 changed files with 2129 additions and 109 deletions

View file

@ -46,18 +46,18 @@ Plane `Backlog + AGENT assignee` 상위 티켓에서 시작한 Milestone authori
Plane-origin authoring이 짧은 사용자 입력과 workspace metadata만으로 실행되되, 장시간 작업과 push 분리를 runtime이 감당하도록 정리한다. Plane-origin authoring이 짧은 사용자 입력과 workspace metadata만으로 실행되되, 장시간 작업과 push 분리를 runtime이 감당하도록 정리한다.
- [x] [thin-prompt] 기본 authoring instruction은 `다음 메시지를 바탕으로 마일스톤을 작성해.` 수준으로 유지하고, Plane/provider 계약 세부를 사용자 프롬프트에 섞지 않는다. 검증: `services/core/internal/authoring` 테스트에서 기본 instruction이 얇고 provider 계약 문구가 포함되지 않음을 확인한다. - [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`로 조기 전환하지 않고 관찰 가능한 상태를 남긴다. - [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`로 조기 전환하지 않고 관찰 가능한 상태를 남긴다.
- [ ] [iop-progress] `/v1/responses` streaming 지원 여부를 dev IOP 계약으로 확인하고, stream 미지원 환경에서는 `authoring_run_updated_at`과 task metadata 기반 stale monitor로 대체한다. 검증: stream 미지원 응답이 silent failure가 아니라 명시 사유로 기록되고, stream 지원 시에는 진행 갱신 중단을 감지할 수 있다. - [x] [iop-progress] `/v1/responses` streaming 지원 여부를 dev IOP 계약으로 확인하고, stream 미지원 환경에서는 `authoring_run_updated_at`과 task metadata 기반 stale monitor로 대체한다. 검증: stream 미지원 응답이 silent failure가 아니라 명시 사유로 기록되고, stream 지원 시에는 진행 갱신 중단을 감지할 수 있다.
- [ ] [push-command] authoring 결과 작성 이후 Core가 workspace agent에 `변경된 내용에 대해 develop 브런치에 푸시해` push-only 요청을 별도 단계로 보낼 수 있다. 검증: 첫 authoring 응답 성공만으로 task 완료를 단정하지 않고, push-only 단계와 remote `develop` 확인이 있어야 다음 단계로 넘어간다. - [x] [push-command] authoring 결과 작성 이후 Core가 workspace agent에 `변경된 내용에 대해 develop 브런치에 푸시해` push-only 요청을 별도 단계로 보낼 수 있다. 검증: 첫 authoring 응답 성공만으로 task 완료를 단정하지 않고, push-only 단계와 remote `develop` 확인이 있어야 다음 단계로 넘어간다.
### Epic: [identity-sync] Provider identity and develop scan ### Epic: [identity-sync] Provider identity and develop scan
사용자 프롬프트에 provider 계약을 넣지 않고도 pushed Milestone이 원래 Plane 티켓과 매칭되도록 identity 연결을 결정적으로 보장한다. 사용자 프롬프트에 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로 수렴한다. - [ ] [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으로 넘어가지 않는다. - [x] [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] [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 ### 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 잠금: 해제`, 사용자 리뷰 없음 상태다. - SDD는 `agent-roadmap/sdd/agent-ops-mcp-control-plane/plane-origin-authoring-roundtrip-sync/SDD.md`에 작성되었고 `[승인됨]`, `SDD 잠금: 해제`, 사용자 리뷰 없음 상태다.
- 마일스톤 구현 잠금은 `해제` 상태이며, 활성 상태는 `[진행중]`이다. - 마일스톤 구현 잠금은 `해제` 상태이며, 활성 상태는 `[진행중]`이다.
- `thin-prompt` Task는 `services/core/internal/authoring` 테스트 근거로 완료 처리되었다. - `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 파일은 없다. - 현재 활성 `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): - 핸즈오프(2026-06-20):
- 현재 타겟은 이 마일스톤이다. 로컬 `agent-roadmap/current.md`의 활성 Milestone은 `Plane-Origin Authoring Roundtrip Sync` 하나로 정리되어 있다. - 현재 타겟은 이 마일스톤이다. 로컬 `agent-roadmap/current.md`의 활성 Milestone은 `Plane-Origin Authoring Roundtrip Sync` 하나로 정리되어 있다.
- SDD gate는 작성과 해제까지 완료되어 구현 plan 생성이 가능하다. - SDD gate는 작성과 해제까지 완료되어 구현 plan 생성이 가능하다.

View file

@ -0,0 +1,251 @@
<!-- task=m-plane-origin-authoring-roundtrip-sync/04+03_identity_write plan=0 tag=IDENTITY_WRITE -->
# 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-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. 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-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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`를 새로 작성한다.

View file

@ -0,0 +1,281 @@
<!-- task=m-plane-origin-authoring-roundtrip-sync/04+03_identity_write plan=1 tag=REVIEW_IDENTITY_WRITE -->
# 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-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. 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-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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로 이동한다.

View file

@ -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
- 없음
## 후속 작업
- 없음

View file

@ -0,0 +1,296 @@
<!-- task=m-plane-origin-authoring-roundtrip-sync/04+03_identity_write plan=0 tag=IDENTITY_WRITE -->
# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,244 @@
<!-- task=m-plane-origin-authoring-roundtrip-sync/04+03_identity_write plan=1 tag=REVIEW_IDENTITY_WRITE -->
# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -16,6 +16,7 @@ import (
modelopenai "github.com/nomadcode/nomadcode-core/internal/adapters/openai" modelopenai "github.com/nomadcode/nomadcode-core/internal/adapters/openai"
"github.com/nomadcode/nomadcode-core/internal/adapters/plane" "github.com/nomadcode/nomadcode-core/internal/adapters/plane"
"github.com/nomadcode/nomadcode-core/internal/agent" "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/config"
"github.com/nomadcode/nomadcode-core/internal/db" "github.com/nomadcode/nomadcode-core/internal/db"
"github.com/nomadcode/nomadcode-core/internal/gitoevents" "github.com/nomadcode/nomadcode-core/internal/gitoevents"
@ -105,7 +106,8 @@ func run(logger *slog.Logger) error {
workflowService := workflow.NewService(store, nil, logger) 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 { if err != nil {
return err return err
} }

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -2,6 +2,7 @@ package authoring
import ( import (
"encoding/json" "encoding/json"
"errors"
"strings" "strings"
"github.com/nomadcode/nomadcode-core/internal/model" "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. // as the push-only authoring stage after the first authoring response succeeds.
const PushOnlyCommand = "변경된 내용에 대해 develop 브런치에 푸시해" 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 // isAuthoringTask returns true when the task is a Plane-origin authoring task
// that should be routed through the IOP workspace authoring bridge. The criteria // that should be routed through the IOP workspace authoring bridge. The criteria
// are: source=="plane" and task metadata contains a "checkout" key with a // 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 var ident workItemIdentity
ident.Provider = task.Source ident.Provider = task.Source
ident.WorkItemID = task.ID
if task.ExternalProvider != nil && *task.ExternalProvider != "" { if task.ExternalProvider != nil && *task.ExternalProvider != "" {
ident.Provider = *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) != "{}" { if len(task.ExternalMetadata) > 0 && string(task.ExternalMetadata) != "null" && string(task.ExternalMetadata) != "{}" {
var extMeta map[string]any var extMeta map[string]any
if err := json.Unmarshal(task.ExternalMetadata, &extMeta); err == nil { 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 == "" { if t, ok := extMeta["tenant"].(string); ok && t != "" && ident.Tenant == "" {
ident.Tenant = t ident.Tenant = t
} }
@ -124,15 +138,33 @@ func getWorkItemIdentity(task storage.Task, payload pipelinePayload) workItemIde
return ident 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 // BuildAuthoringPushInput constructs a push-only model.GenerateInput for the
// second authoring stage. The workspace metadata and provider identity are // second authoring stage. The workspace metadata and provider identity are
// copied from the original task so the workspace agent can locate the slot and // 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 // push the authored content to the remote develop branch. Returns false when
// the task does not qualify as an authoring task. // the task does not qualify as an authoring task.
func BuildAuthoringPushInput(task storage.Task) (model.GenerateInput, bool) { 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) checkout, ok := isAuthoringTask(task)
if !ok { if !ok {
return model.GenerateInput{}, false return model.GenerateInput{}, false, nil
} }
var p pipelinePayload var p pipelinePayload
@ -140,6 +172,9 @@ func BuildAuthoringPushInput(task storage.Task) (model.GenerateInput, bool) {
_ = json.Unmarshal(task.Payload, &p) _ = json.Unmarshal(task.Payload, &p)
} }
ident := getWorkItemIdentity(task, p) ident := getWorkItemIdentity(task, p)
if err := validateWorkItemIdentity(ident); err != nil {
return model.GenerateInput{}, true, err
}
return model.GenerateInput{ return model.GenerateInput{
Input: PushOnlyCommand, Input: PushOnlyCommand,
@ -157,16 +192,27 @@ func BuildAuthoringPushInput(task storage.Task) (model.GenerateInput, bool) {
ExternalURL: ident.ExternalURL, ExternalURL: ident.ExternalURL,
StateID: ident.StateID, StateID: ident.StateID,
}, },
}, true }, true, nil
} }
// BuildAuthoringGenerateInput constructs a workspace-bound model.GenerateInput // BuildAuthoringGenerateInput constructs a workspace-bound model.GenerateInput
// for Plane-origin authoring tasks. Returns false when the task does not qualify // for Plane-origin authoring tasks. Returns false when the task does not qualify
// as an authoring task (non-Plane source or missing checkout metadata). // as an authoring task (non-Plane source or missing checkout metadata).
func BuildAuthoringGenerateInput(task storage.Task) (model.GenerateInput, bool) { 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) checkout, ok := isAuthoringTask(task)
if !ok { if !ok {
return model.GenerateInput{}, false return model.GenerateInput{}, false, nil
} }
// input priority: prompt override > pipeline message > task title // 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) instructions = strings.TrimSpace(p.Instructions)
ident := getWorkItemIdentity(task, p) ident := getWorkItemIdentity(task, p)
if err := validateWorkItemIdentity(ident); err != nil {
return model.GenerateInput{}, true, err
}
if instructions == "" { if instructions == "" {
instructions = defaultAuthoringInstructions instructions = defaultAuthoringInstructions
@ -209,5 +258,5 @@ func BuildAuthoringGenerateInput(task storage.Task) (model.GenerateInput, bool)
ExternalURL: ident.ExternalURL, ExternalURL: ident.ExternalURL,
StateID: ident.StateID, StateID: ident.StateID,
}, },
}, true }, true, nil
} }

View file

@ -2,6 +2,7 @@ package authoring
import ( import (
"encoding/json" "encoding/json"
"errors"
"strings" "strings"
"testing" "testing"
@ -20,13 +21,19 @@ func checkoutMeta(slotPath, sourceBranch string) json.RawMessage {
return raw return raw
} }
func testStringPtr(s string) *string {
return &s
}
func TestBuildAuthoringGenerateInputPlaneOrigin(t *testing.T) { func TestBuildAuthoringGenerateInputPlaneOrigin(t *testing.T) {
task := storage.Task{ task := storage.Task{
ID: "task-1", ID: "task-1",
Title: "Create milestone for NOMAD-42", Title: "Create milestone for NOMAD-42",
Source: "plane", Source: "plane",
Payload: json.RawMessage(`{"prompt":"Author the Milestone for NOMAD-42","instructions":"Use roadmap skill"}`), Payload: json.RawMessage(`{"prompt":"Author the Milestone for NOMAD-42","instructions":"Use roadmap skill"}`),
Metadata: checkoutMeta("/home/user/workspace/nomadcode/slots/000", "develop"), Metadata: checkoutMeta("/home/user/workspace/nomadcode/slots/000", "develop"),
ExternalProvider: testStringPtr("plane"),
ExternalID: testStringPtr("NOMAD-42"),
} }
input, ok := BuildAuthoringGenerateInput(task) input, ok := BuildAuthoringGenerateInput(task)
@ -114,11 +121,13 @@ func TestBuildAuthoringGenerateInputProviderIdentityFallsBackToWorkItemMap(t *te
func TestBuildAuthoringGenerateInputFallbackInstructions(t *testing.T) { func TestBuildAuthoringGenerateInputFallbackInstructions(t *testing.T) {
task := storage.Task{ task := storage.Task{
ID: "task-2", ID: "task-2",
Title: "My work item title", Title: "My work item title",
Source: "plane", Source: "plane",
Payload: json.RawMessage(`{}`), Payload: json.RawMessage(`{}`),
Metadata: checkoutMeta("/home/user/workspace/nomadcode/slots/001", "develop"), Metadata: checkoutMeta("/home/user/workspace/nomadcode/slots/001", "develop"),
ExternalProvider: testStringPtr("plane"),
ExternalID: testStringPtr("NOMAD-42"),
} }
input, ok := BuildAuthoringGenerateInput(task) input, ok := BuildAuthoringGenerateInput(task)
@ -236,11 +245,13 @@ func TestBuildAuthoringGenerateInputCarriesProviderIdentityContext(t *testing.T)
func TestBuildAuthoringPushInputCommandText(t *testing.T) { func TestBuildAuthoringPushInputCommandText(t *testing.T) {
task := storage.Task{ task := storage.Task{
ID: "task-push-1", ID: "task-push-1",
Title: "Author milestone", Title: "Author milestone",
Source: "plane", Source: "plane",
Payload: json.RawMessage(`{}`), Payload: json.RawMessage(`{}`),
Metadata: checkoutMeta("/home/user/workspace/nomadcode/slots/000", "develop"), Metadata: checkoutMeta("/home/user/workspace/nomadcode/slots/000", "develop"),
ExternalProvider: testStringPtr("plane"),
ExternalID: testStringPtr("NOMAD-42"),
} }
input, ok := BuildAuthoringPushInput(task) input, ok := BuildAuthoringPushInput(task)
@ -307,11 +318,13 @@ func TestBuildAuthoringPushInputNonPlaneSource(t *testing.T) {
func TestBuildAuthoringGenerateInputDefaultInstructionsStayThin(t *testing.T) { func TestBuildAuthoringGenerateInputDefaultInstructionsStayThin(t *testing.T) {
task := storage.Task{ task := storage.Task{
ID: "task-2", ID: "task-2",
Title: "My work item title", Title: "My work item title",
Source: "plane", Source: "plane",
Payload: json.RawMessage(`{}`), Payload: json.RawMessage(`{}`),
Metadata: checkoutMeta("/home/user/workspace/nomadcode/slots/001", "develop"), Metadata: checkoutMeta("/home/user/workspace/nomadcode/slots/001", "develop"),
ExternalProvider: testStringPtr("plane"),
ExternalID: testStringPtr("NOMAD-42"),
} }
input, ok := BuildAuthoringGenerateInput(task) 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)
}
}

View file

@ -10,12 +10,14 @@ import (
// DecisionInput contains the outputs and signals from the authoring process, // DecisionInput contains the outputs and signals from the authoring process,
// used to determine the final authoring run state. // used to determine the final authoring run state.
type DecisionInput struct { type DecisionInput struct {
BridgeSuccess bool BridgeSuccess bool
DevelopMatch roadmapsync.ProjectionCandidate DevelopMatch roadmapsync.ProjectionCandidate
ExitCode *int ExitCode *int
PushFailed bool PushFailed bool
DirtyWorkspace bool DirtyWorkspace bool
Conflict bool Conflict bool
IdentityWriteFailed bool
IdentityMissing bool
} }
// Decision represents the state, reason, and failure category determined by the result helper. // 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 { if in.ExitCode != nil && *in.ExitCode != 0 {
return Decision{ return Decision{
State: "failed", State: "failed",

View file

@ -85,6 +85,30 @@ func TestDecideAuthoringResult(t *testing.T) {
FailureCategory: "conflict", 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", name: "develop match ready succeeds",
input: DecisionInput{ input: DecisionInput{
@ -168,6 +192,18 @@ func TestSlotStateForAuthoringOutcome(t *testing.T) {
wantState: projectsync.SlotStateError, wantState: projectsync.SlotStateError,
wantOK: true, 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", name: "in progress is not terminal",
decision: Decision{State: "in_progress"}, decision: Decision{State: "in_progress"},

View file

@ -265,3 +265,41 @@ func TestNewBridgeRequiresTodoStateID(t *testing.T) {
t.Fatal("NewBridge: want error when todo state id is empty") 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")
}
}

View file

@ -3,10 +3,15 @@ package gitosync
import ( import (
"context" "context"
"errors" "errors"
"os"
"os/exec"
"path/filepath"
"strings" "strings"
"testing" "testing"
"github.com/nomadcode/nomadcode-core/internal/authoring"
"github.com/nomadcode/nomadcode-core/internal/gitoevents" "github.com/nomadcode/nomadcode-core/internal/gitoevents"
"github.com/nomadcode/nomadcode-core/internal/roadmapsync"
) )
const milestonePath = "agent-roadmap/phase/p1/milestones/m1.md" 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)
}
}

View file

@ -16,13 +16,12 @@ import (
"github.com/nomadcode/nomadcode-core/internal/model" "github.com/nomadcode/nomadcode-core/internal/model"
"github.com/nomadcode/nomadcode-core/internal/notification" "github.com/nomadcode/nomadcode-core/internal/notification"
"github.com/nomadcode/nomadcode-core/internal/projectsync" "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/storage"
"github.com/nomadcode/nomadcode-core/internal/workflow" "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 { type authoringPushError struct {
err error err error
} }
@ -30,6 +29,13 @@ type authoringPushError struct {
func (e authoringPushError) Error() string { return e.err.Error() } func (e authoringPushError) Error() string { return e.err.Error() }
func (e authoringPushError) Unwrap() error { return e.err } 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 { type TaskJobArgs struct {
TaskID string `json:"task_id"` TaskID string `json:"task_id"`
} }
@ -56,13 +62,14 @@ type TaskLifecycle interface {
type TaskWorker struct { type TaskWorker struct {
river.WorkerDefaults[TaskJobArgs] river.WorkerDefaults[TaskJobArgs]
Lifecycle TaskLifecycle Lifecycle TaskLifecycle
Notifications *notification.Service Notifications *notification.Service
Agent agent.Client Agent agent.Client
Model model.Client Model model.Client
SlotUpdater WorkspaceSlotStateUpdater SlotUpdater WorkspaceSlotStateUpdater
RunTimeout time.Duration RunTimeout time.Duration
Logger *slog.Logger Logger *slog.Logger
IdentityWriter authoring.IdentityWriter
} }
func (w *TaskWorker) Work(ctx context.Context, job *river.Job[TaskJobArgs]) error { 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 // Plane-origin authoring tasks always route through the IOP OpenAI-compatible
// HTTP bridge even when an A2A agent client is configured, because IOP runs // HTTP bridge even when an A2A agent client is configured, because IOP runs
// workspace-bound agent authoring rather than a generic A2A task. // workspace-bound agent authoring rather than a generic A2A task.
generateInput, isAuthoring := authoring.BuildAuthoringGenerateInput(task) generateInput, isAuthoring, authoringInputErr := authoring.BuildAuthoringGenerateInputStrict(task)
if isAuthoring { if isAuthoring {
if authoringInputErr != nil {
return nil, "", authoringInputErr
}
if w.Model == nil { if w.Model == nil {
return nil, "", fmt.Errorf("model client is required for authoring tasks") 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 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 // 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 // agent so it can push to the remote develop branch before we gate on
// the develop match confirmation. // 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) pushGenerated, pushErr := w.Model.Generate(ctx, pushInput)
if pushErr != nil { if pushErr != nil {
return nil, "", authoringPushError{pushErr} return nil, "", authoringPushError{pushErr}
@ -212,14 +245,14 @@ func (w *TaskWorker) runTask(ctx context.Context, task storage.Task) (json.RawMe
BridgeSuccess: true, BridgeSuccess: true,
}) })
result := map[string]any{ result := map[string]any{
"message": generated.Text, "message": generated.Text,
"mode": "authoring_run", "mode": "authoring_run",
"model": generated.Model, "model": generated.Model,
"response_id": generated.ID, "response_id": generated.ID,
"push_response_id": pushGenerated.ID, "push_response_id": pushGenerated.ID,
"push_request_state": "succeeded", "push_request_state": "succeeded",
"authoring_run_state": decision.State, "authoring_run_state": decision.State,
"authoring_run_updated_at": time.Now().UTC().Format(time.RFC3339), "authoring_run_updated_at": time.Now().UTC().Format(time.RFC3339),
workflow.MetadataKeyAuthoringProgressMode: progressMode, workflow.MetadataKeyAuthoringProgressMode: progressMode,
workflow.MetadataKeyAuthoringProgressReason: progressReason, workflow.MetadataKeyAuthoringProgressReason: progressReason,
"usage": map[string]int{ "usage": map[string]int{
@ -482,9 +515,17 @@ func (w *TaskWorker) markFailedWithTask(taskID string, task storage.Task, err er
var releaseSlot bool var releaseSlot bool
if _, isAuthoring := authoring.TaskCheckoutMetadata(task); isAuthoring { if _, isAuthoring := authoring.TaskCheckoutMetadata(task); isAuthoring {
var pushErr authoringPushError var pushErr authoringPushError
var identErr authoringIdentityError
decisionInput := authoring.DecisionInput{BridgeSuccess: false} decisionInput := authoring.DecisionInput{BridgeSuccess: false}
if errors.As(err, &pushErr) { if errors.As(err, &pushErr) {
decisionInput = authoring.DecisionInput{BridgeSuccess: true, PushFailed: true} 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) decision := authoring.DecideAuthoringResult(decisionInput)
failInput.ExtraMetadata = map[string]any{ failInput.ExtraMetadata = map[string]any{

View file

@ -13,10 +13,12 @@ import (
"github.com/riverqueue/river" "github.com/riverqueue/river"
"github.com/nomadcode/nomadcode-core/internal/agent" "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/db"
"github.com/nomadcode/nomadcode-core/internal/model" "github.com/nomadcode/nomadcode-core/internal/model"
"github.com/nomadcode/nomadcode-core/internal/notification" "github.com/nomadcode/nomadcode-core/internal/notification"
"github.com/nomadcode/nomadcode-core/internal/projectsync" "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/storage"
"github.com/nomadcode/nomadcode-core/internal/workflow" "github.com/nomadcode/nomadcode-core/internal/workflow"
) )
@ -479,6 +481,16 @@ func checkoutTaskMeta(slotPath, sourceBranch string) json.RawMessage {
return raw 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) { func TestRunTaskRoutesPlaneOriginToAuthoringBridge(t *testing.T) {
var capturedInput model.GenerateInput 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", ID: "task-plane-1",
Title: "NOMAD-42: Create milestone", Title: "NOMAD-42: Create milestone",
Source: "plane", Source: "plane",
Payload: json.RawMessage(`{"prompt":"Author milestone for NOMAD-42","instructions":"Use roadmap skill"}`), Payload: json.RawMessage(`{"prompt":"Author milestone for NOMAD-42","instructions":"Use roadmap skill"}`),
Metadata: checkoutTaskMeta("/home/user/workspace/nomadcode/slots/000", "develop"), Metadata: checkoutTaskMeta("/home/user/workspace/nomadcode/slots/000", "develop"),
} }, "NOMAD-42")
raw, msg, err := worker.runTask(context.Background(), task) raw, msg, err := worker.runTask(context.Background(), task)
if err != nil { if err != nil {
@ -526,11 +538,37 @@ func TestRunTaskRoutesPlaneOriginToAuthoringBridge(t *testing.T) {
if capturedInput.WorkspaceMetadata.Provider != "plane" { if capturedInput.WorkspaceMetadata.Provider != "plane" {
t.Errorf("unexpected provider: %q", capturedInput.WorkspaceMetadata.Provider) 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) 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) { func TestRunTaskAuthoringPreemptsA2AClient(t *testing.T) {
var modelCalled bool var modelCalled bool
fakeModel := fakeModelClient{ fakeModel := fakeModelClient{
@ -544,16 +582,17 @@ func TestRunTaskAuthoringPreemptsA2AClient(t *testing.T) {
} }
// A2A client is set — authoring task must still go to model bridge. // A2A client is set — authoring task must still go to model bridge.
worker := &TaskWorker{ worker := &TaskWorker{
Model: fakeModel, Model: fakeModel,
Agent: fakeAgentClient{err: errors.New("a2a should not be called")}, Agent: fakeAgentClient{err: errors.New("a2a should not be called")},
IdentityWriter: &fakeIdentityWriter{},
} }
task := storage.Task{ task := withPlaneExternalRef(storage.Task{
ID: "task-preempt", ID: "task-preempt",
Source: "plane", Source: "plane",
Payload: json.RawMessage(`{}`), Payload: json.RawMessage(`{}`),
Metadata: checkoutTaskMeta("/home/user/workspace/nomadcode/slots/000", "develop"), Metadata: checkoutTaskMeta("/home/user/workspace/nomadcode/slots/000", "develop"),
} }, "NOMAD-42")
raw, _, err := worker.runTask(context.Background(), task) raw, _, err := worker.runTask(context.Background(), task)
if err != nil { if err != nil {
@ -610,15 +649,15 @@ func TestRunTaskAuthoringSuccessRecordsState(t *testing.T) {
return model.GenerateResult{Text: "milestone authored", Model: "m"}, nil 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", ID: "task-authoring-ok",
Title: "Author milestone", Title: "Author milestone",
Source: "plane", Source: "plane",
Payload: json.RawMessage(`{}`), Payload: json.RawMessage(`{}`),
Metadata: checkoutTaskMeta("/home/user/workspace/nomadcode/slots/000", "develop"), Metadata: checkoutTaskMeta("/home/user/workspace/nomadcode/slots/000", "develop"),
} }, "NOMAD-42")
raw, _, err := worker.runTask(context.Background(), task) raw, _, err := worker.runTask(context.Background(), task)
if err != nil { if err != nil {
@ -648,17 +687,18 @@ func TestWorkRecordsAuthoringInProgressBeforeExecution(t *testing.T) {
} }
fakeLifecycle := &fakeTaskLifecycle{ fakeLifecycle := &fakeTaskLifecycle{
task: storage.Task{ task: withPlaneExternalRef(storage.Task{
ID: "task-auth-ip", ID: "task-auth-ip",
Source: "plane", Source: "plane",
Status: "pending", Status: "pending",
Metadata: checkoutTaskMeta("/home/user/workspace/nomadcode/slots/000", "develop"), Metadata: checkoutTaskMeta("/home/user/workspace/nomadcode/slots/000", "develop"),
}, }, "NOMAD-42"),
} }
worker := &TaskWorker{ worker := &TaskWorker{
Lifecycle: fakeLifecycle, Lifecycle: fakeLifecycle,
Model: fakeModel, Model: fakeModel,
IdentityWriter: &fakeIdentityWriter{},
} }
job := &river.Job[TaskJobArgs]{Args: TaskJobArgs{TaskID: "task-auth-ip"}} job := &river.Job[TaskJobArgs]{Args: TaskJobArgs{TaskID: "task-auth-ip"}}
@ -992,18 +1032,19 @@ func TestWorkAuthoringSkipsGenericRunTimeout(t *testing.T) {
} }
fakeLifecycle := &fakeTaskLifecycle{ fakeLifecycle := &fakeTaskLifecycle{
task: storage.Task{ task: withPlaneExternalRef(storage.Task{
ID: "task-auth-timeout", ID: "task-auth-timeout",
Source: "plane", Source: "plane",
Status: "pending", Status: "pending",
Metadata: checkoutTaskMeta("/home/user/workspace/nomadcode/slots/000", "develop"), Metadata: checkoutTaskMeta("/home/user/workspace/nomadcode/slots/000", "develop"),
}, }, "NOMAD-42"),
} }
worker := &TaskWorker{ worker := &TaskWorker{
Lifecycle: fakeLifecycle, Lifecycle: fakeLifecycle,
Model: fakeModel, Model: fakeModel,
RunTimeout: 1 * time.Millisecond, RunTimeout: 1 * time.Millisecond,
IdentityWriter: &fakeIdentityWriter{},
} }
go func() { go func() {
@ -1028,17 +1069,18 @@ func TestWorkKeepsAuthoringTaskRunningUntilDevelopMatch(t *testing.T) {
} }
fakeLifecycle := &fakeTaskLifecycle{ fakeLifecycle := &fakeTaskLifecycle{
task: storage.Task{ task: withPlaneExternalRef(storage.Task{
ID: "task-auth-wait", ID: "task-auth-wait",
Source: "plane", Source: "plane",
Status: "pending", Status: "pending",
Metadata: checkoutTaskMeta("/home/user/workspace/nomadcode/slots/000", "develop"), Metadata: checkoutTaskMeta("/home/user/workspace/nomadcode/slots/000", "develop"),
}, }, "NOMAD-42"),
} }
worker := &TaskWorker{ worker := &TaskWorker{
Lifecycle: fakeLifecycle, Lifecycle: fakeLifecycle,
Model: fakeModel, Model: fakeModel,
IdentityWriter: &fakeIdentityWriter{},
} }
job := &river.Job[TaskJobArgs]{Args: TaskJobArgs{TaskID: "task-auth-wait"}} job := &river.Job[TaskJobArgs]{Args: TaskJobArgs{TaskID: "task-auth-wait"}}
@ -1078,17 +1120,18 @@ func TestWorkAuthoringTwoCallsAndPushCommand(t *testing.T) {
} }
fakeLifecycle := &fakeTaskLifecycle{ fakeLifecycle := &fakeTaskLifecycle{
task: storage.Task{ task: withPlaneExternalRef(storage.Task{
ID: "task-auth-two-calls", ID: "task-auth-two-calls",
Source: "plane", Source: "plane",
Status: "pending", Status: "pending",
Metadata: checkoutTaskMeta("/home/user/workspace/nomadcode/slots/000", "develop"), Metadata: checkoutTaskMeta("/home/user/workspace/nomadcode/slots/000", "develop"),
}, }, "NOMAD-42"),
} }
worker := &TaskWorker{ worker := &TaskWorker{
Lifecycle: fakeLifecycle, Lifecycle: fakeLifecycle,
Model: fakeModel, Model: fakeModel,
IdentityWriter: &fakeIdentityWriter{},
} }
job := &river.Job[TaskJobArgs]{Args: TaskJobArgs{TaskID: "task-auth-two-calls"}} job := &river.Job[TaskJobArgs]{Args: TaskJobArgs{TaskID: "task-auth-two-calls"}}
@ -1140,18 +1183,19 @@ func TestWorkAuthoringPushFailureMarksPushFailed(t *testing.T) {
fakeSlotUpdater := &fakeWorkspaceSlotUpdater{} fakeSlotUpdater := &fakeWorkspaceSlotUpdater{}
fakeLifecycle := &fakeTaskLifecycle{ fakeLifecycle := &fakeTaskLifecycle{
task: storage.Task{ task: withPlaneExternalRef(storage.Task{
ID: "task-auth-push-fail", ID: "task-auth-push-fail",
Source: "plane", Source: "plane",
Status: "pending", Status: "pending",
Metadata: checkoutTaskMeta("/home/user/workspace/nomadcode/slots/000", "develop"), Metadata: checkoutTaskMeta("/home/user/workspace/nomadcode/slots/000", "develop"),
}, }, "NOMAD-42"),
} }
worker := &TaskWorker{ worker := &TaskWorker{
Lifecycle: fakeLifecycle, Lifecycle: fakeLifecycle,
Model: fakeModel, Model: fakeModel,
SlotUpdater: fakeSlotUpdater, SlotUpdater: fakeSlotUpdater,
IdentityWriter: &fakeIdentityWriter{},
} }
job := &river.Job[TaskJobArgs]{Args: TaskJobArgs{TaskID: "task-auth-push-fail"}} 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 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", ID: "task-push-resp",
Source: "plane", Source: "plane",
Metadata: checkoutTaskMeta("/home/user/workspace/nomadcode/slots/000", "develop"), Metadata: checkoutTaskMeta("/home/user/workspace/nomadcode/slots/000", "develop"),
} }, "NOMAD-42")
raw, _, err := worker.runTask(context.Background(), task) raw, _, err := worker.runTask(context.Background(), task)
if err != nil { if err != nil {
@ -1249,15 +1293,16 @@ func TestRunTaskAuthoringProgressCallback(t *testing.T) {
} }
worker := &TaskWorker{ worker := &TaskWorker{
Lifecycle: fakeLifecycle, Lifecycle: fakeLifecycle,
Model: fakeModel, Model: fakeModel,
IdentityWriter: &fakeIdentityWriter{},
} }
task := storage.Task{ task := withPlaneExternalRef(storage.Task{
ID: "task-auth-progress", ID: "task-auth-progress",
Source: "plane", Source: "plane",
Metadata: checkoutTaskMeta("/home/user/workspace/nomadcode/slots/000", "develop"), Metadata: checkoutTaskMeta("/home/user/workspace/nomadcode/slots/000", "develop"),
} }, "NOMAD-42")
raw, msg, err := worker.runTask(context.Background(), task) raw, msg, err := worker.runTask(context.Background(), task)
if err != nil { 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]) 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"])
}
}

View file

@ -12,6 +12,7 @@ import (
"github.com/riverqueue/river/rivermigrate" "github.com/riverqueue/river/rivermigrate"
"github.com/nomadcode/nomadcode-core/internal/agent" "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/model"
"github.com/nomadcode/nomadcode-core/internal/notification" "github.com/nomadcode/nomadcode-core/internal/notification"
"github.com/nomadcode/nomadcode-core/internal/workflow" "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 // Milestone creation sync orchestrator: when non-nil its worker is registered so
// EnqueueRoadmapCreationSync has a runtime worker; when nil only the task worker // EnqueueRoadmapCreationSync has a runtime worker; when nil only the task worker
// is registered, preserving the default task scheduler behavior. // 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() workers := river.NewWorkers()
river.AddWorker(workers, &TaskWorker{ river.AddWorker(workers, &TaskWorker{
Lifecycle: lifecycle, Lifecycle: lifecycle,
Notifications: notifications, Notifications: notifications,
Agent: agentClient, Agent: agentClient,
Model: modelClient, Model: modelClient,
SlotUpdater: slotUpdater, IdentityWriter: identityWriter,
RunTimeout: runTimeout, SlotUpdater: slotUpdater,
Logger: logger, RunTimeout: runTimeout,
Logger: logger,
}) })
if creationSync != nil { if creationSync != nil {
river.AddWorker(workers, &RoadmapCreationSyncWorker{ river.AddWorker(workers, &RoadmapCreationSyncWorker{

View file

@ -303,7 +303,7 @@ func lazyPool(t *testing.T) *pgxpool.Pool {
func TestSchedulerRegistersRoadmapSyncWorkerWhenConfigured(t *testing.T) { func TestSchedulerRegistersRoadmapSyncWorkerWhenConfigured(t *testing.T) {
runner := &fakeCreationSyncRunner{} 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 { if err != nil {
t.Fatalf("New with creation sync runner: %v", err) t.Fatalf("New with creation sync runner: %v", err)
} }
@ -317,7 +317,7 @@ func TestSchedulerRegistersRoadmapSyncWorkerWhenConfigured(t *testing.T) {
} }
func TestSchedulerWithoutRoadmapSyncKeepsTaskWorkerOnlyBehavior(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 { if err != nil {
t.Fatalf("New without creation sync runner: %v", err) t.Fatalf("New without creation sync runner: %v", err)
} }