diff --git a/agent-task/archive/2026/06/m-milestone-work-item-creation-sync/02+01_iop_authoring_bridge/code_review_cloud_G07_0.log b/agent-task/archive/2026/06/m-milestone-work-item-creation-sync/02+01_iop_authoring_bridge/code_review_cloud_G07_0.log new file mode 100644 index 0000000..7bdaae1 --- /dev/null +++ b/agent-task/archive/2026/06/m-milestone-work-item-creation-sync/02+01_iop_authoring_bridge/code_review_cloud_G07_0.log @@ -0,0 +1,167 @@ + + +# Code Review Reference - API + +> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.** +> The task is NOT complete until every implementation-owned section below is filled in. +> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving. +> Fill implementation-owned sections, then stop with active files in place and report ready for review. +> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. +> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation. +> Finalization is review-agent-only. + +## 개요 + +date=2026-06-13 +task=m-milestone-work-item-creation-sync/02+01_iop_authoring_bridge, plan=0, tag=API + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md` +- Task ids: + - `cycle-iop-http-ready`: IOP Edge OpenAI-compatible HTTP(`/v1/responses`) workspace agent 실행 통로를 선행 조건으로 확인한다. + - `cycle-authoring-run`: Plane 티켓 제목/본문과 provider work item identity를 입력으로 `slots/` workspace에서 1회 authoring run을 실행한다. +- Completion mode: check-on-pass + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [API-1] Responses Metadata Workspace Shape | [x] | +| [API-2] Authoring Run Builder | [x] | +| [API-3] Authoring State Boundary | [x] | + +## 구현 체크리스트 + +- [x] `model.GenerateInput.Metadata`가 `metadata.workspace` object를 표현할 수 있게 additive typed metadata 구조를 도입하고 기존 flat metadata 호환을 유지한다. +- [x] IOP workspace authoring request builder를 추가해 slot path, Plane ticket title/body, provider identity, project sync 설정, roadmap 작성 지시를 `/v1/responses` payload에 담는다. +- [x] authoring run 상태 모델 `in_progress`, `succeeded`, `failed`와 stale 판단 기준을 저장 가능한 typed metadata 또는 새 persistence 경계로 남긴다. +- [x] scheduler 또는 work item pipeline에서 Plane-origin authoring mode task가 일반 model completion과 구분되어 IOP HTTP bridge를 호출하도록 연결한다. +- [x] `cd services/core && go 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하는지 확인한다. +- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [ ] PASS이면 active task 디렉터리 `agent-task/m-milestone-work-item-creation-sync/02+01_iop_authoring_bridge/`를 archive로 이동한다. +- [ ] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [ ] PASS split 작업이면 이동 후 빈 active parent를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [x] WARN/FAIL이면 다음 active plan/review 또는 `USER_REVIEW.md`를 작성한다. + +## 계획 대비 변경 사항 + +- **API-3 상태 기록 경로**: plan은 "task metadata에 `authoring_run_state` 기록"이라 했다. 구현은 두 갈래로 처리했다. + - 성공: `runTask` result JSON에 `authoring_run_state=succeeded`/`authoring_run_updated_at`을 넣고, `lifecycle.CompleteTask`에서 해당 키를 result에서 promote해 task metadata에 merge한다. + - 실패: `workflow.FailureInput`에 `ExtraMetadata map[string]any`를 추가하고, `markFailedWithTask`에서 authoring task 감지 시 `authoring_run_state=failed`/`authoring_run_updated_at`/`authoring_failure_type`을 넣어 `FailTaskWithMetadata`에 전달한다. + - 이 방식은 lifecycle이 단일 write 경계를 유지하면서 authoring 상태를 함께 기록할 수 있게 한다. +- **`markFailedWithTask` 도입**: 기존 `markFailed(taskID, err)`는 task 정보 없이 실행되어 authoring 여부를 판별할 수 없었다. `markFailedWithTask(taskID, task, err)`를 별도로 추가해 `runTask` 실패 경로에만 적용하고, `CompleteTask` 실패 경로는 기존 `markFailed`를 유지한다. +- **`authoring_failure_type` 대신 `workflow.MetadataKeyAuthoringFailureType` 상수 사용**: model.go에 상수를 추가해 scheduler와 lifecycle 모두 같은 키를 참조한다. + +## 주요 설계 결정 + +- **`internal/authoring` 패키지 신설**: plan은 `internal/authoring` 또는 `internal/workitempipeline` 하위를 제안했다. 별도 패키지로 분리해 scheduler와 authoring 판별 로직의 의존 방향을 명확히 했다. scheduler → authoring → workitempipeline/model 방향이다. +- **authoring 감지 기준**: `source=="plane"` AND `metadata.checkout.slot_path`가 비어 있지 않은 경우만 authoring task로 판정한다. IOP CLI 직접 실행이나 A2A path를 건드리지 않는다. +- **stale 판단**: plan 설계대로 별도 상태 없이 `authoring_run_updated_at` 기준으로 상위 레이어에서 판단한다. lifecycle에 stale 상태 write를 넣지 않았다. +- **`FailureInput.ExtraMetadata`**: lifecycle의 기존 `FailTaskWithMetadata` 시그니처를 최소한으로 확장한다. 기존 caller(scheduler `markFailed`, workflow service 등)는 `ExtraMetadata`를 nil로 전달하며 동작이 바뀌지 않는다. + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- `/v1/responses` request에 `metadata.workspace` object가 실제 JSON object로 들어가는지 확인한다. +- 일반 model task와 Plane-origin authoring task가 섞이지 않는지 확인한다. +- IOP CLI 실행이나 A2A fallback이 이 plan에 들어오지 않았는지 확인한다. + +## 검증 결과 + +### API-1 중간 검증 +```bash +$ cd services/core && go test ./internal/adapters/openai +ok github.com/nomadcode/nomadcode-core/internal/adapters/openai 0.006s +``` + +### API-2 중간 검증 +```bash +$ cd services/core && go test ./internal/authoring ./internal/scheduler +ok github.com/nomadcode/nomadcode-core/internal/authoring 0.003s +ok github.com/nomadcode/nomadcode-core/internal/scheduler 1.509s +``` + +### API-3 중간 검증 +```bash +$ cd services/core && go test ./internal/workflow ./internal/scheduler +ok github.com/nomadcode/nomadcode-core/internal/workflow (cached) +ok github.com/nomadcode/nomadcode-core/internal/scheduler 1.509s +``` + +### 최종 검증 +```bash +$ cd services/core && go test ./... +ok github.com/nomadcode/nomadcode-core/cmd/plane-smoke (cached) +? github.com/nomadcode/nomadcode-core/cmd/server [no test files] +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/http 0.004s +? github.com/nomadcode/nomadcode-core/internal/model [no test files] +ok github.com/nomadcode/nomadcode-core/internal/notification (cached) +ok github.com/nomadcode/nomadcode-core/internal/projectsync (cached) +ok github.com/nomadcode/nomadcode-core/internal/protosocket 0.010s +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 +(출력 없음 — whitespace 오류 없음) +``` + +--- + +## 코드리뷰 결과 + +- 종합 판정: FAIL +- 차원별 평가: + - Correctness: Fail + - Completeness: Fail + - Test coverage: Fail + - API contract: Fail + - Code quality: Pass + - Plan deviation: Fail + - Verification trust: Pass +- 발견된 문제: + - Required: `services/core/internal/authoring/request.go:79`의 builder는 `prompt`/`instructions` payload만 읽고, 실제 Plane work item pipeline이 만드는 `payload.message`와 `payload.work_item`은 무시합니다. 실제 생성 경로는 `services/core/internal/workitem/provider.go:176`에서 `message`/`work_item`을 payload에 넣고 `services/core/internal/workitempipeline/service.go:117`에서 task metadata는 `checkout`으로 교체하므로, 현재 authoring request는 Plane 티켓 본문을 전달하지 못하고 provider work item identity도 보존하지 못합니다. `message`와 `work_item`을 실제 입력/identity로 파싱하거나 `buildGenerateInput`의 prompt parsing 계약과 맞추고, 테스트는 `workitem.BuildCreateTaskInput`/pipeline이 만드는 실제 payload shape를 사용해야 합니다. + - Required: `services/core/internal/scheduler/jobs.go:115`에서 `w.Agent != nil`이면 즉시 A2A 실행으로 빠져 `services/core/internal/scheduler/jobs.go:128`의 authoring bridge 분기에 도달하지 않습니다. 서버는 `services/core/cmd/server/main.go:85`에서 A2A client와 model client를 동시에 주입할 수 있으므로, A2A가 설정된 환경의 Plane-origin authoring task는 계획의 IOP HTTP `/v1/responses` bridge가 아니라 A2A path로 실행됩니다. authoring task 판별을 agent 우선 분기보다 앞에 두거나, authoring task만 명시적으로 model/OpenAI-compatible bridge를 사용하게 해야 합니다. + - Required: `services/core/internal/workflow/model.go:54`는 authoring 상태 값에 `in_progress`를 포함하지만, 실제 저장 경로는 `services/core/internal/scheduler/jobs.go:158`의 성공과 `services/core/internal/scheduler/jobs.go:376`의 실패뿐입니다. 시작 시점에는 `services/core/internal/workflow/lifecycle.go:172`가 `agent_run_state=running`만 기록하므로 running authoring task의 `authoring_run_state=in_progress`/`authoring_run_updated_at`이 저장되지 않아 stale 판단 기준이 성립하지 않습니다. authoring task 시작 시 metadata에 `in_progress`와 timestamp를 남기고 실제 lifecycle 또는 scheduler 통합 테스트로 검증해야 합니다. +- 다음 단계: WARN/FAIL 후속으로 `PLAN-cloud-G07.md`와 `CODE_REVIEW-cloud-G07.md`를 새로 작성한다. diff --git a/agent-task/archive/2026/06/m-milestone-work-item-creation-sync/02+01_iop_authoring_bridge/code_review_cloud_G07_1.log b/agent-task/archive/2026/06/m-milestone-work-item-creation-sync/02+01_iop_authoring_bridge/code_review_cloud_G07_1.log new file mode 100644 index 0000000..fb46c75 --- /dev/null +++ b/agent-task/archive/2026/06/m-milestone-work-item-creation-sync/02+01_iop_authoring_bridge/code_review_cloud_G07_1.log @@ -0,0 +1,209 @@ + + +# Code Review Reference - REVIEW_API + +> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.** +> The task is NOT complete until every implementation-owned section below is filled in. +> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving. +> Fill implementation-owned sections, then stop with active files in place and report ready for review. +> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves. +> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review. +> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume. +> Follow the ownership table at the bottom of this file for which sections you own. + +## 개요 + +date=2026-06-13 +task=m-milestone-work-item-creation-sync/02+01_iop_authoring_bridge, plan=1, tag=REVIEW_API + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md` +- Task ids: + - `cycle-iop-http-ready`: IOP Edge OpenAI-compatible HTTP(`/v1/responses`) workspace agent 실행 통로를 선행 조건으로 확인한다. + - `cycle-authoring-run`: Plane 티켓 제목/본문과 provider work item identity를 입력으로 `slots/` workspace에서 1회 authoring run을 실행한다. +- Completion mode: check-on-pass + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. +리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. + +1. 판정을 append한다. +2. `CODE_REVIEW-cloud-G07.md` → `code_review_cloud_G07_N.log`, `PLAN-cloud-G07.md` → `plan_cloud_G07_M.log`로 아카이브한다. +3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 archive로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. +4. PASS이고 task group이 `m-`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. +5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [REVIEW_API-1] 실제 Plane payload와 provider identity 보존 | [x] | +| [REVIEW_API-2] Authoring task의 HTTP bridge 우선 routing | [x] | +| [REVIEW_API-3] Authoring in-progress 상태 metadata | [x] | + +## 구현 체크리스트 + +- [x] [REVIEW_API-1] Authoring builder가 실제 work item pipeline payload(`message`, `work_item`)와 task external fields에서 Plane 티켓 본문 및 provider identity를 보존한다. +- [x] [REVIEW_API-2] Plane-origin authoring task는 A2A client가 설정되어 있어도 IOP OpenAI-compatible HTTP bridge를 우선 호출한다. +- [x] [REVIEW_API-3] authoring run 시작 시 `authoring_run_state=in_progress`와 `authoring_run_updated_at`을 저장하고 stale 판단 가능한 metadata 경계를 검증한다. +- [x] `cd services/core && go test ./internal/authoring ./internal/scheduler ./internal/workitempipeline ./internal/workflow`와 `cd services/core && go 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-milestone-work-item-creation-sync/02+01_iop_authoring_bridge/`를 archive로 이동한다. +- [x] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [x] PASS split 작업이면 이동 후 빈 active parent를 제거하거나, 남은 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가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다. + +## 계획 대비 변경 사항 + +- **`runTask` 구조 재정비 (REVIEW_API-2)**: 초기 구현에서는 A2A client가 있으면 먼저 `runAgentTask`로 분기했다. 리뷰에서 authoring task가 A2A client를 우선해야 한다는 지적을 반영해, `runTask` 최상단에서 `BuildAuthoringGenerateInput`으로 authoring 여부를 먼저 판별하고 early return하도록 재구성했다. A2A / generic model 경로는 authoring이 아닐 때만 진입한다. +- **pipeline payload shape 지원 추가 (REVIEW_API-1)**: 초기 구현의 `authoringPayload`는 `prompt`/`instructions` 필드만 읽었다. 실제 `workitem.BuildCreateTaskInput`이 생성하는 payload는 `message`(Plane 본문)와 `work_item`(provider identity map)이다. `pipelinePayload`로 대체해 두 shape를 모두 지원한다. 입력 우선순위: `prompt` override > `message` > task title. +- **provider identity 소스 우선순위 명확화 (REVIEW_API-1)**: `providerIdentity`를 `task.ExternalProvider`/`task.ExternalID` → `work_item` map → source/ID fallback 순으로 재정의했다. `task.Metadata.external` 읽기는 실제 storage 구조와 맞지 않아 제거했다. +- **`authoring_run_state=in_progress` 기록 추가 (REVIEW_API-3)**: 초기 구현은 완료/실패 시에만 상태를 기록했다. `Work`의 `StartTask` 직후 authoring task이면 `MergeTaskMetadata`로 `in_progress`를 기록하도록 추가했다. stale 판단 레이어가 `authoring_run_updated_at`을 threshold와 비교할 수 있게 한다. +- **`TaskLifecycle.MergeTaskMetadata` 추가**: status 전환 없이 metadata만 업데이트하는 경로가 필요했다. lifecycle이 단일 write 경계를 유지하기 위해 인터페이스에 추가했다. 기존 caller는 영향 없다. +- **`fakeTaskLifecycle.StartTask` metadata 보존**: 테스트 스텁이 `StartTask` 시 `f.task.Metadata`를 `{"attempt":N}`으로 완전 교체해 checkout metadata가 사라지는 버그가 있었다. 기존 metadata에 attempt를 merge하는 방식으로 수정했다. + +## 주요 설계 결정 + +- **authoring early-return 우선**: `runTask`에서 authoring 여부를 A2A/model 분기보다 먼저 확인한다. Plane-origin authoring은 특수 실행 경로이며 A2A나 generic model completion과 섞이지 않아야 한다는 리뷰 요구사항을 반영한다. +- **`pipelinePayload` 두 shape 통합**: `message`/`work_item`(pipeline) 과 `prompt`/`instructions`(generic override)를 하나의 struct로 파싱한다. pipeline payload가 항상 있고, prompt override는 테스트/수동 케이스에서만 사용된다. +- **`in_progress` 기록을 `Work`에서 처리**: `StartTask`는 lifecycle에서 이미 `agent_run_state=running`을 기록한다. authoring 전용 `authoring_run_state=in_progress`는 scheduler가 authoring 여부를 판별한 뒤 `MergeTaskMetadata`로 기록한다. lifecycle은 판별 로직을 갖지 않는다. +- **stale 판단은 `authoring_run_updated_at` 기준**: `in_progress` 기록 시점의 timestamp를 저장하므로, 상위 레이어가 현재 시각과 비교해 stale 여부를 판정할 수 있다. lifecycle 내부에 별도 stale 상태는 만들지 않는다. + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- 실제 work item pipeline payload shape(`message`, `work_item`)가 authoring input과 `metadata.workspace`에 반영되는지 확인한다. +- A2A client가 설정된 worker에서도 Plane-origin checkout metadata task가 model/OpenAI-compatible bridge로 routing되는지 확인한다. +- `authoring_run_state`가 `in_progress` → `succeeded` 또는 `failed`로 저장되고, non-authoring task에는 authoring metadata가 생기지 않는지 확인한다. + +## 검증 결과 + +_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ + +필수 규칙: +- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다. +- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. +- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. +- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다. +- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다. + +### REVIEW_API-1 중간 검증 +```bash +$ cd services/core && go test ./internal/authoring ./internal/workitempipeline +ok github.com/nomadcode/nomadcode-core/internal/authoring 0.003s +ok github.com/nomadcode/nomadcode-core/internal/workitempipeline (cached) +``` + +### REVIEW_API-2 중간 검증 +```bash +$ cd services/core && go test ./internal/scheduler +ok github.com/nomadcode/nomadcode-core/internal/scheduler 2.014s +``` + +### REVIEW_API-3 중간 검증 +```bash +$ cd services/core && go test ./internal/workflow ./internal/scheduler +ok github.com/nomadcode/nomadcode-core/internal/workflow 0.003s +ok github.com/nomadcode/nomadcode-core/internal/scheduler (cached) +``` + +### 최종 검증 +```bash +$ cd services/core && go test ./internal/authoring ./internal/scheduler ./internal/workitempipeline ./internal/workflow +ok github.com/nomadcode/nomadcode-core/internal/authoring (cached) +ok github.com/nomadcode/nomadcode-core/internal/scheduler (cached) +ok github.com/nomadcode/nomadcode-core/internal/workitempipeline (cached) +ok github.com/nomadcode/nomadcode-core/internal/workflow (cached) + +$ cd services/core && go test ./... +ok github.com/nomadcode/nomadcode-core/cmd/plane-smoke (cached) +? github.com/nomadcode/nomadcode-core/cmd/server [no test files] +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/http 0.003s +? github.com/nomadcode/nomadcode-core/internal/model [no test files] +ok github.com/nomadcode/nomadcode-core/internal/notification (cached) +ok github.com/nomadcode/nomadcode-core/internal/projectsync (cached) +ok github.com/nomadcode/nomadcode-core/internal/protosocket 0.011s +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 +(출력 없음 — whitespace 오류 없음) +``` + +--- + +> **[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. + +Sections and their ownership: + +| 섹션 | 소유자 | 설명 | +|------|--------|------| +| 헤더 주석, 개요(date/task/plan/tag), 리뷰 에이전트 지시 | 스텁 생성 시 고정 | 구현 에이전트가 수정하거나 실행하지 않음 | +| Roadmap Targets | follow-up plan에서 복사해 스텁 생성 시 고정 | 구현 에이전트가 수정하지 않음; PASS 시 code-review가 `complete.log`의 `Roadmap Completion`으로 복사 | +| 구현 항목별 완료 여부 (항목명) | 스텁 생성 시 고정 | `[ ]` -> `[x]` 체크만 구현 에이전트가 수행 | +| 구현 체크리스트 (항목 텍스트/순서) | follow-up plan에서 복사해 스텁 생성 시 고정 | 구현 에이전트가 `[ ]` -> `[x]` 체크만 수행; 마지막 체크박스는 저장 전 필수 | +| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section | +| 계획 대비 변경 사항, 주요 설계 결정 | 구현 에이전트가 채움 | placeholder 텍스트를 실제 내용으로 교체 | +| 사용자 리뷰 요청 | 구현 에이전트가 채움 | 진행에 사용자 입력이 필요하지 않으면 `상태: 없음` 유지; 구현 중 직접 질문은 금지; 필요하면 결정 항목, 근거, 명령 출력, 자동 후속 불가 이유, 재개 조건을 기록 | +| 리뷰어를 위한 체크포인트 | 스텁 생성 시 고정 | 계획에서 추출한 리뷰 포인트 | +| 검증 결과 (섹션 제목 + 명령) | 스텁 생성 시 고정 | 실행 출력만 구현 에이전트가 채움; 명령 변경은 `계획 대비 변경 사항`에 기록 | +| 코드리뷰 결과 | 리뷰 에이전트가 append | 스텁에 포함하지 않음 | + +## 코드리뷰 결과 + +- 종합 판정: PASS +- 차원별 평가: + - Correctness: Pass + - Completeness: Pass + - Test coverage: Pass + - API contract: Pass + - Code quality: Pass + - Plan deviation: Pass + - Verification trust: Pass +- 발견된 문제: 없음 +- 다음 단계: PASS 종결. `complete.log` 작성 후 task 디렉터리를 archive로 이동한다. diff --git a/agent-task/archive/2026/06/m-milestone-work-item-creation-sync/02+01_iop_authoring_bridge/complete.log b/agent-task/archive/2026/06/m-milestone-work-item-creation-sync/02+01_iop_authoring_bridge/complete.log new file mode 100644 index 0000000..1c475a2 --- /dev/null +++ b/agent-task/archive/2026/06/m-milestone-work-item-creation-sync/02+01_iop_authoring_bridge/complete.log @@ -0,0 +1,44 @@ +# Complete - m-milestone-work-item-creation-sync/02+01_iop_authoring_bridge + +## 완료 일시 + +2026-06-13 + +## 요약 + +IOP authoring bridge follow-up loop 2에서 Plane-origin workspace authoring request shape, HTTP bridge routing, authoring run metadata state를 검증하고 최종 PASS로 종결했다. + +## 루프 이력 + +| Plan | Review | Verdict | 메모 | +|------|--------|---------|------| +| `plan_cloud_G07_0.log` | `code_review_cloud_G07_0.log` | FAIL | 실제 pipeline payload/identity, A2A 우선 routing, in_progress metadata 누락으로 후속 plan 작성 | +| `plan_cloud_G07_1.log` | `code_review_cloud_G07_1.log` | PASS | Required 3건 보완 확인 및 검증 통과 | + +## 구현/정리 내용 + +- `metadata.workspace` object 지원을 유지하면서 actual Plane pipeline payload의 `message`/`work_item`과 task external fields를 authoring request에 반영했다. +- Plane-origin authoring task가 A2A client 존재 여부와 무관하게 OpenAI-compatible `/v1/responses` model bridge를 우선 사용하도록 scheduler routing을 정리했다. +- authoring run의 `in_progress`, `succeeded`, `failed` metadata state와 `authoring_run_updated_at` 저장 경계를 추가하고 tests로 검증했다. + +## 최종 검증 + +- `cd services/core && go test ./internal/authoring ./internal/scheduler ./internal/workitempipeline ./internal/workflow` - PASS; targeted packages all `ok`. +- `cd services/core && go test ./...` - PASS; all core packages passed. +- `git diff --check` - PASS; whitespace 오류 없음. + +## Roadmap Completion + +- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md` +- Completed task ids: + - `cycle-iop-http-ready`: PASS; evidence=`agent-task/archive/2026/06/m-milestone-work-item-creation-sync/02+01_iop_authoring_bridge/plan_cloud_G07_1.log`, `agent-task/archive/2026/06/m-milestone-work-item-creation-sync/02+01_iop_authoring_bridge/code_review_cloud_G07_1.log`; verification=`cd services/core && go test ./internal/authoring ./internal/scheduler ./internal/workitempipeline ./internal/workflow`, `cd services/core && go test ./...`, `git diff --check` + - `cycle-authoring-run`: PASS; evidence=`agent-task/archive/2026/06/m-milestone-work-item-creation-sync/02+01_iop_authoring_bridge/plan_cloud_G07_1.log`, `agent-task/archive/2026/06/m-milestone-work-item-creation-sync/02+01_iop_authoring_bridge/code_review_cloud_G07_1.log`; verification=`cd services/core && go test ./internal/authoring ./internal/scheduler ./internal/workitempipeline ./internal/workflow`, `cd services/core && go test ./...`, `git diff --check` +- Not completed task ids: 없음 + +## 잔여 Nit + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/m-milestone-work-item-creation-sync/02+01_iop_authoring_bridge/PLAN-cloud-G07.md b/agent-task/archive/2026/06/m-milestone-work-item-creation-sync/02+01_iop_authoring_bridge/plan_cloud_G07_0.log similarity index 100% rename from agent-task/m-milestone-work-item-creation-sync/02+01_iop_authoring_bridge/PLAN-cloud-G07.md rename to agent-task/archive/2026/06/m-milestone-work-item-creation-sync/02+01_iop_authoring_bridge/plan_cloud_G07_0.log diff --git a/agent-task/archive/2026/06/m-milestone-work-item-creation-sync/02+01_iop_authoring_bridge/plan_cloud_G07_1.log b/agent-task/archive/2026/06/m-milestone-work-item-creation-sync/02+01_iop_authoring_bridge/plan_cloud_G07_1.log new file mode 100644 index 0000000..66a7e73 --- /dev/null +++ b/agent-task/archive/2026/06/m-milestone-work-item-creation-sync/02+01_iop_authoring_bridge/plan_cloud_G07_1.log @@ -0,0 +1,136 @@ + + +# Plan - REVIEW_API IOP Authoring Bridge Fixes + +## 이 파일을 읽는 구현 에이전트에게 + +이 plan은 `code_review_cloud_G07_0.log`의 Required 이슈만 해결한다. 구현 중 사용자에게 직접 질문하거나 `request_user_input`을 호출하지 않는다. 사용자 전용 결정, 사용자 소유 외부 환경, 범위 충돌로 막히면 `CODE_REVIEW-cloud-G07.md`의 `사용자 리뷰 요청`에 근거를 기록하고 active 파일을 유지한 채 멈춘다. + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md` +- Task ids: + - `cycle-iop-http-ready`: IOP Edge OpenAI-compatible HTTP(`/v1/responses`) workspace agent 실행 통로를 선행 조건으로 확인한다. + - `cycle-authoring-run`: Plane 티켓 제목/본문과 provider work item identity를 입력으로 `slots/` workspace에서 1회 authoring run을 실행한다. +- Completion mode: check-on-pass + +## 리뷰 근거 + +- 이전 리뷰: `agent-task/m-milestone-work-item-creation-sync/02+01_iop_authoring_bridge/code_review_cloud_G07_0.log` +- 판정: FAIL +- Required 이슈 수: 3 + +## 범위 결정 근거 + +- Plane Todo projection, identity map persistence, idempotency retry는 계속 후속 subtask 범위로 둔다. +- 이 plan은 이미 추가된 OpenAI metadata object support를 유지하면서 실제 Plane-origin authoring request shape, 실행 routing, 상태 metadata만 보정한다. + +## 구현 체크리스트 + +- [ ] [REVIEW_API-1] Authoring builder가 실제 work item pipeline payload(`message`, `work_item`)와 task external fields에서 Plane 티켓 본문 및 provider identity를 보존한다. +- [ ] [REVIEW_API-2] Plane-origin authoring task는 A2A client가 설정되어 있어도 IOP OpenAI-compatible HTTP bridge를 우선 호출한다. +- [ ] [REVIEW_API-3] authoring run 시작 시 `authoring_run_state=in_progress`와 `authoring_run_updated_at`을 저장하고 stale 판단 가능한 metadata 경계를 검증한다. +- [ ] `cd services/core && go test ./internal/authoring ./internal/scheduler ./internal/workitempipeline ./internal/workflow`와 `cd services/core && go test ./...`, `git diff --check`를 실행한다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## [REVIEW_API-1] 실제 Plane payload와 provider identity 보존 + +### 문제 + +`services/core/internal/authoring/request.go`는 payload의 `prompt`/`instructions`와 `metadata.external`만 본다. 실제 work item pipeline은 `services/core/internal/workitem/provider.go`에서 payload에 `message`와 `work_item`을 넣고, 외부 식별자는 task의 `ExternalProvider`/`ExternalID` 컬럼으로 저장한다. 따라서 현재 bridge는 Plane 티켓 본문을 입력하지 못하고 `metadata.workspace.work_item_id`가 내부 task id로 떨어질 수 있다. + +### 해결 방법 + +- `authoring` payload parser가 `prompt`, `message`, `input`, `instructions`, `system`, `work_item`을 읽도록 확장한다. +- `metadata.workspace.provider`/`work_item_id`는 우선 `task.ExternalProvider`/`task.ExternalID`, 다음 `payload.work_item.provider`/`id`, 마지막 기존 fallback을 사용한다. +- prompt가 별도로 없으면 title과 Plane body/message가 모두 모델 input에 포함되게 구성한다. 빈 body만 있을 때는 기존 title fallback을 유지한다. +- `metadata.workspace` object에는 workspace path/source branch와 provider identity가 실제 Plane ref로 들어가야 한다. + +### 수정 파일 및 체크리스트 + +- [ ] `services/core/internal/authoring/request.go` +- [ ] `services/core/internal/authoring/request_test.go` +- [ ] 필요 시 `services/core/internal/scheduler/jobs_test.go` + +### 테스트 작성 + +- `workitem.BuildCreateTaskInput` 또는 동일한 payload shape(`message`, `work_item`)를 사용한 authoring builder 테스트를 추가한다. +- `task.ExternalProvider="plane"`, `task.ExternalID="work-1"`인 실제 `storage.Task`에서 `WorkspaceMetadata.WorkItemID=="work-1"`인지 검증한다. +- 기존 `prompt` payload 테스트는 backward-compatible 경로로 유지한다. + +### 중간 검증 + +```bash +cd services/core && go test ./internal/authoring ./internal/workitempipeline +``` + +## [REVIEW_API-2] Authoring task의 HTTP bridge 우선 routing + +### 문제 + +`services/core/internal/scheduler/jobs.go`는 `w.Agent != nil`이면 바로 A2A 경로로 실행한다. 서버 wiring은 A2A client와 model client를 동시에 주입할 수 있으므로, A2A가 설정된 환경에서는 Plane-origin authoring task가 `/v1/responses` bridge까지 도달하지 않는다. + +### 해결 방법 + +- `TaskWorker.runTask`에서 authoring task 판별을 A2A 우선 분기보다 앞에 둔다. +- authoring task이면 `w.Model.Generate`를 사용하고, `w.Model == nil`이면 명확한 error를 반환한다. +- non-authoring task의 기존 A2A 우선 동작과 generic model fallback은 유지한다. + +### 수정 파일 및 체크리스트 + +- [ ] `services/core/internal/scheduler/jobs.go` +- [ ] `services/core/internal/scheduler/jobs_test.go` + +### 테스트 작성 + +- worker에 `Agent`와 `Model`을 둘 다 넣고 Plane-origin checkout metadata task를 실행했을 때 fake model이 호출되고 fake agent는 호출되지 않음을 검증한다. +- manual/non-plane task는 기존처럼 A2A가 우선 호출됨을 검증한다. +- authoring task인데 model client가 nil이면 명확한 error를 반환하는지 검증한다. + +### 중간 검증 + +```bash +cd services/core && go test ./internal/scheduler +``` + +## [REVIEW_API-3] Authoring in-progress 상태 metadata + +### 문제 + +`authoring_run_state` 값 목록에는 `in_progress`가 있지만 실제 저장은 성공/실패 경로뿐이다. 실행 중인 authoring task에 `authoring_run_updated_at`이 없으면 stale 판단 기준이 없다. + +### 해결 방법 + +- Plane-origin authoring task가 running으로 전환될 때 `authoring_run_state=in_progress`와 `authoring_run_updated_at=`를 task metadata에 저장한다. +- 이 저장 경계는 기존 lifecycle 단일 metadata write 원칙을 유지해야 한다. 구현 선택지는 `Lifecycle.StartTask`에서 source/checkout metadata를 판별하거나 scheduler가 lifecycle에 명시적인 in-progress metadata를 전달하도록 interface를 확장하는 방식 중 기존 구조와 더 잘 맞는 쪽을 택한다. +- 성공/실패 시 기존 `succeeded`/`failed` overwrite가 유지되는지 확인한다. + +### 수정 파일 및 체크리스트 + +- [ ] `services/core/internal/workflow/lifecycle.go` +- [ ] `services/core/internal/workflow/model.go` +- [ ] `services/core/internal/workflow/service_test.go` +- [ ] 필요 시 `services/core/internal/scheduler/jobs.go` +- [ ] 필요 시 `services/core/internal/scheduler/jobs_test.go` + +### 테스트 작성 + +- authoring checkout metadata를 가진 Plane task가 `StartTask`/worker start 경계에서 `authoring_run_state=in_progress`와 timestamp를 저장하는지 검증한다. +- 성공 completion은 `succeeded`, failure는 `failed`로 덮어쓰는지 검증한다. +- non-authoring task에는 authoring metadata가 붙지 않는지 검증한다. + +### 중간 검증 + +```bash +cd services/core && go test ./internal/workflow ./internal/scheduler +``` + +## 최종 검증 + +```bash +cd services/core && go test ./internal/authoring ./internal/scheduler ./internal/workitempipeline ./internal/workflow +cd services/core && go test ./... +git diff --check +``` + +Go test cache output is acceptable for unchanged packages; changed packages must appear in output or be rerun directly as 중간 검증. diff --git a/agent-task/m-milestone-work-item-creation-sync/02+01_iop_authoring_bridge/CODE_REVIEW-cloud-G07.md b/agent-task/m-milestone-work-item-creation-sync/02+01_iop_authoring_bridge/CODE_REVIEW-cloud-G07.md deleted file mode 100644 index 0f587d4..0000000 --- a/agent-task/m-milestone-work-item-creation-sync/02+01_iop_authoring_bridge/CODE_REVIEW-cloud-G07.md +++ /dev/null @@ -1,120 +0,0 @@ - - -# Code Review Reference - API - -> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.** -> The task is NOT complete until every implementation-owned section below is filled in. -> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving. -> Fill implementation-owned sections, then stop with active files in place and report ready for review. -> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. -> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation. -> Finalization is review-agent-only. - -## 개요 - -date=2026-06-13 -task=m-milestone-work-item-creation-sync/02+01_iop_authoring_bridge, plan=0, tag=API - -## Roadmap Targets - -- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md` -- Task ids: - - `cycle-iop-http-ready`: IOP Edge OpenAI-compatible HTTP(`/v1/responses`) workspace agent 실행 통로를 선행 조건으로 확인한다. - - `cycle-authoring-run`: Plane 티켓 제목/본문과 provider work item identity를 입력으로 `slots/` workspace에서 1회 authoring run을 실행한다. -- Completion mode: check-on-pass - -## 이 파일을 읽는 리뷰 에이전트에게 - -> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. - -각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. - -## 구현 항목별 완료 여부 - -| 항목 | 완료 여부 | -|------|---------| -| [API-1] Responses Metadata Workspace Shape | [ ] | -| [API-2] Authoring Run Builder | [ ] | -| [API-3] Authoring State Boundary | [ ] | - -## 구현 체크리스트 - -- [ ] `model.GenerateInput.Metadata`가 `metadata.workspace` object를 표현할 수 있게 additive typed metadata 구조를 도입하고 기존 flat metadata 호환을 유지한다. -- [ ] IOP workspace authoring request builder를 추가해 slot path, Plane ticket title/body, provider identity, project sync 설정, roadmap 작성 지시를 `/v1/responses` payload에 담는다. -- [ ] authoring run 상태 모델 `in_progress`, `succeeded`, `failed`와 stale 판단 기준을 저장 가능한 typed metadata 또는 새 persistence 경계로 남긴다. -- [ ] scheduler 또는 work item pipeline에서 Plane-origin authoring mode task가 일반 model completion과 구분되어 IOP HTTP bridge를 호출하도록 연결한다. -- [ ] `cd services/core && go test ./...`와 `git diff --check`를 실행한다. -- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. - -## 코드리뷰 전용 체크리스트 - -> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. -> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. - -- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. -- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. -- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G07_N.log`로 아카이브한다. -- [ ] active `PLAN-*-G??.md`를 `plan_cloud_G07_M.log`로 아카이브한다. -- [ ] `.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-milestone-work-item-creation-sync/02+01_iop_authoring_bridge/`를 archive로 이동한다. -- [ ] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. -- [ ] PASS split 작업이면 이동 후 빈 active parent를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. -- [ ] WARN/FAIL이면 다음 active plan/review 또는 `USER_REVIEW.md`를 작성한다. - -## 계획 대비 변경 사항 - -_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._ - -## 주요 설계 결정 - -_구현 에이전트가 주요 설계 결정 사항을 기록한다._ - -## 사용자 리뷰 요청 - -_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다._ - -- 상태: 없음 -- 사유 유형: 없음 -- 결정 필요: 없음 -- 차단 근거: 없음 -- 실행한 검증/명령: 없음 -- 자동 후속 불가 이유: 없음 -- 재개 조건: 없음 - -## 리뷰어를 위한 체크포인트 - -- `/v1/responses` request에 `metadata.workspace` object가 실제 JSON object로 들어가는지 확인한다. -- 일반 model task와 Plane-origin authoring task가 섞이지 않는지 확인한다. -- IOP CLI 실행이나 A2A fallback이 이 plan에 들어오지 않았는지 확인한다. - -## 검증 결과 - -_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ - -### API-1 중간 검증 -```bash -$ cd services/core && go test ./internal/adapters/openai -(output) -``` - -### API-2 중간 검증 -```bash -$ cd services/core && go test ./internal/authoring ./internal/scheduler -(output) -``` - -### API-3 중간 검증 -```bash -$ cd services/core && go test ./internal/workflow ./internal/scheduler -(output) -``` - -### 최종 검증 -```bash -$ cd services/core && go test ./... -$ git diff --check -(output) -``` - ---- diff --git a/services/core/internal/adapters/openai/client.go b/services/core/internal/adapters/openai/client.go index b21327c..33c49a3 100644 --- a/services/core/internal/adapters/openai/client.go +++ b/services/core/internal/adapters/openai/client.go @@ -71,7 +71,7 @@ func (c *Client) Generate(ctx context.Context, input model.GenerateInput) (model Model: modelName, Input: input.Input, Instructions: input.Instructions, - Metadata: input.Metadata, + Metadata: buildRequestMetadata(input), Stream: false, Temperature: input.Temperature, TopP: input.TopP, @@ -145,7 +145,7 @@ type responsesRequest struct { Model string `json:"model"` Input string `json:"input"` Instructions string `json:"instructions,omitempty"` - Metadata map[string]string `json:"metadata,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` Stream bool `json:"stream"` Temperature *float64 `json:"temperature,omitempty"` TopP *float64 `json:"top_p,omitempty"` @@ -249,6 +249,22 @@ func responsesURL(base string) (string, error) { return parsed.String(), nil } +// buildRequestMetadata merges flat string metadata with typed WorkspaceMetadata. +// WorkspaceMetadata wins on key collision ("workspace" key). +func buildRequestMetadata(input model.GenerateInput) map[string]any { + if len(input.Metadata) == 0 && input.WorkspaceMetadata == nil { + return nil + } + merged := make(map[string]any, len(input.Metadata)+1) + for k, v := range input.Metadata { + merged[k] = v + } + if input.WorkspaceMetadata != nil { + merged["workspace"] = input.WorkspaceMetadata + } + return merged +} + func firstNonZero(values ...int) int { for _, value := range values { if value != 0 { diff --git a/services/core/internal/adapters/openai/client_test.go b/services/core/internal/adapters/openai/client_test.go index af8d760..ad560d3 100644 --- a/services/core/internal/adapters/openai/client_test.go +++ b/services/core/internal/adapters/openai/client_test.go @@ -95,6 +95,61 @@ func TestGenerateCallsResponsesAPI(t *testing.T) { } } +func TestGenerateWorkspaceMetadataIsObject(t *testing.T) { + var gotBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode request: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"r1","model":"m","output_text":"ok","usage":{"total_tokens":1}}`)) + })) + defer server.Close() + + client := NewClient(Config{BaseURL: server.URL, Model: "m"}, nil) + _, err := client.Generate(context.Background(), model.GenerateInput{ + Input: "author milestone", + Metadata: map[string]string{ + "task_id": "task-abc", + "source": "plane", + }, + WorkspaceMetadata: &model.WorkspaceMetadata{ + Path: "/home/user/workspace/nomadcode/slots/000", + SourceBranch: "develop", + Provider: "plane", + WorkItemID: "NOMAD-42", + }, + }) + if err != nil { + t.Fatalf("Generate returned error: %v", err) + } + + metadata, ok := gotBody["metadata"].(map[string]any) + if !ok { + t.Fatalf("expected metadata object, got %T: %#v", gotBody["metadata"], gotBody["metadata"]) + } + if metadata["task_id"] != "task-abc" || metadata["source"] != "plane" { + t.Fatalf("flat metadata not preserved: %#v", metadata) + } + ws, ok := metadata["workspace"].(map[string]any) + if !ok { + t.Fatalf("expected metadata.workspace object, got %T: %#v", metadata["workspace"], metadata["workspace"]) + } + if ws["path"] != "/home/user/workspace/nomadcode/slots/000" { + t.Fatalf("unexpected workspace.path: %#v", ws["path"]) + } + if ws["source_branch"] != "develop" { + t.Fatalf("unexpected workspace.source_branch: %#v", ws["source_branch"]) + } + if ws["provider"] != "plane" { + t.Fatalf("unexpected workspace.provider: %#v", ws["provider"]) + } + if ws["work_item_id"] != "NOMAD-42" { + t.Fatalf("unexpected workspace.work_item_id: %#v", ws["work_item_id"]) + } +} + func TestGenerateExtractsOutputContent(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") diff --git a/services/core/internal/authoring/request.go b/services/core/internal/authoring/request.go new file mode 100644 index 0000000..17b1fdf --- /dev/null +++ b/services/core/internal/authoring/request.go @@ -0,0 +1,131 @@ +package authoring + +import ( + "encoding/json" + "strings" + + "github.com/nomadcode/nomadcode-core/internal/model" + "github.com/nomadcode/nomadcode-core/internal/storage" + "github.com/nomadcode/nomadcode-core/internal/workitempipeline" +) + +// pipelinePayload is the actual task.Payload shape produced by +// workitem.BuildCreateTaskInput. "message" carries the Plane ticket body +// (comment > description > title), and "work_item" carries the provider +// identity map (provider, tenant, project, id, state_id, external_url). +// "prompt" and "instructions" are accepted as a generic override shape. +type pipelinePayload struct { + // pipeline shape (set by workitem.BuildCreateTaskInput) + Message string `json:"message"` + WorkItem map[string]string `json:"work_item"` + // generic override shape + Prompt string `json:"prompt"` + Instructions string `json:"instructions"` +} + +// isAuthoringTask returns true when the task is a Plane-origin authoring task +// that should be routed through the IOP workspace authoring bridge. The criteria +// are: source=="plane" and task metadata contains a "checkout" key with a +// non-empty slot path. +func isAuthoringTask(task storage.Task) (workitempipeline.CheckoutMetadata, bool) { + if task.Source != "plane" { + return workitempipeline.CheckoutMetadata{}, false + } + if len(task.Metadata) == 0 || string(task.Metadata) == "null" { + return workitempipeline.CheckoutMetadata{}, false + } + var meta struct { + Checkout *workitempipeline.CheckoutMetadata `json:"checkout"` + } + if err := json.Unmarshal(task.Metadata, &meta); err != nil || meta.Checkout == nil { + return workitempipeline.CheckoutMetadata{}, false + } + if strings.TrimSpace(meta.Checkout.SlotPath) == "" { + return workitempipeline.CheckoutMetadata{}, false + } + return *meta.Checkout, true +} + +// providerIdentity resolves the Plane provider and work item ID for a task. +// Priority: storage.Task.ExternalProvider/ExternalID (set by workitem pipeline) +// → work_item payload map → task.Source / task.ID fallback. +func providerIdentity(task storage.Task, payload pipelinePayload) (provider, workItemID string) { + provider = task.Source + workItemID = task.ID + + if task.ExternalProvider != nil && *task.ExternalProvider != "" { + provider = *task.ExternalProvider + } + if task.ExternalID != nil && *task.ExternalID != "" { + workItemID = *task.ExternalID + return provider, workItemID + } + + // fall back to work_item map in payload (also set by workitem pipeline) + if id := strings.TrimSpace(payload.WorkItem["id"]); id != "" { + workItemID = id + } + if p := strings.TrimSpace(payload.WorkItem["provider"]); p != "" { + provider = p + } + return provider, workItemID +} + +// BuildAuthoringGenerateInput constructs a workspace-bound model.GenerateInput +// for Plane-origin authoring tasks. Returns false when the task does not qualify +// as an authoring task (non-Plane source or missing checkout metadata). +func BuildAuthoringGenerateInput(task storage.Task) (model.GenerateInput, bool) { + checkout, ok := isAuthoringTask(task) + if !ok { + return model.GenerateInput{}, false + } + + // input priority: prompt override > pipeline message > task title + input := strings.TrimSpace(task.Title) + instructions := "" + + var p pipelinePayload + if len(task.Payload) > 0 && string(task.Payload) != "null" && string(task.Payload) != "{}" { + _ = json.Unmarshal(task.Payload, &p) + } + + if body := strings.TrimSpace(p.Message); body != "" { + input = body + } + if override := strings.TrimSpace(p.Prompt); override != "" { + input = override + } + instructions = strings.TrimSpace(p.Instructions) + + if instructions == "" { + instructions = buildAuthoringInstructions(task, checkout) + } + + provider, workItemID := providerIdentity(task, p) + + return model.GenerateInput{ + Input: input, + Instructions: instructions, + Metadata: map[string]string{ + "task_id": task.ID, + "source": task.Source, + }, + WorkspaceMetadata: &model.WorkspaceMetadata{ + Path: checkout.SlotPath, + SourceBranch: checkout.SourceBranch, + Provider: provider, + WorkItemID: workItemID, + }, + }, true +} + +func buildAuthoringInstructions(task storage.Task, checkout workitempipeline.CheckoutMetadata) string { + var b strings.Builder + b.WriteString("You are a workspace agent running inside a NomadCode slot checkout.\n") + b.WriteString("Your task is to author or update the agent-roadmap Milestone file for the following Plane work item.\n\n") + b.WriteString("Use the roadmap skill to create or update the Milestone under the active Phase, then commit and push to the develop branch.\n\n") + b.WriteString("Work item title: " + task.Title + "\n") + b.WriteString("Workspace slot path: " + checkout.SlotPath + "\n") + b.WriteString("Source branch: " + checkout.SourceBranch + "\n") + return b.String() +} diff --git a/services/core/internal/authoring/request_test.go b/services/core/internal/authoring/request_test.go new file mode 100644 index 0000000..c9cb45b --- /dev/null +++ b/services/core/internal/authoring/request_test.go @@ -0,0 +1,185 @@ +package authoring + +import ( + "encoding/json" + "testing" + + "github.com/nomadcode/nomadcode-core/internal/storage" +) + +func checkoutMeta(slotPath, sourceBranch string) json.RawMessage { + raw, _ := json.Marshal(map[string]any{ + "checkout": map[string]any{ + "slot_path": slotPath, + "source_branch": sourceBranch, + "slot_index": 0, + "slot_id": 1, + }, + }) + return raw +} + +func TestBuildAuthoringGenerateInputPlaneOrigin(t *testing.T) { + task := storage.Task{ + ID: "task-1", + Title: "Create milestone for NOMAD-42", + Source: "plane", + Payload: json.RawMessage(`{"prompt":"Author the Milestone for NOMAD-42","instructions":"Use roadmap skill"}`), + Metadata: checkoutMeta("/home/user/workspace/nomadcode/slots/000", "develop"), + } + + input, ok := BuildAuthoringGenerateInput(task) + if !ok { + t.Fatal("expected authoring task to be detected") + } + // prompt override wins over message/title + if input.Input != "Author the Milestone for NOMAD-42" { + t.Errorf("unexpected input: %q", input.Input) + } + if input.Instructions != "Use roadmap skill" { + t.Errorf("unexpected instructions: %q", input.Instructions) + } + if input.WorkspaceMetadata == nil { + t.Fatal("expected WorkspaceMetadata to be set") + } + if input.WorkspaceMetadata.Path != "/home/user/workspace/nomadcode/slots/000" { + t.Errorf("unexpected workspace path: %q", input.WorkspaceMetadata.Path) + } + if input.WorkspaceMetadata.SourceBranch != "develop" { + t.Errorf("unexpected source branch: %q", input.WorkspaceMetadata.SourceBranch) + } + if input.Metadata["task_id"] != "task-1" || input.Metadata["source"] != "plane" { + t.Errorf("unexpected metadata: %#v", input.Metadata) + } +} + +func TestBuildAuthoringGenerateInputPipelinePayload(t *testing.T) { + // pipeline payload shape produced by workitem.BuildCreateTaskInput: + // message = Plane ticket body, work_item = provider identity map + pipelineBody := `{"message":"Plane ticket body text","work_item":{"provider":"plane","tenant":"acme","project":"proj-1","id":"NOMAD-42","state_id":"backlog","external_url":"https://plane.example.com/NOMAD-42"}}` + extID := "NOMAD-42" + extProvider := "plane" + task := storage.Task{ + ID: "task-2", + Title: "fallback title", + Source: "plane", + Payload: json.RawMessage(pipelineBody), + Metadata: checkoutMeta("/home/user/workspace/nomadcode/slots/001", "develop"), + ExternalProvider: &extProvider, + ExternalID: &extID, + } + + input, ok := BuildAuthoringGenerateInput(task) + if !ok { + t.Fatal("expected authoring task to be detected") + } + // message field is used as input when no prompt override is present + if input.Input != "Plane ticket body text" { + t.Errorf("expected pipeline message as input, got %q", input.Input) + } + if input.WorkspaceMetadata == nil { + t.Fatal("expected WorkspaceMetadata to be set") + } + // provider identity from ExternalProvider/ExternalID (highest priority) + if input.WorkspaceMetadata.Provider != "plane" { + t.Errorf("unexpected provider: %q", input.WorkspaceMetadata.Provider) + } + if input.WorkspaceMetadata.WorkItemID != "NOMAD-42" { + t.Errorf("unexpected work_item_id: %q", input.WorkspaceMetadata.WorkItemID) + } +} + +func TestBuildAuthoringGenerateInputProviderIdentityFallsBackToWorkItemMap(t *testing.T) { + // When ExternalProvider/ExternalID are nil, fall back to work_item map in payload. + pipelineBody := `{"message":"body","work_item":{"provider":"plane","id":"NOMAD-99"}}` + task := storage.Task{ + ID: "task-3", + Source: "plane", + Payload: json.RawMessage(pipelineBody), + Metadata: checkoutMeta("/home/user/workspace/nomadcode/slots/000", "develop"), + } + + input, ok := BuildAuthoringGenerateInput(task) + if !ok { + t.Fatal("expected authoring task to be detected") + } + if input.WorkspaceMetadata.Provider != "plane" { + t.Errorf("unexpected provider: %q", input.WorkspaceMetadata.Provider) + } + if input.WorkspaceMetadata.WorkItemID != "NOMAD-99" { + t.Errorf("unexpected work_item_id: %q", input.WorkspaceMetadata.WorkItemID) + } +} + +func TestBuildAuthoringGenerateInputFallbackInstructions(t *testing.T) { + task := storage.Task{ + ID: "task-2", + Title: "My work item title", + Source: "plane", + Payload: json.RawMessage(`{}`), + Metadata: checkoutMeta("/home/user/workspace/nomadcode/slots/001", "develop"), + } + + input, ok := BuildAuthoringGenerateInput(task) + if !ok { + t.Fatal("expected authoring task to be detected") + } + if input.Input != "My work item title" { + t.Errorf("unexpected input fallback: %q", input.Input) + } + if input.Instructions == "" { + t.Error("expected fallback instructions to be set") + } + if input.WorkspaceMetadata.Path != "/home/user/workspace/nomadcode/slots/001" { + t.Errorf("unexpected workspace path: %q", input.WorkspaceMetadata.Path) + } +} + +func TestBuildAuthoringGenerateInputNonPlaneSource(t *testing.T) { + task := storage.Task{ + ID: "task-3", + Title: "Generic task", + Source: "manual", + Payload: json.RawMessage(`{"prompt":"do something"}`), + Metadata: checkoutMeta("/home/user/workspace/nomadcode/slots/000", "develop"), + } + + _, ok := BuildAuthoringGenerateInput(task) + if ok { + t.Error("expected non-plane task to not be detected as authoring task") + } +} + +func TestBuildAuthoringGenerateInputMissingCheckout(t *testing.T) { + task := storage.Task{ + ID: "task-4", + Title: "Plane task without checkout", + Source: "plane", + Metadata: json.RawMessage(`{"agent_run_state":"running"}`), + } + + _, ok := BuildAuthoringGenerateInput(task) + if ok { + t.Error("expected task without checkout metadata to not be detected as authoring task") + } +} + +func TestBuildAuthoringGenerateInputEmptySlotPath(t *testing.T) { + raw, _ := json.Marshal(map[string]any{ + "checkout": map[string]any{ + "slot_path": "", + "source_branch": "develop", + }, + }) + task := storage.Task{ + ID: "task-5", + Title: "Plane task with empty slot path", + Source: "plane", + Metadata: raw, + } + + _, ok := BuildAuthoringGenerateInput(task) + if ok { + t.Error("expected task with empty slot path to not be detected as authoring task") + } +} diff --git a/services/core/internal/model/model.go b/services/core/internal/model/model.go index 4b0dc67..879e500 100644 --- a/services/core/internal/model/model.go +++ b/services/core/internal/model/model.go @@ -9,14 +9,26 @@ type Client interface { Generate(ctx context.Context, input GenerateInput) (GenerateResult, error) } +// WorkspaceMetadata carries workspace-bound authoring context passed as +// metadata.workspace in the /v1/responses payload. Fields here map directly +// to IOP Edge workspace routing fields so the receiving agent can resolve the +// correct slot checkout without CLI-level invocation by NomadCode. +type WorkspaceMetadata struct { + Path string `json:"path"` + SourceBranch string `json:"source_branch,omitempty"` + Provider string `json:"provider,omitempty"` + WorkItemID string `json:"work_item_id,omitempty"` +} + type GenerateInput struct { - Model string - Instructions string - Input string - Metadata map[string]string - MaxOutputTokens int - Temperature *float64 - TopP *float64 + Model string + Instructions string + Input string + Metadata map[string]string + WorkspaceMetadata *WorkspaceMetadata + MaxOutputTokens int + Temperature *float64 + TopP *float64 } type GenerateResult struct { diff --git a/services/core/internal/scheduler/jobs.go b/services/core/internal/scheduler/jobs.go index 7c94f83..5956243 100644 --- a/services/core/internal/scheduler/jobs.go +++ b/services/core/internal/scheduler/jobs.go @@ -12,6 +12,7 @@ import ( "github.com/riverqueue/river" "github.com/nomadcode/nomadcode-core/internal/agent" + "github.com/nomadcode/nomadcode-core/internal/authoring" "github.com/nomadcode/nomadcode-core/internal/model" "github.com/nomadcode/nomadcode-core/internal/notification" "github.com/nomadcode/nomadcode-core/internal/storage" @@ -38,6 +39,7 @@ type TaskLifecycle interface { CompleteTask(ctx context.Context, id string, result json.RawMessage) (storage.Task, error) FailTask(ctx context.Context, id string, message string) (storage.Task, error) FailTaskWithMetadata(ctx context.Context, id string, input workflow.FailureInput) (storage.Task, error) + MergeTaskMetadata(ctx context.Context, id string, updates map[string]any) (storage.Task, error) } type TaskWorker struct { @@ -62,6 +64,18 @@ func (w *TaskWorker) Work(ctx context.Context, job *river.Job[TaskJobArgs]) erro return err } + // Record authoring_run_state=in_progress before execution so a stale + // detection layer can compare authoring_run_updated_at against a threshold + // without waiting for the run to complete or fail. + if _, isAuthoring := authoring.BuildAuthoringGenerateInput(task); isAuthoring { + if _, mergeErr := w.Lifecycle.MergeTaskMetadata(ctx, taskID, map[string]any{ + workflow.MetadataKeyAuthoringRunState: "in_progress", + workflow.MetadataKeyAuthoringRunUpdatedAt: time.Now().UTC().Format(time.RFC3339), + }); mergeErr != nil && w.Logger != nil { + w.Logger.Warn("authoring in_progress metadata merge failed", "task_id", taskID, "error", mergeErr) + } + } + attempt, reason, occurredAt := parseTaskEventContext(task, notification.TaskEventRunning) w.notifyTaskEvent(ctx, notification.TaskEvent{ Type: notification.TaskEventRunning, @@ -82,7 +96,7 @@ func (w *TaskWorker) Work(ctx context.Context, job *river.Job[TaskJobArgs]) erro result, message, err := w.runTask(runCtx, task) if err != nil { - w.markFailed(taskID, err) + w.markFailedWithTask(taskID, task, err) return err } @@ -111,6 +125,38 @@ func (w *TaskWorker) Work(ctx context.Context, job *river.Job[TaskJobArgs]) erro } func (w *TaskWorker) runTask(ctx context.Context, task storage.Task) (json.RawMessage, string, error) { + // Plane-origin authoring tasks always route through the IOP OpenAI-compatible + // HTTP bridge even when an A2A agent client is configured, because IOP runs + // workspace-bound agent authoring rather than a generic A2A task. + generateInput, isAuthoring := authoring.BuildAuthoringGenerateInput(task) + if isAuthoring { + if w.Model == nil { + return nil, "", fmt.Errorf("model client is required for authoring tasks") + } + generated, err := w.Model.Generate(ctx, generateInput) + if err != nil { + return nil, "", err + } + result := map[string]any{ + "message": generated.Text, + "mode": "authoring_run", + "model": generated.Model, + "response_id": generated.ID, + "authoring_run_state": "succeeded", + "authoring_run_updated_at": time.Now().UTC().Format(time.RFC3339), + "usage": map[string]int{ + "input_tokens": generated.Usage.InputTokens, + "output_tokens": generated.Usage.OutputTokens, + "total_tokens": generated.Usage.TotalTokens, + }, + } + raw, err := json.Marshal(result) + if err != nil { + return nil, "", err + } + return raw, "authoring run completed", nil + } + if w.Agent != nil { return w.runAgentTask(ctx, task) } @@ -335,6 +381,54 @@ func (w *TaskWorker) notifyTaskEvent(ctx context.Context, event notification.Tas } } +// markFailedWithTask is markFailed with the originating task available so +// authoring-specific failure metadata can be appended before the lifecycle call. +func (w *TaskWorker) markFailedWithTask(taskID string, task storage.Task, err error) { + if err == nil || w.Lifecycle == nil { + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + msg := err.Error() + failureType := workflow.FailureTypeExecution + if errors.Is(err, context.DeadlineExceeded) { + msg = "timeout" + failureType = workflow.FailureTypeTimeout + } + + failInput := workflow.FailureInput{Message: msg, Type: failureType} + + if _, isAuthoring := authoring.BuildAuthoringGenerateInput(task); isAuthoring { + failInput.ExtraMetadata = map[string]any{ + workflow.MetadataKeyAuthoringRunState: "failed", + workflow.MetadataKeyAuthoringRunUpdatedAt: time.Now().UTC().Format(time.RFC3339), + workflow.MetadataKeyAuthoringFailureType: string(failureType), + } + } + + failed, failErr := w.Lifecycle.FailTaskWithMetadata(ctx, taskID, failInput) + if failErr != nil { + if w.Logger != nil { + w.Logger.Error("failed to mark task failed", "task_id", taskID, "error", failErr) + } + return + } + + attempt, reason, occurredAt := parseTaskEventContext(failed, notification.TaskEventFailed) + w.notifyTaskEvent(ctx, notification.TaskEvent{ + Type: notification.TaskEventFailed, + TaskID: failed.ID, + Title: failed.Title, + Status: failed.Status, + Message: msg, + Attempt: attempt, + Reason: reason, + OccurredAt: occurredAt, + }) +} + func (w *TaskWorker) markFailed(taskID string, err error) { if err == nil || w.Lifecycle == nil { return diff --git a/services/core/internal/scheduler/jobs_test.go b/services/core/internal/scheduler/jobs_test.go index de3e226..9697825 100644 --- a/services/core/internal/scheduler/jobs_test.go +++ b/services/core/internal/scheduler/jobs_test.go @@ -136,11 +136,12 @@ func (c fakeAgentClient) CancelTask(context.Context, agent.CancelTaskInput) (age } type fakeTaskLifecycle struct { - started []string - completed []string - failed []string - failMessages []string - failInputs []workflow.FailureInput + started []string + completed []string + failed []string + failMessages []string + failInputs []workflow.FailureInput + mergedMetadata []map[string]any task storage.Task } @@ -152,7 +153,17 @@ func (f *fakeTaskLifecycle) StartTask(ctx context.Context, id string) (storage.T f.started = append(f.started, id) f.task.Status = "running" attempt := len(f.started) - f.task.Metadata = json.RawMessage(fmt.Sprintf(`{"attempt":%d}`, attempt)) + // Merge attempt into existing metadata rather than replacing it, so that + // checkout metadata (used for authoring task detection) is preserved. + var existing map[string]any + if len(f.task.Metadata) > 0 && string(f.task.Metadata) != "null" { + _ = json.Unmarshal(f.task.Metadata, &existing) + } + if existing == nil { + existing = make(map[string]any) + } + existing["attempt"] = attempt + f.task.Metadata, _ = json.Marshal(existing) f.task.UpdatedAt = time.Now().UTC() return f.task, nil } @@ -190,6 +201,11 @@ func (f *fakeTaskLifecycle) FailTask(ctx context.Context, id string, message str }) } +func (f *fakeTaskLifecycle) MergeTaskMetadata(_ context.Context, _ string, updates map[string]any) (storage.Task, error) { + f.mergedMetadata = append(f.mergedMetadata, updates) + return f.task, nil +} + func TestWorkCompletesThroughLifecycle(t *testing.T) { fakeLifecycle := &fakeTaskLifecycle{ task: storage.Task{ @@ -433,6 +449,299 @@ func TestWorkRetriesAfterFailureState(t *testing.T) { } } +func checkoutTaskMeta(slotPath, sourceBranch string) json.RawMessage { + raw, _ := json.Marshal(map[string]any{ + "checkout": map[string]any{ + "slot_path": slotPath, + "source_branch": sourceBranch, + "slot_index": 0, + "slot_id": 1, + }, + }) + return raw +} + +func TestRunTaskRoutesPlaneOriginToAuthoringBridge(t *testing.T) { + var capturedInput model.GenerateInput + + fakeModel := fakeModelClient{ + generate: func(_ context.Context, input model.GenerateInput) (model.GenerateResult, error) { + capturedInput = input + return model.GenerateResult{ + ID: "resp-1", + Model: "m", + Text: "authoring done", + }, nil + }, + } + + worker := &TaskWorker{Model: fakeModel} + + task := storage.Task{ + ID: "task-plane-1", + Title: "NOMAD-42: Create milestone", + Source: "plane", + Payload: json.RawMessage(`{"prompt":"Author milestone for NOMAD-42","instructions":"Use roadmap skill"}`), + Metadata: checkoutTaskMeta("/home/user/workspace/nomadcode/slots/000", "develop"), + } + + raw, msg, err := worker.runTask(context.Background(), task) + if err != nil { + t.Fatalf("runTask returned error: %v", err) + } + if msg == "" { + t.Fatal("expected non-empty message") + } + if raw == nil { + t.Fatal("expected non-nil result") + } + + if capturedInput.WorkspaceMetadata == nil { + t.Fatal("expected WorkspaceMetadata to be set for Plane-origin authoring task") + } + if capturedInput.WorkspaceMetadata.Path != "/home/user/workspace/nomadcode/slots/000" { + t.Errorf("unexpected workspace path: %q", capturedInput.WorkspaceMetadata.Path) + } + if capturedInput.WorkspaceMetadata.SourceBranch != "develop" { + t.Errorf("unexpected source branch: %q", capturedInput.WorkspaceMetadata.SourceBranch) + } +} + +func TestRunTaskAuthoringPreemptsA2AClient(t *testing.T) { + var modelCalled bool + fakeModel := fakeModelClient{ + generate: func(_ context.Context, input model.GenerateInput) (model.GenerateResult, error) { + modelCalled = true + if input.WorkspaceMetadata == nil { + t.Error("expected WorkspaceMetadata for authoring task routed through model") + } + return model.GenerateResult{Text: "done", Model: "m"}, nil + }, + } + // A2A client is set — authoring task must still go to model bridge. + worker := &TaskWorker{ + Model: fakeModel, + Agent: fakeAgentClient{err: errors.New("a2a should not be called")}, + } + + task := storage.Task{ + ID: "task-preempt", + Source: "plane", + Payload: json.RawMessage(`{}`), + Metadata: checkoutTaskMeta("/home/user/workspace/nomadcode/slots/000", "develop"), + } + + raw, _, err := worker.runTask(context.Background(), task) + if err != nil { + t.Fatalf("runTask returned error: %v", err) + } + if !modelCalled { + t.Error("expected model client to be called for authoring task, not A2A") + } + var result map[string]any + _ = json.Unmarshal(raw, &result) + if result["mode"] != "authoring_run" { + t.Errorf("expected mode=authoring_run, got %#v", result["mode"]) + } +} + +func TestRunTaskGenericModelHasNoWorkspaceMetadata(t *testing.T) { + var capturedInput model.GenerateInput + + fakeModel := fakeModelClient{ + generate: func(_ context.Context, input model.GenerateInput) (model.GenerateResult, error) { + capturedInput = input + return model.GenerateResult{Text: "done"}, nil + }, + } + + worker := &TaskWorker{Model: fakeModel} + + task := storage.Task{ + ID: "task-manual-1", + Title: "Generic task", + Source: "manual", + Payload: json.RawMessage(`{"prompt":"do something"}`), + } + + if _, _, err := worker.runTask(context.Background(), task); err != nil { + t.Fatalf("runTask returned error: %v", err) + } + if capturedInput.WorkspaceMetadata != nil { + t.Errorf("expected no WorkspaceMetadata for generic task, got %#v", capturedInput.WorkspaceMetadata) + } +} + +type fakeModelClient struct { + generate func(context.Context, model.GenerateInput) (model.GenerateResult, error) +} + +func (f fakeModelClient) Generate(ctx context.Context, input model.GenerateInput) (model.GenerateResult, error) { + return f.generate(ctx, input) +} + +func TestRunTaskAuthoringSuccessRecordsState(t *testing.T) { + fakeModel := fakeModelClient{ + generate: func(_ context.Context, _ model.GenerateInput) (model.GenerateResult, error) { + return model.GenerateResult{Text: "milestone authored", Model: "m"}, nil + }, + } + worker := &TaskWorker{Model: fakeModel} + + task := storage.Task{ + ID: "task-authoring-ok", + Title: "Author milestone", + Source: "plane", + Payload: json.RawMessage(`{}`), + Metadata: checkoutTaskMeta("/home/user/workspace/nomadcode/slots/000", "develop"), + } + + raw, _, err := worker.runTask(context.Background(), task) + if err != nil { + t.Fatalf("runTask returned error: %v", err) + } + + var result map[string]any + if err := json.Unmarshal(raw, &result); err != nil { + t.Fatalf("unmarshal result: %v", err) + } + if result["authoring_run_state"] != "succeeded" { + t.Errorf("expected authoring_run_state=succeeded, got %#v", result["authoring_run_state"]) + } + if result["authoring_run_updated_at"] == nil || result["authoring_run_updated_at"] == "" { + t.Error("expected authoring_run_updated_at to be set") + } + if result["mode"] != "authoring_run" { + t.Errorf("expected mode=authoring_run, got %#v", result["mode"]) + } +} + +func TestWorkRecordsAuthoringInProgressBeforeExecution(t *testing.T) { + fakeModel := fakeModelClient{ + generate: func(_ context.Context, _ model.GenerateInput) (model.GenerateResult, error) { + return model.GenerateResult{Text: "done", Model: "m"}, nil + }, + } + + fakeLifecycle := &fakeTaskLifecycle{ + task: storage.Task{ + ID: "task-auth-ip", + Source: "plane", + Status: "pending", + Metadata: checkoutTaskMeta("/home/user/workspace/nomadcode/slots/000", "develop"), + }, + } + + worker := &TaskWorker{ + Lifecycle: fakeLifecycle, + Model: fakeModel, + } + + job := &river.Job[TaskJobArgs]{Args: TaskJobArgs{TaskID: "task-auth-ip"}} + if err := worker.Work(context.Background(), job); err != nil { + t.Fatalf("Work returned error: %v", err) + } + + // MergeTaskMetadata must have been called at least once with in_progress. + found := false + for _, m := range fakeLifecycle.mergedMetadata { + if m[workflow.MetadataKeyAuthoringRunState] == "in_progress" { + found = true + if m[workflow.MetadataKeyAuthoringRunUpdatedAt] == nil { + t.Error("expected authoring_run_updated_at to be set with in_progress") + } + } + } + if !found { + t.Errorf("expected MergeTaskMetadata to be called with authoring_run_state=in_progress, got %v", fakeLifecycle.mergedMetadata) + } +} + +func TestWorkDoesNotRecordAuthoringInProgressForGenericTask(t *testing.T) { + fakeLifecycle := &fakeTaskLifecycle{ + task: storage.Task{ + ID: "task-generic-ip", + Source: "manual", + Status: "pending", + }, + } + + worker := &TaskWorker{Lifecycle: fakeLifecycle} + + job := &river.Job[TaskJobArgs]{Args: TaskJobArgs{TaskID: "task-generic-ip"}} + if err := worker.Work(context.Background(), job); err != nil { + t.Fatalf("Work returned error: %v", err) + } + + for _, m := range fakeLifecycle.mergedMetadata { + if m[workflow.MetadataKeyAuthoringRunState] != nil { + t.Errorf("expected no authoring metadata for generic task, got %v", m) + } + } +} + +func TestMarkFailedWithTaskAuthoringRecordsState(t *testing.T) { + lc := &fakeTaskLifecycle{ + task: storage.Task{ + ID: "task-authoring-fail", + Title: "Author milestone", + Source: "plane", + Status: "running", + Metadata: checkoutTaskMeta("/home/user/workspace/nomadcode/slots/000", "develop"), + }, + } + lc.task.Metadata = checkoutTaskMeta("/home/user/workspace/nomadcode/slots/000", "develop") + + worker := &TaskWorker{Lifecycle: lc} + + task := storage.Task{ + ID: "task-authoring-fail", + Source: "plane", + Metadata: checkoutTaskMeta("/home/user/workspace/nomadcode/slots/000", "develop"), + } + + worker.markFailedWithTask("task-authoring-fail", task, errors.New("IOP bridge failed")) + + if len(lc.failInputs) != 1 { + t.Fatalf("expected 1 fail input, got %d", len(lc.failInputs)) + } + extra := lc.failInputs[0].ExtraMetadata + if extra == nil { + t.Fatal("expected ExtraMetadata to be set for authoring task failure") + } + if extra[workflow.MetadataKeyAuthoringRunState] != "failed" { + t.Errorf("expected authoring_run_state=failed, got %#v", extra[workflow.MetadataKeyAuthoringRunState]) + } + if extra[workflow.MetadataKeyAuthoringRunUpdatedAt] == nil { + t.Error("expected authoring_run_updated_at to be set") + } +} + +func TestMarkFailedWithTaskGenericNoAuthoringState(t *testing.T) { + lc := &fakeTaskLifecycle{ + task: storage.Task{ + ID: "task-generic-fail", + Source: "manual", + Status: "running", + }, + } + worker := &TaskWorker{Lifecycle: lc} + + task := storage.Task{ + ID: "task-generic-fail", + Source: "manual", + } + + worker.markFailedWithTask("task-generic-fail", task, errors.New("generic error")) + + if len(lc.failInputs) != 1 { + t.Fatalf("expected 1 fail input, got %d", len(lc.failInputs)) + } + if lc.failInputs[0].ExtraMetadata != nil { + t.Errorf("expected no ExtraMetadata for generic task, got %#v", lc.failInputs[0].ExtraMetadata) + } +} + type logRecord struct { msg string args map[string]any diff --git a/services/core/internal/workflow/lifecycle.go b/services/core/internal/workflow/lifecycle.go index 9893f7c..e1b540a 100644 --- a/services/core/internal/workflow/lifecycle.go +++ b/services/core/internal/workflow/lifecycle.go @@ -210,6 +210,16 @@ func (l *Lifecycle) CompleteTask(ctx context.Context, id string, result json.Raw if sVal, ok := resMap["summary"].(string); ok { summary = strings.TrimSpace(sVal) } + // Promote authoring run state keys from the result payload into + // task metadata so the lifecycle layer is the single writer. + for _, key := range []string{ + MetadataKeyAuthoringRunState, + MetadataKeyAuthoringRunUpdatedAt, + } { + if v, ok := resMap[key]; ok && v != nil { + updates[key] = v + } + } } } @@ -231,8 +241,9 @@ func (l *Lifecycle) CompleteTask(ctx context.Context, id string, result json.Raw } type FailureInput struct { - Message string `json:"message"` - Type FailureType `json:"type"` + Message string `json:"message"` + Type FailureType `json:"type"` + ExtraMetadata map[string]any `json:"extra_metadata,omitempty"` } func (l *Lifecycle) FailTaskWithMetadata(ctx context.Context, id string, input FailureInput) (storage.Task, error) { @@ -269,6 +280,9 @@ func (l *Lifecycle) FailTaskWithMetadata(ctx context.Context, id string, input F MetadataKeyFailedAt: time.Now().UTC().Format(time.RFC3339), MetadataKeyRetryable: retryable, } + for k, v := range input.ExtraMetadata { + updates[k] = v + } newMeta, err := mergeTaskMetadata(task.Metadata, updates) if err != nil { @@ -290,6 +304,21 @@ func (l *Lifecycle) FailTask(ctx context.Context, id string, message string) (st }) } +// MergeTaskMetadata merges the given updates into the task's current metadata +// without touching task status. Used by the scheduler to record authoring-specific +// state (e.g. authoring_run_state=in_progress) outside of status transitions. +func (l *Lifecycle) MergeTaskMetadata(ctx context.Context, id string, updates map[string]any) (storage.Task, error) { + task, err := l.store.GetTask(ctx, id) + if err != nil { + return storage.Task{}, err + } + newMeta, err := mergeTaskMetadata(task.Metadata, updates) + if err != nil { + return storage.Task{}, err + } + return l.store.UpdateMetadata(ctx, id, newMeta) +} + func (l *Lifecycle) CancelTask(ctx context.Context, id string, message string) (storage.Task, error) { task, err := l.store.GetTask(ctx, id) diff --git a/services/core/internal/workflow/model.go b/services/core/internal/workflow/model.go index d85d045..00cae06 100644 --- a/services/core/internal/workflow/model.go +++ b/services/core/internal/workflow/model.go @@ -49,6 +49,14 @@ const ( MetadataKeyPlanRef = "plan_ref" MetadataKeyAttempt = "attempt" MetadataKeyRetryable = "retryable" + + // Authoring run state keys for Plane-origin workspace authoring tasks. + // authoring_run_state values: "in_progress", "succeeded", "failed". + // Stale detection uses authoring_run_updated_at compared against a + // configured staleness threshold — no separate timeout state is stored. + MetadataKeyAuthoringRunState = "authoring_run_state" + MetadataKeyAuthoringRunUpdatedAt = "authoring_run_updated_at" + MetadataKeyAuthoringFailureType = "authoring_failure_type" )