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:
parent
f9c6be03ef
commit
6ab8dd8f63
15 changed files with 1413 additions and 138 deletions
|
|
@ -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`를 새로 작성한다.
|
||||||
|
|
@ -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로 이동한다.
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
- 없음
|
||||||
|
|
||||||
|
## 후속 작업
|
||||||
|
|
||||||
|
- 없음
|
||||||
|
|
@ -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 중간 검증.
|
||||||
|
|
@ -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)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
131
services/core/internal/authoring/request.go
Normal file
131
services/core/internal/authoring/request.go
Normal 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()
|
||||||
|
}
|
||||||
185
services/core/internal/authoring/request_test.go
Normal file
185
services/core/internal/authoring/request_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue