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.
|
||||
- [ ] 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/` 아래로 이동한다.
|
||||
|
|
@ -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초) 설정 범위 내에서 래핑되어 실행됩니다.
|
||||
- 이 제한을 초과하면 단일 실행이 실패한 것으로 간주하여 에러를 반환하고, 작업 상태를 `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`)가 기록됩니다.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
Loading…
Reference in a new issue