From 13ce1ec281a0ba933da5eaab6b09d6159fd97ca4 Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 20 Jun 2026 19:48:16 +0900 Subject: [PATCH] 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 --- .../code_review_cloud_G08_0.log} | 62 +++++-- .../03+01,02_push_command/complete.log | 51 ++++++ .../plan_cloud_G08_0.log} | 0 services/core/README.md | 1 + services/core/internal/authoring/request.go | 40 +++++ .../core/internal/authoring/request_test.go | 71 ++++++++ services/core/internal/scheduler/jobs.go | 29 +++- services/core/internal/scheduler/jobs_test.go | 152 ++++++++++++++++++ 8 files changed, 390 insertions(+), 16 deletions(-) rename agent-task/{m-plane-origin-authoring-roundtrip-sync/03+01,02_push_command/CODE_REVIEW-cloud-G08.md => archive/2026/06/m-plane-origin-authoring-roundtrip-sync/03+01,02_push_command/code_review_cloud_G08_0.log} (54%) create mode 100644 agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/03+01,02_push_command/complete.log rename agent-task/{m-plane-origin-authoring-roundtrip-sync/03+01,02_push_command/PLAN-cloud-G08.md => archive/2026/06/m-plane-origin-authoring-roundtrip-sync/03+01,02_push_command/plan_cloud_G08_0.log} (100%) diff --git a/agent-task/m-plane-origin-authoring-roundtrip-sync/03+01,02_push_command/CODE_REVIEW-cloud-G08.md b/agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/03+01,02_push_command/code_review_cloud_G08_0.log similarity index 54% rename from agent-task/m-plane-origin-authoring-roundtrip-sync/03+01,02_push_command/CODE_REVIEW-cloud-G08.md rename to agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/03+01,02_push_command/code_review_cloud_G08_0.log index 0b4014d..4be5f3b 100644 --- a/agent-task/m-plane-origin-authoring-roundtrip-sync/03+01,02_push_command/CODE_REVIEW-cloud-G08.md +++ b/agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/03+01,02_push_command/code_review_cloud_G08_0.log @@ -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. -- [ ] 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`. -- [ ] `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] `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를 검증한다. +- [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 전환을 검증한다. +- [x] `cd services/core && go test -count=1 ./internal/authoring/... ./internal/scheduler/... ./internal/adapters/openai/... ./internal/agent/... ./internal/adapters/a2a/...`와 `git diff --check`를 실행한다. +- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. ## 코드리뷰 전용 체크리스트 > **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. -- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. -- [ ] active review/plan files를 `.log`로 아카이브한다. -- [ ] `.gitignore`의 Agent-Ops 관리 block을 확인한다. -- [ ] PASS이면 `complete.log` 작성 후 active task 디렉터리를 archive로 이동한다. -- [ ] PASS이면 runtime completion metadata를 보고하고 roadmap 직접 수정은 하지 않는다. +- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [x] active review/plan files를 `.log`로 아카이브한다. +- [x] `.gitignore`의 Agent-Ops 관리 block을 확인한다. +- [x] PASS이면 `complete.log` 작성 후 active task 디렉터리를 archive로 이동한다. +- [x] PASS이면 runtime completion metadata를 보고하고 roadmap 직접 수정은 하지 않는다. - [ ] 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 $ 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 $ 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 $ 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?** + +## 코드리뷰 결과 + +- 종합 판정: 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/` 아래로 이동한다. diff --git a/agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/03+01,02_push_command/complete.log b/agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/03+01,02_push_command/complete.log new file mode 100644 index 0000000..d6d786e --- /dev/null +++ b/agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/03+01,02_push_command/complete.log @@ -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 + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/m-plane-origin-authoring-roundtrip-sync/03+01,02_push_command/PLAN-cloud-G08.md b/agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/03+01,02_push_command/plan_cloud_G08_0.log similarity index 100% rename from agent-task/m-plane-origin-authoring-roundtrip-sync/03+01,02_push_command/PLAN-cloud-G08.md rename to agent-task/archive/2026/06/m-plane-origin-authoring-roundtrip-sync/03+01,02_push_command/plan_cloud_G08_0.log diff --git a/services/core/README.md b/services/core/README.md index 1d821e2..9aa6400 100644 --- a/services/core/README.md +++ b/services/core/README.md @@ -243,6 +243,7 @@ NomadCode Core의 비동기 작업 재시도 및 타임아웃 처리는 다음 - 개별 작업 실행 시도는 `WORKFLOW_TASK_TIMEOUT_SEC` (기본값: 300초) 설정 범위 내에서 래핑되어 실행됩니다. - 이 제한을 초과하면 단일 실행이 실패한 것으로 간주하여 에러를 반환하고, 작업 상태를 `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 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)**: - River 비동기 작업 큐는 실패한 작업 시도를 최대 `workflow.DefaultTaskMaxAttempts` (기본값: 3회)까지 자동으로 다시 시도합니다. - 각 시도가 시작될 때마다 작업 메타데이터의 `attempt` 카운트가 1씩 증가하여 기록되며, 실패 시 메타데이터의 `retryable` 필드에 남은 재시도 가능 여부(`attempt < DefaultTaskMaxAttempts`)가 기록됩니다. diff --git a/services/core/internal/authoring/request.go b/services/core/internal/authoring/request.go index 1b30fd7..cdf9406 100644 --- a/services/core/internal/authoring/request.go +++ b/services/core/internal/authoring/request.go @@ -25,6 +25,10 @@ type pipelinePayload struct { 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 // that should be routed through the IOP workspace authoring bridge. The criteria // 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 } +// 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 // for Plane-origin authoring tasks. Returns false when the task does not qualify // as an authoring task (non-Plane source or missing checkout metadata). diff --git a/services/core/internal/authoring/request_test.go b/services/core/internal/authoring/request_test.go index 1090d9d..d9e39de 100644 --- a/services/core/internal/authoring/request_test.go +++ b/services/core/internal/authoring/request_test.go @@ -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) { task := storage.Task{ ID: "task-2", diff --git a/services/core/internal/scheduler/jobs.go b/services/core/internal/scheduler/jobs.go index 48d1ef4..cd968e9 100644 --- a/services/core/internal/scheduler/jobs.go +++ b/services/core/internal/scheduler/jobs.go @@ -20,6 +20,16 @@ import ( "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 { TaskID string `json:"task_id"` } @@ -188,6 +198,16 @@ func (w *TaskWorker) runTask(ctx context.Context, task storage.Task) (json.RawMe if err != nil { 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{ BridgeSuccess: true, }) @@ -196,6 +216,8 @@ func (w *TaskWorker) runTask(ctx context.Context, task storage.Task) (json.RawMe "mode": "authoring_run", "model": generated.Model, "response_id": generated.ID, + "push_response_id": pushGenerated.ID, + "push_request_state": "succeeded", "authoring_run_state": decision.State, "authoring_run_updated_at": time.Now().UTC().Format(time.RFC3339), workflow.MetadataKeyAuthoringProgressMode: progressMode, @@ -459,7 +481,12 @@ func (w *TaskWorker) markFailedWithTask(taskID string, task storage.Task, err er var terminalSlotState projectsync.SlotState var releaseSlot bool 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{ workflow.MetadataKeyAuthoringRunState: decision.State, workflow.MetadataKeyAuthoringRunUpdatedAt: time.Now().UTC().Format(time.RFC3339), diff --git a/services/core/internal/scheduler/jobs_test.go b/services/core/internal/scheduler/jobs_test.go index a464366..8854434 100644 --- a/services/core/internal/scheduler/jobs_test.go +++ b/services/core/internal/scheduler/jobs_test.go @@ -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) { fakeLifecycle := &fakeTaskLifecycle{ task: storage.Task{