feat: authoring bridge implementation and scheduler improvements

- Add authoring adapter for openai integration
- Move 02+01_iop_authoring_bridge to archive
- Update scheduler jobs and workflow lifecycle
- Enhance openai client and model definitions
This commit is contained in:
toki 2026-06-13 20:47:09 +09:00
parent f9c6be03ef
commit 6ab8dd8f63
15 changed files with 1413 additions and 138 deletions

View file

@ -0,0 +1,167 @@
<!-- task=m-milestone-work-item-creation-sync/02+01_iop_authoring_bridge plan=0 tag=API -->
# Code Review Reference - API
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a 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/<index>` 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-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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`를 새로 작성한다.

View file

@ -0,0 +1,209 @@
<!-- task=m-milestone-work-item-creation-sync/02+01_iop_authoring_bridge plan=1 tag=REVIEW_API -->
# 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/<index>` 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-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. 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-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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로 이동한다.

View file

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

View file

@ -0,0 +1,136 @@
<!-- task=m-milestone-work-item-creation-sync/02+01_iop_authoring_bridge plan=1 tag=REVIEW_API -->
# 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/<index>` 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=<RFC3339 UTC>`를 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 중간 검증.

View file

@ -1,120 +0,0 @@
<!-- task=m-milestone-work-item-creation-sync/02+01_iop_authoring_bridge plan=0 tag=API -->
# Code Review Reference - API
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a 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/<index>` 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-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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)
```
---

View file

@ -71,7 +71,7 @@ func (c *Client) Generate(ctx context.Context, input model.GenerateInput) (model
Model: modelName, Model: modelName,
Input: input.Input, Input: input.Input,
Instructions: input.Instructions, Instructions: input.Instructions,
Metadata: input.Metadata, Metadata: buildRequestMetadata(input),
Stream: false, Stream: false,
Temperature: input.Temperature, Temperature: input.Temperature,
TopP: input.TopP, TopP: input.TopP,
@ -145,7 +145,7 @@ type responsesRequest struct {
Model string `json:"model"` Model string `json:"model"`
Input string `json:"input"` Input string `json:"input"`
Instructions string `json:"instructions,omitempty"` Instructions string `json:"instructions,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"` Metadata map[string]any `json:"metadata,omitempty"`
Stream bool `json:"stream"` Stream bool `json:"stream"`
Temperature *float64 `json:"temperature,omitempty"` Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"` TopP *float64 `json:"top_p,omitempty"`
@ -249,6 +249,22 @@ func responsesURL(base string) (string, error) {
return parsed.String(), nil 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 { func firstNonZero(values ...int) int {
for _, value := range values { for _, value := range values {
if value != 0 { if value != 0 {

View file

@ -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) { func TestGenerateExtractsOutputContent(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")

View file

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

View file

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

View file

@ -9,14 +9,26 @@ type Client interface {
Generate(ctx context.Context, input GenerateInput) (GenerateResult, error) 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 { type GenerateInput struct {
Model string Model string
Instructions string Instructions string
Input string Input string
Metadata map[string]string Metadata map[string]string
MaxOutputTokens int WorkspaceMetadata *WorkspaceMetadata
Temperature *float64 MaxOutputTokens int
TopP *float64 Temperature *float64
TopP *float64
} }
type GenerateResult struct { type GenerateResult struct {

View file

@ -12,6 +12,7 @@ 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/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/storage" "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) CompleteTask(ctx context.Context, id string, result json.RawMessage) (storage.Task, error)
FailTask(ctx context.Context, id string, message string) (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) 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 { type TaskWorker struct {
@ -62,6 +64,18 @@ func (w *TaskWorker) Work(ctx context.Context, job *river.Job[TaskJobArgs]) erro
return err 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) attempt, reason, occurredAt := parseTaskEventContext(task, notification.TaskEventRunning)
w.notifyTaskEvent(ctx, notification.TaskEvent{ w.notifyTaskEvent(ctx, notification.TaskEvent{
Type: notification.TaskEventRunning, 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) result, message, err := w.runTask(runCtx, task)
if err != nil { if err != nil {
w.markFailed(taskID, err) w.markFailedWithTask(taskID, task, err)
return 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) { 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 { if w.Agent != nil {
return w.runAgentTask(ctx, task) 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) { func (w *TaskWorker) markFailed(taskID string, err error) {
if err == nil || w.Lifecycle == nil { if err == nil || w.Lifecycle == nil {
return return

View file

@ -136,11 +136,12 @@ func (c fakeAgentClient) CancelTask(context.Context, agent.CancelTaskInput) (age
} }
type fakeTaskLifecycle struct { type fakeTaskLifecycle struct {
started []string started []string
completed []string completed []string
failed []string failed []string
failMessages []string failMessages []string
failInputs []workflow.FailureInput failInputs []workflow.FailureInput
mergedMetadata []map[string]any
task storage.Task 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.started = append(f.started, id)
f.task.Status = "running" f.task.Status = "running"
attempt := len(f.started) 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() f.task.UpdatedAt = time.Now().UTC()
return f.task, nil 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) { func TestWorkCompletesThroughLifecycle(t *testing.T) {
fakeLifecycle := &fakeTaskLifecycle{ fakeLifecycle := &fakeTaskLifecycle{
task: storage.Task{ 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 { type logRecord struct {
msg string msg string
args map[string]any args map[string]any

View file

@ -210,6 +210,16 @@ func (l *Lifecycle) CompleteTask(ctx context.Context, id string, result json.Raw
if sVal, ok := resMap["summary"].(string); ok { if sVal, ok := resMap["summary"].(string); ok {
summary = strings.TrimSpace(sVal) 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 { type FailureInput struct {
Message string `json:"message"` Message string `json:"message"`
Type FailureType `json:"type"` 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) { 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), MetadataKeyFailedAt: time.Now().UTC().Format(time.RFC3339),
MetadataKeyRetryable: retryable, MetadataKeyRetryable: retryable,
} }
for k, v := range input.ExtraMetadata {
updates[k] = v
}
newMeta, err := mergeTaskMetadata(task.Metadata, updates) newMeta, err := mergeTaskMetadata(task.Metadata, updates)
if err != nil { 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) { func (l *Lifecycle) CancelTask(ctx context.Context, id string, message string) (storage.Task, error) {
task, err := l.store.GetTask(ctx, id) task, err := l.store.GetTask(ctx, id)

View file

@ -49,6 +49,14 @@ const (
MetadataKeyPlanRef = "plan_ref" MetadataKeyPlanRef = "plan_ref"
MetadataKeyAttempt = "attempt" MetadataKeyAttempt = "attempt"
MetadataKeyRetryable = "retryable" 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"
) )