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:
toki 2026-06-20 19:48:16 +09:00
parent 8157ba060b
commit 13ce1ec281
8 changed files with 390 additions and 16 deletions

View file

@ -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/` 아래로 이동한다.

View file

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

View file

@ -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`) 기록됩니다.

View file

@ -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).

View file

@ -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",

View file

@ -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),

View file

@ -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{