chore: archive migrated task files and update core services
- Move 03+01,02_push_command files to archive - Update services/core authoring request and scheduler jobs - Update related tests and documentation
This commit is contained in:
parent
8157ba060b
commit
13ce1ec281
8 changed files with 390 additions and 16 deletions
|
|
@ -39,33 +39,37 @@ task=m-plane-origin-authoring-roundtrip-sync/03+01,02_push_command, plan=0, tag=
|
||||||
|
|
||||||
| 항목 | 완료 여부 |
|
| 항목 | 완료 여부 |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| [AUTHORING_RUNTIME-1] Push-only authoring stage and develop-match gate | [ ] |
|
| [AUTHORING_RUNTIME-1] Push-only authoring stage and develop-match gate | [x] |
|
||||||
|
|
||||||
## 구현 체크리스트
|
## 구현 체크리스트
|
||||||
|
|
||||||
- [ ] `push-command` 기준으로 first authoring response success alone does not complete or advance projection; a push-only request is sent with the exact user-facing command and workspace metadata. 검증: scheduler test captures two authoring model/agent calls and the second command text.
|
- [x] `push-command` 기준으로 first authoring response success alone does not complete or advance projection; a push-only request is sent with the exact user-facing command and workspace metadata. 검증: `TestWorkAuthoringTwoCallsAndPushCommand`가 두 번의 model 호출과 두 번째 호출 command text를 검증한다.
|
||||||
- [ ] push-only success records `wait_type=develop_match` and waits for remote `develop` confirmation via the existing Gito/develop match path; push-only failure records `push_failed` and maps slot to `dirty`.
|
- [x] push-only success records `wait_type=develop_match` and waits for remote `develop` confirmation via the existing Gito/develop match path; push-only failure records `push_failed` and maps slot to `dirty`. 검증: `TestWorkAuthoringPushFailureMarksPushFailed`가 `push_failed` 카테고리와 slot dirty 전환을 검증한다.
|
||||||
- [ ] `cd services/core && go test -count=1 ./internal/authoring/... ./internal/scheduler/... ./internal/adapters/openai/... ./internal/agent/... ./internal/adapters/a2a/...`와 `git diff --check`를 실행한다.
|
- [x] `cd services/core && go test -count=1 ./internal/authoring/... ./internal/scheduler/... ./internal/adapters/openai/... ./internal/agent/... ./internal/adapters/a2a/...`와 `git diff --check`를 실행한다.
|
||||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다.
|
||||||
|
|
||||||
## 코드리뷰 전용 체크리스트
|
## 코드리뷰 전용 체크리스트
|
||||||
|
|
||||||
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
|
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
|
||||||
|
|
||||||
- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
|
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
|
||||||
- [ ] active review/plan files를 `.log`로 아카이브한다.
|
- [x] active review/plan files를 `.log`로 아카이브한다.
|
||||||
- [ ] `.gitignore`의 Agent-Ops 관리 block을 확인한다.
|
- [x] `.gitignore`의 Agent-Ops 관리 block을 확인한다.
|
||||||
- [ ] PASS이면 `complete.log` 작성 후 active task 디렉터리를 archive로 이동한다.
|
- [x] PASS이면 `complete.log` 작성 후 active task 디렉터리를 archive로 이동한다.
|
||||||
- [ ] PASS이면 runtime completion metadata를 보고하고 roadmap 직접 수정은 하지 않는다.
|
- [x] PASS이면 runtime completion metadata를 보고하고 roadmap 직접 수정은 하지 않는다.
|
||||||
- [ ] WARN/FAIL이면 후속 plan 또는 USER_REVIEW 필요 여부를 판단한다.
|
- [ ] WARN/FAIL이면 후속 plan 또는 USER_REVIEW 필요 여부를 판단한다.
|
||||||
|
|
||||||
## 계획 대비 변경 사항
|
## 계획 대비 변경 사항
|
||||||
|
|
||||||
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
|
- `result.go`와 `result_test.go`는 변경 없음. `PushFailed` 필드와 `push_failed` 카테고리, `SlotStateDirty` 매핑이 이미 구현되어 있었음.
|
||||||
|
- `markFailedWithTask`에 `errors.As(err, &pushErr)` 분기를 추가해 `authoringPushError` 타입 감지로 `push_failed` 결정을 만들었음. 계획의 "add helper/error mapping if needed"를 `result.go` 대신 `jobs.go` 내부에서 sentinel error type으로 처리.
|
||||||
|
|
||||||
## 주요 설계 결정
|
## 주요 설계 결정
|
||||||
|
|
||||||
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
|
- `authoringPushError` sentinel 타입을 `jobs.go` 내부에 정의해 push-only stage 실패를 일반 bridge 실패와 구분. `markFailedWithTask`에서 `errors.As`로 감지해 `DecisionInput{BridgeSuccess: true, PushFailed: true}`를 사용.
|
||||||
|
- push-only `model.GenerateInput`은 `authoring.BuildAuthoringPushInput(task)` 헬퍼로 구성하여 workspace 메타데이터와 provider identity를 그대로 보존.
|
||||||
|
- `PushOnlyCommand` 상수를 `request.go`에 export하여 테스트에서 정확한 문자열을 직접 참조 가능하게 함.
|
||||||
|
- `wait_type=develop_match` 게이트는 push-only 성공 후에만 `Work` 함수 내 결과 검사 블록에서 설정됨. push-only 실패 시 `runTask`가 `authoringPushError`를 반환하고 `Work`는 `markFailedWithTask`로 분기해 develop match 대기 블록에 진입하지 않음.
|
||||||
|
|
||||||
## 사용자 리뷰 요청
|
## 사용자 리뷰 요청
|
||||||
|
|
||||||
|
|
@ -94,21 +98,49 @@ _구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
$ cd services/core && go test -count=1 ./internal/authoring/... ./internal/scheduler/... ./internal/adapters/openai/... ./internal/agent/... ./internal/adapters/a2a/...
|
$ cd services/core && go test -count=1 ./internal/authoring/... ./internal/scheduler/... ./internal/adapters/openai/... ./internal/agent/... ./internal/adapters/a2a/...
|
||||||
(output)
|
ok github.com/nomadcode/nomadcode-core/internal/authoring 0.004s
|
||||||
|
ok github.com/nomadcode/nomadcode-core/internal/scheduler 2.038s
|
||||||
|
ok github.com/nomadcode/nomadcode-core/internal/adapters/openai 0.008s
|
||||||
|
? github.com/nomadcode/nomadcode-core/internal/agent [no test files]
|
||||||
|
ok github.com/nomadcode/nomadcode-core/internal/adapters/a2a 0.007s
|
||||||
```
|
```
|
||||||
|
|
||||||
### 최종 검증
|
### 최종 검증
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
$ cd services/core && go test -count=1 ./internal/authoring/... ./internal/scheduler/... ./internal/adapters/openai/... ./internal/agent/... ./internal/adapters/a2a/...
|
$ cd services/core && go test -count=1 ./internal/authoring/... ./internal/scheduler/... ./internal/adapters/openai/... ./internal/agent/... ./internal/adapters/a2a/...
|
||||||
(output)
|
ok github.com/nomadcode/nomadcode-core/internal/authoring 0.004s
|
||||||
|
ok github.com/nomadcode/nomadcode-core/internal/scheduler 2.038s
|
||||||
|
ok github.com/nomadcode/nomadcode-core/internal/adapters/openai 0.008s
|
||||||
|
? github.com/nomadcode/nomadcode-core/internal/agent [no test files]
|
||||||
|
ok github.com/nomadcode/nomadcode-core/internal/adapters/a2a 0.007s
|
||||||
```
|
```
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
$ git diff --check
|
$ git diff --check
|
||||||
(output)
|
(no output — clean)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
> **[IMPLEMENTING AGENT - BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
|
> **[IMPLEMENTING AGENT - BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
|
||||||
|
|
||||||
|
## 코드리뷰 결과
|
||||||
|
|
||||||
|
- 종합 판정: PASS
|
||||||
|
- 차원별 평가:
|
||||||
|
- correctness: Pass
|
||||||
|
- completeness: Pass
|
||||||
|
- test coverage: Pass
|
||||||
|
- API contract: Pass
|
||||||
|
- code quality: Pass
|
||||||
|
- plan deviation: Pass
|
||||||
|
- verification trust: Pass
|
||||||
|
- spec conformance: Pass
|
||||||
|
- 발견된 문제: 없음
|
||||||
|
- 검증 확인:
|
||||||
|
- `cd services/core && go test -count=1 ./internal/authoring/... ./internal/scheduler/... ./internal/adapters/openai/... ./internal/agent/... ./internal/adapters/a2a/...` 통과
|
||||||
|
- `cd services/core && go test -count=1 ./...` 통과
|
||||||
|
- `git diff --check` 통과
|
||||||
|
- standard remote runner `$HOME/agent-work/nomadcode`를 `8157ba060bd43d7871634ef3df73a9d17f5139c5`로 reset/clean 후 리뷰 diff를 적용해 동일 focused/core 전체 Go test와 `git diff --check` 통과
|
||||||
|
- 다음 단계: PASS이므로 active plan/review를 로그로 아카이브하고 `complete.log` 작성 후 task directory를 `agent-task/archive/YYYY/MM/` 아래로 이동한다.
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
# Complete - m-plane-origin-authoring-roundtrip-sync/03+01,02_push_command
|
||||||
|
|
||||||
|
## 완료 일시
|
||||||
|
|
||||||
|
2026-06-20
|
||||||
|
|
||||||
|
## 요약
|
||||||
|
|
||||||
|
Plane-origin authoring의 push-only command stage와 develop-match gate 구현을 1개 루프로 검토했고 최종 판정은 PASS다.
|
||||||
|
|
||||||
|
## 루프 이력
|
||||||
|
|
||||||
|
| Plan | Review | Verdict | 메모 |
|
||||||
|
|------|--------|---------|------|
|
||||||
|
| `plan_cloud_G08_0.log` | `code_review_cloud_G08_0.log` | PASS | push-only 요청, push 실패 분류, develop_match 대기 gate와 검증 증거가 계획 및 SDD S04 기준을 충족했다. |
|
||||||
|
|
||||||
|
## 구현/정리 내용
|
||||||
|
|
||||||
|
- Plane-origin authoring 성공 후 workspace agent에 `변경된 내용에 대해 develop 브런치에 푸시해` push-only 요청을 별도 model call로 보내도록 했다.
|
||||||
|
- push-only 성공 시 `push_response_id`, `push_request_state=succeeded`, `wait_type=develop_match` metadata를 기록하고 task completion/projection은 remote develop match 이후로 유지했다.
|
||||||
|
- push-only 실패를 `push_failed` authoring failure category로 분류하고 workspace slot을 `dirty`로 전환하도록 했다.
|
||||||
|
- authoring push input helper, scheduler tests, authoring helper tests, core README 운영 설명을 추가했다.
|
||||||
|
|
||||||
|
## 최종 검증
|
||||||
|
|
||||||
|
- `cd services/core && go test -count=1 ./internal/authoring/... ./internal/scheduler/... ./internal/adapters/openai/... ./internal/agent/... ./internal/adapters/a2a/...` - PASS; authoring, scheduler, OpenAI, agent, A2A focused tests 통과
|
||||||
|
- `cd services/core && go test -count=1 ./...` - PASS; core 전체 Go test 통과
|
||||||
|
- `git diff --check` - PASS; 출력 없음
|
||||||
|
- standard remote runner `$HOME/agent-work/nomadcode` reset/clean 후 리뷰 diff 적용 - PASS; focused Go test, `go test -count=1 ./...`, `git diff --check` 모두 통과
|
||||||
|
|
||||||
|
## Roadmap Completion
|
||||||
|
|
||||||
|
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-origin-authoring-roundtrip-sync.md`
|
||||||
|
- Completed task ids:
|
||||||
|
- `push-command`: PASS; evidence=`agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/03+01,02_push_command/plan_cloud_G08_0.log`, `agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/03+01,02_push_command/code_review_cloud_G08_0.log`; verification=`cd services/core && go test -count=1 ./internal/authoring/... ./internal/scheduler/... ./internal/adapters/openai/... ./internal/agent/... ./internal/adapters/a2a/...`, `cd services/core && go test -count=1 ./...`, `git diff --check`, standard remote runner 동일 명령 통과
|
||||||
|
- Not completed task ids: 없음
|
||||||
|
|
||||||
|
## Spec Completion
|
||||||
|
|
||||||
|
- SDD: `agent-roadmap/sdd/agent-ops-mcp-control-plane/plane-origin-authoring-roundtrip-sync/SDD.md`
|
||||||
|
- Completed scenario ids:
|
||||||
|
- `S04`: PASS; task=`push-command`; evidence=`agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/03+01,02_push_command/plan_cloud_G08_0.log`, `agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/03+01,02_push_command/code_review_cloud_G08_0.log`; verification=`cd services/core && go test -count=1 ./internal/authoring/... ./internal/scheduler/... ./internal/adapters/openai/... ./internal/agent/... ./internal/adapters/a2a/...`, `cd services/core && go test -count=1 ./...`, `git diff --check`, standard remote runner 동일 명령 통과
|
||||||
|
- Not completed scenario ids: 없음
|
||||||
|
|
||||||
|
## 잔여 Nit
|
||||||
|
|
||||||
|
- 없음
|
||||||
|
|
||||||
|
## 후속 작업
|
||||||
|
|
||||||
|
- 없음
|
||||||
|
|
@ -243,6 +243,7 @@ NomadCode Core의 비동기 작업 재시도 및 타임아웃 처리는 다음
|
||||||
- 개별 작업 실행 시도는 `WORKFLOW_TASK_TIMEOUT_SEC` (기본값: 300초) 설정 범위 내에서 래핑되어 실행됩니다.
|
- 개별 작업 실행 시도는 `WORKFLOW_TASK_TIMEOUT_SEC` (기본값: 300초) 설정 범위 내에서 래핑되어 실행됩니다.
|
||||||
- 이 제한을 초과하면 단일 실행이 실패한 것으로 간주하여 에러를 반환하고, 작업 상태를 `failed`로 변경합니다. 이때 실패 분류 메타데이터로 `failure_type=timeout`, `status_reason=timeout`이 기록됩니다.
|
- 이 제한을 초과하면 단일 실행이 실패한 것으로 간주하여 에러를 반환하고, 작업 상태를 `failed`로 변경합니다. 이때 실패 분류 메타데이터로 `failure_type=timeout`, `status_reason=timeout`이 기록됩니다.
|
||||||
- **예외 — Plane-origin authoring 작업**: checkout slot이 있는 Plane-origin authoring 태스크는 `WORKFLOW_TASK_TIMEOUT_SEC` 제한을 받지 않습니다. IOP Responses 호출은 10분 이상 실행될 수 있으며, 실행 범위는 `MODEL_TIMEOUT_SEC` (기본값: 900초)로만 제어됩니다. 진행 상태는 `authoring_run_state`와 `authoring_run_updated_at` 메타데이터로 관찰합니다.
|
- **예외 — Plane-origin authoring 작업**: checkout slot이 있는 Plane-origin authoring 태스크는 `WORKFLOW_TASK_TIMEOUT_SEC` 제한을 받지 않습니다. IOP Responses 호출은 10분 이상 실행될 수 있으며, 실행 범위는 `MODEL_TIMEOUT_SEC` (기본값: 900초)로만 제어됩니다. 진행 상태는 `authoring_run_state`와 `authoring_run_updated_at` 메타데이터로 관찰합니다.
|
||||||
|
- **Plane-origin authoring 3단계 실행 흐름**: Plane-origin authoring 태스크는 세 단계로 실행됩니다. (1) **첫 번째 authoring 응답**: IOP/model 호출로 마일스톤 초안을 생성하고 `response_id`를 기록합니다. 이 단계의 성공만으로 task가 완료되거나 projection으로 넘어가지 않습니다. (2) **push-only 요청**: 첫 번째 응답 성공 직후 workspace agent에 `변경된 내용에 대해 develop 브런치에 푸시해` 요청을 별도 model 호출로 보내고 `push_response_id`와 `push_request_state=succeeded`를 기록합니다. push-only 실패 시 `authoring_failure_category=push_failed`로 기록되고 slot은 `dirty` 상태로 전환됩니다. (3) **develop 브랜치 match 대기**: push-only 성공 후 `wait_type=develop_match`를 설정하고 Gito webhook 기반 develop match 확인을 기다립니다. 이 gate를 통과해야만 task가 `succeeded`로 완료됩니다.
|
||||||
- **최대 시도 횟수 (Max Attempts)**:
|
- **최대 시도 횟수 (Max Attempts)**:
|
||||||
- River 비동기 작업 큐는 실패한 작업 시도를 최대 `workflow.DefaultTaskMaxAttempts` (기본값: 3회)까지 자동으로 다시 시도합니다.
|
- River 비동기 작업 큐는 실패한 작업 시도를 최대 `workflow.DefaultTaskMaxAttempts` (기본값: 3회)까지 자동으로 다시 시도합니다.
|
||||||
- 각 시도가 시작될 때마다 작업 메타데이터의 `attempt` 카운트가 1씩 증가하여 기록되며, 실패 시 메타데이터의 `retryable` 필드에 남은 재시도 가능 여부(`attempt < DefaultTaskMaxAttempts`)가 기록됩니다.
|
- 각 시도가 시작될 때마다 작업 메타데이터의 `attempt` 카운트가 1씩 증가하여 기록되며, 실패 시 메타데이터의 `retryable` 필드에 남은 재시도 가능 여부(`attempt < DefaultTaskMaxAttempts`)가 기록됩니다.
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,10 @@ type pipelinePayload struct {
|
||||||
|
|
||||||
const defaultAuthoringInstructions = "다음 메시지를 바탕으로 마일스톤을 작성해."
|
const defaultAuthoringInstructions = "다음 메시지를 바탕으로 마일스톤을 작성해."
|
||||||
|
|
||||||
|
// PushOnlyCommand is the exact user-facing command sent to the workspace agent
|
||||||
|
// as the push-only authoring stage after the first authoring response succeeds.
|
||||||
|
const PushOnlyCommand = "변경된 내용에 대해 develop 브런치에 푸시해"
|
||||||
|
|
||||||
// isAuthoringTask returns true when the task is a Plane-origin authoring task
|
// isAuthoringTask returns true when the task is a Plane-origin authoring task
|
||||||
// that should be routed through the IOP workspace authoring bridge. The criteria
|
// that should be routed through the IOP workspace authoring bridge. The criteria
|
||||||
// are: source=="plane" and task metadata contains a "checkout" key with a
|
// are: source=="plane" and task metadata contains a "checkout" key with a
|
||||||
|
|
@ -120,6 +124,42 @@ func getWorkItemIdentity(task storage.Task, payload pipelinePayload) workItemIde
|
||||||
return ident
|
return ident
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BuildAuthoringPushInput constructs a push-only model.GenerateInput for the
|
||||||
|
// second authoring stage. The workspace metadata and provider identity are
|
||||||
|
// copied from the original task so the workspace agent can locate the slot and
|
||||||
|
// push the authored content to the remote develop branch. Returns false when
|
||||||
|
// the task does not qualify as an authoring task.
|
||||||
|
func BuildAuthoringPushInput(task storage.Task) (model.GenerateInput, bool) {
|
||||||
|
checkout, ok := isAuthoringTask(task)
|
||||||
|
if !ok {
|
||||||
|
return model.GenerateInput{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
var p pipelinePayload
|
||||||
|
if len(task.Payload) > 0 && string(task.Payload) != "null" && string(task.Payload) != "{}" {
|
||||||
|
_ = json.Unmarshal(task.Payload, &p)
|
||||||
|
}
|
||||||
|
ident := getWorkItemIdentity(task, p)
|
||||||
|
|
||||||
|
return model.GenerateInput{
|
||||||
|
Input: PushOnlyCommand,
|
||||||
|
Metadata: map[string]string{
|
||||||
|
"task_id": task.ID,
|
||||||
|
"source": task.Source,
|
||||||
|
},
|
||||||
|
WorkspaceMetadata: &model.WorkspaceMetadata{
|
||||||
|
Path: checkout.SlotPath,
|
||||||
|
SourceBranch: checkout.SourceBranch,
|
||||||
|
Provider: ident.Provider,
|
||||||
|
WorkItemID: ident.WorkItemID,
|
||||||
|
Tenant: ident.Tenant,
|
||||||
|
Project: ident.Project,
|
||||||
|
ExternalURL: ident.ExternalURL,
|
||||||
|
StateID: ident.StateID,
|
||||||
|
},
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
// BuildAuthoringGenerateInput constructs a workspace-bound model.GenerateInput
|
// BuildAuthoringGenerateInput constructs a workspace-bound model.GenerateInput
|
||||||
// for Plane-origin authoring tasks. Returns false when the task does not qualify
|
// for Plane-origin authoring tasks. Returns false when the task does not qualify
|
||||||
// as an authoring task (non-Plane source or missing checkout metadata).
|
// as an authoring task (non-Plane source or missing checkout metadata).
|
||||||
|
|
|
||||||
|
|
@ -234,6 +234,77 @@ func TestBuildAuthoringGenerateInputCarriesProviderIdentityContext(t *testing.T)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildAuthoringPushInputCommandText(t *testing.T) {
|
||||||
|
task := storage.Task{
|
||||||
|
ID: "task-push-1",
|
||||||
|
Title: "Author milestone",
|
||||||
|
Source: "plane",
|
||||||
|
Payload: json.RawMessage(`{}`),
|
||||||
|
Metadata: checkoutMeta("/home/user/workspace/nomadcode/slots/000", "develop"),
|
||||||
|
}
|
||||||
|
|
||||||
|
input, ok := BuildAuthoringPushInput(task)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected authoring task to be detected")
|
||||||
|
}
|
||||||
|
if input.Input != PushOnlyCommand {
|
||||||
|
t.Errorf("expected push command %q, got %q", PushOnlyCommand, input.Input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildAuthoringPushInputPreservesWorkspaceMetadata(t *testing.T) {
|
||||||
|
pipelineBody := `{"message":"body","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-push-2",
|
||||||
|
Source: "plane",
|
||||||
|
Payload: json.RawMessage(pipelineBody),
|
||||||
|
Metadata: checkoutMeta("/home/user/workspace/nomadcode/slots/001", "develop"),
|
||||||
|
ExternalProvider: &extProvider,
|
||||||
|
ExternalID: &extID,
|
||||||
|
}
|
||||||
|
|
||||||
|
input, ok := BuildAuthoringPushInput(task)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected authoring task to be detected")
|
||||||
|
}
|
||||||
|
if input.WorkspaceMetadata == nil {
|
||||||
|
t.Fatal("expected WorkspaceMetadata to be set")
|
||||||
|
}
|
||||||
|
if input.WorkspaceMetadata.Path != "/home/user/workspace/nomadcode/slots/001" {
|
||||||
|
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.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)
|
||||||
|
}
|
||||||
|
if input.WorkspaceMetadata.Tenant != "acme" {
|
||||||
|
t.Errorf("unexpected tenant: %q", input.WorkspaceMetadata.Tenant)
|
||||||
|
}
|
||||||
|
if input.Metadata["task_id"] != "task-push-2" || input.Metadata["source"] != "plane" {
|
||||||
|
t.Errorf("unexpected metadata: %#v", input.Metadata)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildAuthoringPushInputNonPlaneSource(t *testing.T) {
|
||||||
|
task := storage.Task{
|
||||||
|
ID: "task-push-3",
|
||||||
|
Source: "manual",
|
||||||
|
Metadata: checkoutMeta("/home/user/workspace/nomadcode/slots/000", "develop"),
|
||||||
|
}
|
||||||
|
|
||||||
|
_, ok := BuildAuthoringPushInput(task)
|
||||||
|
if ok {
|
||||||
|
t.Error("expected non-plane task to not produce push input")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildAuthoringGenerateInputDefaultInstructionsStayThin(t *testing.T) {
|
func TestBuildAuthoringGenerateInputDefaultInstructionsStayThin(t *testing.T) {
|
||||||
task := storage.Task{
|
task := storage.Task{
|
||||||
ID: "task-2",
|
ID: "task-2",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,16 @@ import (
|
||||||
"github.com/nomadcode/nomadcode-core/internal/workflow"
|
"github.com/nomadcode/nomadcode-core/internal/workflow"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// authoringPushError wraps a push-only stage failure so markFailedWithTask can
|
||||||
|
// distinguish it from a first-stage bridge failure and record push_failed instead
|
||||||
|
// of bridge_failed.
|
||||||
|
type authoringPushError struct {
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e authoringPushError) Error() string { return e.err.Error() }
|
||||||
|
func (e authoringPushError) Unwrap() error { return e.err }
|
||||||
|
|
||||||
type TaskJobArgs struct {
|
type TaskJobArgs struct {
|
||||||
TaskID string `json:"task_id"`
|
TaskID string `json:"task_id"`
|
||||||
}
|
}
|
||||||
|
|
@ -188,6 +198,16 @@ func (w *TaskWorker) runTask(ctx context.Context, task storage.Task) (json.RawMe
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Push-only second stage: send the authored content to the workspace
|
||||||
|
// agent so it can push to the remote develop branch before we gate on
|
||||||
|
// the develop match confirmation.
|
||||||
|
pushInput, _ := authoring.BuildAuthoringPushInput(task)
|
||||||
|
pushGenerated, pushErr := w.Model.Generate(ctx, pushInput)
|
||||||
|
if pushErr != nil {
|
||||||
|
return nil, "", authoringPushError{pushErr}
|
||||||
|
}
|
||||||
|
|
||||||
decision := authoring.DecideAuthoringResult(authoring.DecisionInput{
|
decision := authoring.DecideAuthoringResult(authoring.DecisionInput{
|
||||||
BridgeSuccess: true,
|
BridgeSuccess: true,
|
||||||
})
|
})
|
||||||
|
|
@ -196,6 +216,8 @@ func (w *TaskWorker) runTask(ctx context.Context, task storage.Task) (json.RawMe
|
||||||
"mode": "authoring_run",
|
"mode": "authoring_run",
|
||||||
"model": generated.Model,
|
"model": generated.Model,
|
||||||
"response_id": generated.ID,
|
"response_id": generated.ID,
|
||||||
|
"push_response_id": pushGenerated.ID,
|
||||||
|
"push_request_state": "succeeded",
|
||||||
"authoring_run_state": decision.State,
|
"authoring_run_state": decision.State,
|
||||||
"authoring_run_updated_at": time.Now().UTC().Format(time.RFC3339),
|
"authoring_run_updated_at": time.Now().UTC().Format(time.RFC3339),
|
||||||
workflow.MetadataKeyAuthoringProgressMode: progressMode,
|
workflow.MetadataKeyAuthoringProgressMode: progressMode,
|
||||||
|
|
@ -459,7 +481,12 @@ func (w *TaskWorker) markFailedWithTask(taskID string, task storage.Task, err er
|
||||||
var terminalSlotState projectsync.SlotState
|
var terminalSlotState projectsync.SlotState
|
||||||
var releaseSlot bool
|
var releaseSlot bool
|
||||||
if _, isAuthoring := authoring.TaskCheckoutMetadata(task); isAuthoring {
|
if _, isAuthoring := authoring.TaskCheckoutMetadata(task); isAuthoring {
|
||||||
decision := authoring.DecideAuthoringResult(authoring.DecisionInput{BridgeSuccess: false})
|
var pushErr authoringPushError
|
||||||
|
decisionInput := authoring.DecisionInput{BridgeSuccess: false}
|
||||||
|
if errors.As(err, &pushErr) {
|
||||||
|
decisionInput = authoring.DecisionInput{BridgeSuccess: true, PushFailed: true}
|
||||||
|
}
|
||||||
|
decision := authoring.DecideAuthoringResult(decisionInput)
|
||||||
failInput.ExtraMetadata = map[string]any{
|
failInput.ExtraMetadata = map[string]any{
|
||||||
workflow.MetadataKeyAuthoringRunState: decision.State,
|
workflow.MetadataKeyAuthoringRunState: decision.State,
|
||||||
workflow.MetadataKeyAuthoringRunUpdatedAt: time.Now().UTC().Format(time.RFC3339),
|
workflow.MetadataKeyAuthoringRunUpdatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
|
|
||||||
|
|
@ -1068,6 +1068,158 @@ func TestWorkKeepsAuthoringTaskRunningUntilDevelopMatch(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWorkAuthoringTwoCallsAndPushCommand(t *testing.T) {
|
||||||
|
var calls []model.GenerateInput
|
||||||
|
fakeModel := fakeModelClient{
|
||||||
|
generate: func(_ context.Context, input model.GenerateInput) (model.GenerateResult, error) {
|
||||||
|
calls = append(calls, input)
|
||||||
|
return model.GenerateResult{Text: "done", ID: fmt.Sprintf("resp-%d", len(calls)), Model: "m"}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
fakeLifecycle := &fakeTaskLifecycle{
|
||||||
|
task: storage.Task{
|
||||||
|
ID: "task-auth-two-calls",
|
||||||
|
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-two-calls"}}
|
||||||
|
if err := worker.Work(context.Background(), job); err != nil {
|
||||||
|
t.Fatalf("Work returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(calls) != 2 {
|
||||||
|
t.Fatalf("expected exactly 2 model calls (authoring + push-only), got %d", len(calls))
|
||||||
|
}
|
||||||
|
if calls[1].Input != "변경된 내용에 대해 develop 브런치에 푸시해" {
|
||||||
|
t.Errorf("expected push-only command text, got %q", calls[1].Input)
|
||||||
|
}
|
||||||
|
if calls[1].WorkspaceMetadata == nil {
|
||||||
|
t.Fatal("expected WorkspaceMetadata on push-only call")
|
||||||
|
}
|
||||||
|
if calls[1].WorkspaceMetadata.Path != "/home/user/workspace/nomadcode/slots/000" {
|
||||||
|
t.Errorf("unexpected push-only workspace path: %q", calls[1].WorkspaceMetadata.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Completed must NOT be called; task stays running for develop match.
|
||||||
|
if len(fakeLifecycle.completed) != 0 {
|
||||||
|
t.Fatalf("expected CompleteTask to NOT be called, but was called: %v", fakeLifecycle.completed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// wait_type=develop_match must be set only after push-only succeeds.
|
||||||
|
foundWaitType := false
|
||||||
|
for _, m := range fakeLifecycle.mergedMetadata {
|
||||||
|
if m[workflow.MetadataKeyWaitType] == "develop_match" {
|
||||||
|
foundWaitType = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundWaitType {
|
||||||
|
t.Errorf("expected MergeTaskMetadata to be called with wait_type=develop_match, got %v", fakeLifecycle.mergedMetadata)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkAuthoringPushFailureMarksPushFailed(t *testing.T) {
|
||||||
|
callCount := 0
|
||||||
|
fakeModel := fakeModelClient{
|
||||||
|
generate: func(_ context.Context, _ model.GenerateInput) (model.GenerateResult, error) {
|
||||||
|
callCount++
|
||||||
|
if callCount == 2 {
|
||||||
|
return model.GenerateResult{}, errors.New("git push rejected")
|
||||||
|
}
|
||||||
|
return model.GenerateResult{Text: "authored", ID: "resp-1", Model: "m"}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
fakeSlotUpdater := &fakeWorkspaceSlotUpdater{}
|
||||||
|
fakeLifecycle := &fakeTaskLifecycle{
|
||||||
|
task: storage.Task{
|
||||||
|
ID: "task-auth-push-fail",
|
||||||
|
Source: "plane",
|
||||||
|
Status: "pending",
|
||||||
|
Metadata: checkoutTaskMeta("/home/user/workspace/nomadcode/slots/000", "develop"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
worker := &TaskWorker{
|
||||||
|
Lifecycle: fakeLifecycle,
|
||||||
|
Model: fakeModel,
|
||||||
|
SlotUpdater: fakeSlotUpdater,
|
||||||
|
}
|
||||||
|
|
||||||
|
job := &river.Job[TaskJobArgs]{Args: TaskJobArgs{TaskID: "task-auth-push-fail"}}
|
||||||
|
err := worker.Work(context.Background(), job)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected Work to return error on push failure")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(fakeLifecycle.failed) == 0 {
|
||||||
|
t.Fatal("expected FailTask to be called on push failure")
|
||||||
|
}
|
||||||
|
if len(fakeLifecycle.failInputs) == 0 {
|
||||||
|
t.Fatal("expected failInputs to be recorded")
|
||||||
|
}
|
||||||
|
|
||||||
|
fi := fakeLifecycle.failInputs[0]
|
||||||
|
if fi.ExtraMetadata == nil {
|
||||||
|
t.Fatal("expected ExtraMetadata on failure input")
|
||||||
|
}
|
||||||
|
if fi.ExtraMetadata["authoring_failure_category"] != "push_failed" {
|
||||||
|
t.Errorf("expected authoring_failure_category=push_failed, got %#v", fi.ExtraMetadata["authoring_failure_category"])
|
||||||
|
}
|
||||||
|
if fi.ExtraMetadata["authoring_run_state"] != "failed" {
|
||||||
|
t.Errorf("expected authoring_run_state=failed, got %#v", fi.ExtraMetadata["authoring_run_state"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slot must be marked dirty.
|
||||||
|
if len(fakeSlotUpdater.calls) == 0 {
|
||||||
|
t.Fatal("expected slot state update on push failure")
|
||||||
|
}
|
||||||
|
if fakeSlotUpdater.calls[0].State != string(projectsync.SlotStateDirty) {
|
||||||
|
t.Errorf("expected slot state dirty, got %q", fakeSlotUpdater.calls[0].State)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunTaskAuthoringRecordsPushResponse(t *testing.T) {
|
||||||
|
callCount := 0
|
||||||
|
fakeModel := fakeModelClient{
|
||||||
|
generate: func(_ context.Context, _ model.GenerateInput) (model.GenerateResult, error) {
|
||||||
|
callCount++
|
||||||
|
return model.GenerateResult{ID: fmt.Sprintf("id-%d", callCount), Text: "done", Model: "m"}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
worker := &TaskWorker{Model: fakeModel}
|
||||||
|
|
||||||
|
task := storage.Task{
|
||||||
|
ID: "task-push-resp",
|
||||||
|
Source: "plane",
|
||||||
|
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["push_response_id"] == "" || result["push_response_id"] == nil {
|
||||||
|
t.Errorf("expected push_response_id in result, got %#v", result["push_response_id"])
|
||||||
|
}
|
||||||
|
if result["push_request_state"] != "succeeded" {
|
||||||
|
t.Errorf("expected push_request_state=succeeded, got %#v", result["push_request_state"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRunTaskAuthoringProgressCallback(t *testing.T) {
|
func TestRunTaskAuthoringProgressCallback(t *testing.T) {
|
||||||
fakeLifecycle := &fakeTaskLifecycle{
|
fakeLifecycle := &fakeTaskLifecycle{
|
||||||
task: storage.Task{
|
task: storage.Task{
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue