feat: iop-agent-run-bridge milestone updates and worker improvements
- Update iop-agent-run-bridge milestone with latest changes - Update gito-control-plane contract notes - Improve worker runner and test coverage
This commit is contained in:
parent
42176cac8e
commit
7adbe11048
11 changed files with 1547 additions and 15 deletions
|
|
@ -11,7 +11,7 @@
|
|||
|
||||
## 상태
|
||||
|
||||
[계획]
|
||||
[진행중]
|
||||
|
||||
## 승격 조건
|
||||
|
||||
|
|
@ -38,9 +38,9 @@
|
|||
|
||||
IOP backend를 workspace operation 실행 통로로 연결한다.
|
||||
|
||||
- [ ] [input] repo, branch, workspace path, user instruction, policy context를 IOP 입력으로 전달하는 형식을 정한다.
|
||||
- [x] [input] repo, branch, workspace path, user instruction, policy context를 IOP 입력으로 전달하는 형식을 정한다.
|
||||
- [ ] [invoke] Gito가 직접 IOP CLI를 실행하지 않고 IOP backend invocation/result 계약을 호출하거나 수신하는 형식을 정한다. 검증: fake IOP backend result가 operation log를 남긴다.
|
||||
- [ ] [state] `queued`, `running`, `succeeded`, `failed`, `cancelled` run state를 operation lifecycle에 매핑한다.
|
||||
- [x] [state] `queued`, `running`, `succeeded`, `failed`, `cancelled` run state를 operation lifecycle에 매핑한다.
|
||||
- [ ] [result] run 이후 Git status와 revision diff로 성공 여부를 확인한다.
|
||||
|
||||
### Epic: [git-result] Git result convergence
|
||||
|
|
@ -76,3 +76,4 @@ agent run 결과를 Git revision과 operation event로 수렴한다.
|
|||
- 선행 작업: Agent Shell Package and IOP Backend Boundary
|
||||
- 후속 작업: Command Policy and Cancellation
|
||||
- 확인 필요: IOP backend invocation/result contract
|
||||
- 완료 근거: `[input]`과 `[state]`는 `packages/contracts/notes/gito-control-plane.md`의 `IOP Agent Run Bridge` 섹션에서 transport-independent `AgentRunInput`과 run state mapping으로 정리했다. 기존 `services/core/internal/core/model.go`와 `services/core/internal/core/model_test.go`는 `agent_run` operation type과 `queued/running/succeeded/failed/cancelled` lifecycle 전이를 이미 검증한다.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,147 @@
|
|||
<!-- task=m-iop-agent-run-bridge/01_iop_invocation_contract plan=0 tag=API -->
|
||||
|
||||
# Code Review Reference - API
|
||||
|
||||
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
|
||||
> The task is NOT complete until every implementation-owned section below is filled in.
|
||||
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
|
||||
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
|
||||
> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves.
|
||||
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review.
|
||||
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
|
||||
> Follow the ownership table at the bottom of this file for which sections you own.
|
||||
|
||||
## 개요
|
||||
|
||||
date=2026-06-16
|
||||
task=m-iop-agent-run-bridge/01_iop_invocation_contract, plan=0, tag=API
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/agent-shell-iop-execution/milestones/iop-agent-run-bridge.md`
|
||||
- Task ids:
|
||||
- `invoke`: Gito가 직접 IOP CLI를 실행하지 않고 IOP backend invocation/result 계약을 호출하거나 수신하는 형식을 정한다.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 이 파일을 읽는 리뷰 에이전트에게
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
|
||||
|
||||
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
|
||||
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
|
||||
|
||||
1. 판정을 append한다.
|
||||
2. `CODE_REVIEW-local-G05.md` → `code_review_local_G05_N.log`, `PLAN-local-G05.md` → `plan_local_G05_M.log`로 아카이브한다.
|
||||
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-iop-agent-run-bridge/01_iop_invocation_contract/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
|
||||
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
|
||||
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
|
||||
|
||||
---
|
||||
|
||||
## 구현 항목별 완료 여부
|
||||
|
||||
| 항목 | 완료 여부 |
|
||||
|------|---------|
|
||||
| [API-1] Worker IOP Invocation Contract | [x] |
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [x] worker에 IOP adapter/result 수신 경계를 추가하고 기존 `NewRunner` call site를 깨지 않는다. `NewRunnerWithDependencies`를 추가해 기존 시그니처를 유지하고 `services/core/cmd/worker/main.go`는 변경 없이 둠.
|
||||
- [x] fake IOP backend result가 `agent_run` operation을 terminal state로 전환하고 operation event log를 남기는 unit test를 추가한다. 검증: `cd services/core && go test -count=1 ./internal/worker ./internal/storage ./internal/core`.
|
||||
- [x] `invoke` 범위 밖인 CLI/profile/credential/real IOP 호출은 구현하지 않고 review note에 명시한다.
|
||||
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
## 코드리뷰 전용 체크리스트
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
|
||||
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
|
||||
|
||||
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
|
||||
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
|
||||
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G05_N.log`로 아카이브한다.
|
||||
- [x] active `PLAN-*-G??.md`를 `plan_local_G05_M.log`로 아카이브한다.
|
||||
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
|
||||
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
|
||||
- [ ] PASS이면 active task 디렉터리 `agent-task/m-iop-agent-run-bridge/01_iop_invocation_contract/`를 `agent-task/archive/YYYY/MM/m-iop-agent-run-bridge/01_iop_invocation_contract/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
|
||||
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
|
||||
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-iop-agent-run-bridge/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
|
||||
- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G06.md`와 `CODE_REVIEW-local-G06.md`를 작성하고 `complete.log`를 작성하지 않는다.
|
||||
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
|
||||
- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
|
||||
|
||||
## 계획 대비 변경 사항
|
||||
|
||||
없음: 구현은 plan의 `API-1` 범위 내에서 진행했고 설계/검증 시나리오도 동일.
|
||||
|
||||
## 주요 설계 결정
|
||||
|
||||
- `Runner` 기존 시그니처를 유지하기 위해 `NewRunnerWithDependencies`를 추가해 주입 경로만 확장.
|
||||
- `agent_run` 처리에서 `OperationStore`/`OperationEventStore`/`InputLoader`/`Invoker`가 모두 필요하도록 하여, non-agent 경로의 기존 동작은 변경 없이 유지.
|
||||
- 이벤트 payload는 `{operation_id, repo_id, type, state, at}` 구조의 JSON object로 고정하고, 종료 이벤트에 `message`를 추가해 종료 사유를 명시.
|
||||
- backend 호출 실패는 `err`가 있으면 실패 상태 및 `"agent run invocation failed"` 메시지로 sanitize한 종료 이벤트를 남기도록 고정.
|
||||
|
||||
## 사용자 리뷰 요청
|
||||
|
||||
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
|
||||
|
||||
- 상태: 없음
|
||||
- 사유 유형: 없음
|
||||
- 결정 필요: 없음
|
||||
- 차단 근거: 없음
|
||||
- 실행한 검증/명령: 없음
|
||||
- 자동 후속 불가 이유: 없음
|
||||
- 재개 조건: 없음
|
||||
|
||||
## 리뷰어를 위한 체크포인트
|
||||
|
||||
- `NewRunner` 기존 signature와 non-agent operation behavior가 유지되는지 확인한다.
|
||||
- fake IOP backend result가 operation terminal state와 durable operation events를 모두 남기는지 확인한다.
|
||||
- raw secret, raw backend log, concrete IOP CLI/profile/credential 방식이 코드나 tracked docs에 들어가지 않았는지 확인한다.
|
||||
|
||||
## 검증 결과
|
||||
|
||||
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
|
||||
|
||||
필수 규칙:
|
||||
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
|
||||
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
|
||||
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
|
||||
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
|
||||
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
|
||||
|
||||
### API-1 중간 검증
|
||||
```
|
||||
$ cd services/core && go test -count=1 ./internal/worker ./internal/storage ./internal/core
|
||||
ok git.toki-labs.com/toki/gito/services/core/internal/worker 0.004s
|
||||
ok git.toki-labs.com/toki/gito/services/core/internal/storage 0.004s
|
||||
ok git.toki-labs.com/toki/gito/services/core/internal/core 0.002s
|
||||
```
|
||||
|
||||
### 최종 검증
|
||||
```
|
||||
$ cd services/core && go test -count=1 ./internal/worker ./internal/storage ./internal/core
|
||||
ok git.toki-labs.com/toki/gito/services/core/internal/worker 0.004s
|
||||
ok git.toki-labs.com/toki/gito/services/core/internal/storage 0.004s
|
||||
ok git.toki-labs.com/toki/gito/services/core/internal/core 0.002s
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
|
||||
> If anything is blank, go back and fill it in before saving this file.
|
||||
> Leave review-agent-only sections unchanged.
|
||||
|
||||
## 코드리뷰 결과
|
||||
|
||||
- 종합 판정: FAIL
|
||||
- 차원별 평가:
|
||||
- correctness: Fail
|
||||
- completeness: Fail
|
||||
- test coverage: Fail
|
||||
- API contract: Pass
|
||||
- code quality: Pass
|
||||
- plan deviation: Pass
|
||||
- verification trust: Warn
|
||||
- 발견된 문제:
|
||||
- Required: `services/core/internal/worker/runner.go:181`에서 `runAgentRunOperation`이 `OperationStore.StartOperation`을 다시 호출합니다. 실제 `pgOperationStore.PickQueuedOperation`은 `services/core/internal/storage/postgres.go:624`에서 queued operation을 이미 `running`으로 전환해 반환하고, `core.OperationState.CanTransitionTo`는 동일 상태 전환을 거부하므로 실제 Postgres store에서는 `running -> running` invalid transition으로 backend invoke 전에 실패합니다. `PickQueuedOperation`이 획득한 running 상태를 시작 상태로 받아들이도록 `StartOperation` 재호출을 제거하거나, picker/handler 계약을 재정의해 단일 지점에서만 `queued -> running` 전환하도록 수정하세요. 또한 `services/core/internal/worker/runner_test.go:56`의 fake store가 실제 transition rule을 반영하지 않아 이 회귀를 잡지 못하므로 stateful fake 또는 storage-backed 검증으로 running operation에서 `StartOperation`이 호출되지 않음을 확인해야 합니다.
|
||||
- 다음 단계: FAIL 후속 루프를 진행한다. `PLAN-local-G06.md`와 `CODE_REVIEW-local-G06.md`에 위 Required 이슈를 좁게 반영한다.
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
<!-- task=m-iop-agent-run-bridge/01_iop_invocation_contract plan=1 tag=REVIEW_API -->
|
||||
|
||||
# Code Review Reference - REVIEW_API
|
||||
|
||||
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
|
||||
> The task is NOT complete until every implementation-owned section below is filled in.
|
||||
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
|
||||
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
|
||||
> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves.
|
||||
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review.
|
||||
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
|
||||
> Follow the ownership table at the bottom of this file for which sections you own.
|
||||
|
||||
## 개요
|
||||
|
||||
date=2026-06-16
|
||||
task=m-iop-agent-run-bridge/01_iop_invocation_contract, plan=1, tag=REVIEW_API
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/agent-shell-iop-execution/milestones/iop-agent-run-bridge.md`
|
||||
- Task ids:
|
||||
- `invoke`: Gito가 직접 IOP CLI를 실행하지 않고 IOP backend invocation/result 계약을 호출하거나 수신하는 형식을 정한다.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 이 파일을 읽는 리뷰 에이전트에게
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
|
||||
|
||||
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
|
||||
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
|
||||
|
||||
1. 판정을 append한다.
|
||||
2. `CODE_REVIEW-local-G06.md` → `code_review_local_G06_N.log`, `PLAN-local-G06.md` → `plan_local_G06_M.log`로 아카이브한다.
|
||||
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-iop-agent-run-bridge/01_iop_invocation_contract/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
|
||||
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
|
||||
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
|
||||
|
||||
---
|
||||
|
||||
## 구현 항목별 완료 여부
|
||||
|
||||
| 항목 | 완료 여부 |
|
||||
|------|---------|
|
||||
| [REVIEW_API-1] Runner agent_run state transition contract | [x] |
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [x] `PickQueuedOperation`이 이미 `running`으로 전환한 `agent_run`을 `StartOperation`으로 다시 시작하지 않도록 runner 상태 전환 경계를 수정한다.
|
||||
- [x] worker test fake를 실제 operation transition rule에 맞게 강화하고, picked `agent_run`에서 `StartOperation`이 호출되지 않아도 backend result가 terminal state/event로 기록되는 테스트를 추가/수정한다. 검증: `cd services/core && go test -count=1 ./internal/worker ./internal/storage ./internal/core`.
|
||||
- [x] local core smoke baseline을 실행하고 실제 stdout/stderr를 review stub에 기록한다. 검증: `cd services/core && go test ./...`.
|
||||
- [x] `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
## 코드리뷰 전용 체크리스트
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
|
||||
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
|
||||
|
||||
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
|
||||
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
|
||||
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G06_N.log`로 아카이브한다.
|
||||
- [x] active `PLAN-*-G??.md`를 `plan_local_G06_M.log`로 아카이브한다.
|
||||
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
|
||||
- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
|
||||
- [x] PASS이면 active task 디렉터리 `agent-task/m-iop-agent-run-bridge/01_iop_invocation_contract/`를 `agent-task/archive/2026/06/m-iop-agent-run-bridge/01_iop_invocation_contract/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
|
||||
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
|
||||
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-iop-agent-run-bridge/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
|
||||
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G06.md`와 `CODE_REVIEW-local-G06.md`를 작성하고 `complete.log`를 작성하지 않는다.
|
||||
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
|
||||
- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
|
||||
|
||||
## 계획 대비 변경 사항
|
||||
|
||||
없음: G06 계획의 REVIEW_API-1 범위대로 runner의 중복 `StartOperation` 호출을 제거하고 worker unit test fake/검증만 보강했다.
|
||||
|
||||
## 주요 설계 결정
|
||||
|
||||
- `PickQueuedOperation`이 `queued -> running` 전이의 단일 owner라는 storage 계약을 유지하고, `runAgentRunOperation`은 picked operation을 이미 running으로 간주한다.
|
||||
- started/agent_started event 기록 순서는 유지하되, operation state 변경은 backend result 이후 `SucceedOperation`/`FailOperation`/`CancelOperation` terminal 전이만 수행한다.
|
||||
- worker test fake는 단일 operation의 현재 상태를 보관하고 `core.OperationState.CanTransitionTo`와 같은 전이 규칙을 적용한다. success/failure path는 picked running operation에서 `StartOperation`이 호출되지 않는지 명시적으로 검증한다.
|
||||
|
||||
## 사용자 리뷰 요청
|
||||
|
||||
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
|
||||
|
||||
- 상태: 없음
|
||||
- 사유 유형: 없음
|
||||
- 결정 필요: 없음
|
||||
- 차단 근거: 없음
|
||||
- 실행한 검증/명령: 없음
|
||||
- 자동 후속 불가 이유: 없음
|
||||
- 재개 조건: 없음
|
||||
|
||||
## 리뷰어를 위한 체크포인트
|
||||
|
||||
- `PickQueuedOperation`이 반환한 `agent_run`을 handler가 다시 `StartOperation`하지 않는지 확인한다.
|
||||
- fake worker store가 실제 `OperationState.CanTransitionTo` 계약을 반영해 중복 start 회귀를 잡는지 확인한다.
|
||||
- IOP CLI/profile/credential/production wiring이 이번 follow-up 범위에 새로 들어오지 않았는지 확인한다.
|
||||
|
||||
## 검증 결과
|
||||
|
||||
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
|
||||
|
||||
필수 규칙:
|
||||
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
|
||||
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
|
||||
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
|
||||
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
|
||||
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
|
||||
|
||||
### REVIEW_API-1 중간 검증
|
||||
```
|
||||
$ cd services/core && go test -count=1 ./internal/worker ./internal/storage ./internal/core
|
||||
ok git.toki-labs.com/toki/gito/services/core/internal/worker 0.004s
|
||||
ok git.toki-labs.com/toki/gito/services/core/internal/storage 0.005s
|
||||
ok git.toki-labs.com/toki/gito/services/core/internal/core 0.004s
|
||||
```
|
||||
|
||||
### 최종 검증
|
||||
```
|
||||
$ cd services/core && go test ./...
|
||||
? git.toki-labs.com/toki/gito/services/core/cmd/server [no test files]
|
||||
? git.toki-labs.com/toki/gito/services/core/cmd/worker [no test files]
|
||||
ok git.toki-labs.com/toki/gito/services/core/internal/config (cached)
|
||||
ok git.toki-labs.com/toki/gito/services/core/internal/controlplane 0.351s
|
||||
ok git.toki-labs.com/toki/gito/services/core/internal/core (cached)
|
||||
? git.toki-labs.com/toki/gito/services/core/internal/events [no test files]
|
||||
ok git.toki-labs.com/toki/gito/services/core/internal/gitengine 1.094s
|
||||
ok git.toki-labs.com/toki/gito/services/core/internal/protosocket (cached)
|
||||
? git.toki-labs.com/toki/gito/services/core/internal/provider [no test files]
|
||||
ok git.toki-labs.com/toki/gito/services/core/internal/provider/forgejo (cached)
|
||||
ok git.toki-labs.com/toki/gito/services/core/internal/storage (cached)
|
||||
ok git.toki-labs.com/toki/gito/services/core/internal/worker 0.003s
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
|
||||
> If anything is blank, go back and fill it in before saving this file.
|
||||
> Leave review-agent-only sections unchanged.
|
||||
|
||||
## 코드리뷰 결과
|
||||
|
||||
- 종합 판정: PASS
|
||||
- 차원별 평가:
|
||||
- correctness: Pass
|
||||
- completeness: Pass
|
||||
- test coverage: Pass
|
||||
- API contract: Pass
|
||||
- code quality: Pass
|
||||
- plan deviation: Pass
|
||||
- verification trust: Pass
|
||||
- 발견된 문제: 없음
|
||||
- 다음 단계: PASS 완료 처리한다. `complete.log`를 작성하고 task directory를 archive로 이동한다.
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
# Complete - m-iop-agent-run-bridge/01_iop_invocation_contract
|
||||
|
||||
## 완료 일시
|
||||
|
||||
2026-06-16
|
||||
|
||||
## 요약
|
||||
|
||||
IOP agent_run invocation/result worker contract를 2회 리뷰 루프로 완료했다. 최종 판정은 PASS다.
|
||||
|
||||
## 루프 이력
|
||||
|
||||
| Plan | Review | Verdict | 메모 |
|
||||
|------|--------|---------|------|
|
||||
| `plan_local_G05_0.log` | `code_review_local_G05_0.log` | FAIL | `PickQueuedOperation`이 이미 running으로 전환한 operation을 runner가 다시 `StartOperation`하는 Required 이슈로 follow-up 생성 |
|
||||
| `plan_local_G06_1.log` | `code_review_local_G06_1.log` | PASS | 중복 `StartOperation` 호출 제거와 stateful fake test 보강 확인 |
|
||||
|
||||
## 구현/정리 내용
|
||||
|
||||
- worker에 `agent_run` input/result adapter 경계와 result 기반 terminal state/event 기록을 추가했다.
|
||||
- 기존 `NewRunner` 시그니처를 유지하고 `NewRunnerWithDependencies`로 테스트/후속 wiring 주입 경로를 열었다.
|
||||
- `PickQueuedOperation`이 이미 `running`으로 만든 operation을 다시 시작하지 않도록 runner state transition 경계를 정리했다.
|
||||
- worker unit test fake를 실제 operation transition rule에 맞게 강화하고 success/failure path에서 backend invoke, terminal state, operation events, sanitized failure message를 검증했다.
|
||||
|
||||
## 최종 검증
|
||||
|
||||
- `cd services/core && go test -count=1 ./internal/worker ./internal/storage ./internal/core` - PASS; worker/storage/core package 모두 `ok`.
|
||||
- `cd services/core && go test ./...` - PASS; core module 전체 Go package 통과.
|
||||
|
||||
## Roadmap Completion
|
||||
|
||||
- Milestone: `agent-roadmap/phase/agent-shell-iop-execution/milestones/iop-agent-run-bridge.md`
|
||||
- Completed task ids:
|
||||
- `invoke`: PASS; evidence=`agent-task/archive/2026/06/m-iop-agent-run-bridge/01_iop_invocation_contract/plan_local_G06_1.log`, `agent-task/archive/2026/06/m-iop-agent-run-bridge/01_iop_invocation_contract/code_review_local_G06_1.log`; verification=`cd services/core && go test -count=1 ./internal/worker ./internal/storage ./internal/core`, `cd services/core && go test ./...`
|
||||
- Not completed task ids: 없음
|
||||
|
||||
## 잔여 Nit
|
||||
|
||||
- 없음
|
||||
|
||||
## 후속 작업
|
||||
|
||||
- 없음
|
||||
|
|
@ -0,0 +1,209 @@
|
|||
<!-- task=m-iop-agent-run-bridge/01_iop_invocation_contract plan=0 tag=API -->
|
||||
|
||||
# Plan - API
|
||||
|
||||
## 이 파일을 읽는 구현 에이전트에게
|
||||
|
||||
구현 완료의 마지막 단계는 active `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우는 것이다. 검증을 실행하고 실제 stdout/stderr를 붙인 뒤 active 파일을 그대로 둔 채 리뷰 준비 상태로 보고한다. 최종 판정, log rename, `complete.log`, task archive 이동은 code-review-skill 전용이다. 구현 중 사용자만 결정할 수 있는 IOP CLI/profile/credential 방식, 외부 secret 준비, 범위 충돌이 필요하면 review stub의 `사용자 리뷰 요청`에 증거와 재개 조건을 기록하고 멈춘다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하거나 `USER_REVIEW.md`/`complete.log`를 만들지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 증거 공백은 사용자 리뷰 요청이 아니다.
|
||||
|
||||
## 배경
|
||||
|
||||
현재 `iop-run` Epic의 `[input]`과 `[state]`는 계약 노트로 정리되었지만, worker는 `agent_run` operation을 실제 IOP backend adapter로 넘기지 않는다. 이 작업은 concrete IOP CLI/credential 결정을 피하면서 fake IOP backend result를 받아 operation state와 durable operation event로 남기는 최소 invocation 경계를 만든다. 성공 조건은 fake backend result가 operation log에 남고 기존 non-agent operation 흐름이 깨지지 않는 것이다.
|
||||
|
||||
## 사용자 리뷰 요청 흐름
|
||||
|
||||
구현 중 blocker는 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 직접 사용자 prompt는 금지이며, code-review가 해당 요청을 검증하고 실제 `USER_REVIEW.md` 작성 여부를 결정한다.
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/agent-shell-iop-execution/milestones/iop-agent-run-bridge.md`
|
||||
- Task ids:
|
||||
- `invoke`: Gito가 직접 IOP CLI를 실행하지 않고 IOP backend invocation/result 계약을 호출하거나 수신하는 형식을 정한다.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 분석 결과
|
||||
|
||||
### 읽은 파일
|
||||
|
||||
- `agent-roadmap/current.md`
|
||||
- `agent-roadmap/phase/agent-shell-iop-execution/PHASE.md`
|
||||
- `agent-roadmap/phase/agent-shell-iop-execution/milestones/iop-agent-run-bridge.md`
|
||||
- `packages/contracts/notes/gito-control-plane.md`
|
||||
- `packages/contracts/README.md`
|
||||
- `docs/architecture.md`
|
||||
- `services/core/internal/core/model.go`
|
||||
- `services/core/internal/core/model_test.go`
|
||||
- `services/core/internal/events/events.go`
|
||||
- `services/core/internal/worker/runner.go`
|
||||
- `services/core/internal/worker/runner_test.go`
|
||||
- `services/core/internal/storage/storage.go`
|
||||
- `services/core/internal/storage/postgres.go`
|
||||
- `services/core/internal/storage/postgres_test.go`
|
||||
- `services/core/internal/storage/postgres_repo_test.go`
|
||||
- `services/core/migrations/00001_initial.sql`
|
||||
- `services/core/cmd/worker/main.go`
|
||||
- `agent-test/local/rules.md`
|
||||
- `agent-test/local/core-smoke.md`
|
||||
- `agent-test/local/contracts-smoke.md`
|
||||
- `agent-test/local/workspace-ops-smoke.md`
|
||||
|
||||
### 테스트 환경 규칙
|
||||
|
||||
- `test_env=local`.
|
||||
- `agent-test/local/rules.md`를 읽었고, core/contract/workspace-ops 라우팅이 적용된다.
|
||||
- 매칭 프로필: `agent-test/local/core-smoke.md`, `agent-test/local/contracts-smoke.md`, `agent-test/local/workspace-ops-smoke.md`.
|
||||
- 적용 명령: core behavior 변경은 `cd services/core && go test ./...`; 새 behavior 검증은 fresh run을 위해 `-count=1`을 사용한다.
|
||||
- 확인한 명령: `cd services/core && go test -count=1 ./internal/worker ./internal/storage ./internal/core`는 현재 레이아웃에서 성공했다.
|
||||
- `<확인 필요>` 값은 없었다. 외부 IOP/credential 검증은 이 plan 범위가 아니므로 사용하지 않는다.
|
||||
|
||||
### 테스트 커버리지 공백
|
||||
|
||||
- 기존 `services/core/internal/core/model_test.go`는 `agent_run` type과 operation state transition을 검증한다.
|
||||
- 기존 `services/core/internal/storage/postgres_test.go`는 operation lifecycle과 event store를 검증한다.
|
||||
- 공백: worker가 `agent_run` operation을 IOP adapter로 넘기고 fake backend result를 event log에 남기는 테스트가 없다. 이 plan에서 worker unit test를 추가한다.
|
||||
|
||||
### 심볼 참조
|
||||
|
||||
- renamed/removed symbol 없음.
|
||||
- `NewRunner` call sites: `services/core/cmd/worker/main.go`, `services/core/internal/worker/runner_test.go`. 기존 signature는 유지한다.
|
||||
|
||||
### 분할 판단
|
||||
|
||||
- split decision policy를 먼저 평가했다.
|
||||
- 공유 task group: `agent-task/m-iop-agent-run-bridge`.
|
||||
- `01_iop_invocation_contract`: 독립 subtask. fake IOP invocation/result를 operation state/event로 기록한다.
|
||||
- `02+01_git_result_convergence`: `01_iop_invocation_contract` 완료 후 진행. IOP success를 Git status/revision evidence로 확정한다.
|
||||
- 이 plan은 worker/storage/event 경계만 다루며, Git evidence 수집은 후속 subtask가 담당한다.
|
||||
|
||||
### 범위 결정 근거
|
||||
|
||||
- IOP CLI command, profile/model, stdin/input-file, credential injection은 Milestone 구현 잠금의 사용자 결정 항목이므로 구현하지 않는다.
|
||||
- operation metadata DB schema, Flutter UI, `../agent-shell`, provider PR/MR, commit/push, dirty workspace policy는 제외한다.
|
||||
- `services/core/cmd/worker/main.go`의 production wiring은 실제 store/IOP adapter 결정이 필요하므로 기존 nil wiring을 유지한다.
|
||||
|
||||
### 빌드 등급
|
||||
|
||||
- `local-G05`: worker/storage/event 경계를 건드리지만 파일 수와 call site가 작고 deterministic Go unit tests로 검증 가능하다.
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [ ] worker에 IOP adapter/result 수신 경계를 추가하고 기존 `NewRunner` call site를 깨지 않는다.
|
||||
- [ ] fake IOP backend result가 `agent_run` operation을 terminal state로 전환하고 operation event log를 남기는 unit test를 추가한다. 검증: `cd services/core && go test -count=1 ./internal/worker ./internal/storage ./internal/core`.
|
||||
- [ ] `invoke` 범위 밖인 CLI/profile/credential/real IOP 호출은 구현하지 않고 review note에 명시한다.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
### [API-1] Worker IOP Invocation Contract
|
||||
|
||||
#### 문제
|
||||
|
||||
- `services/core/internal/worker/runner.go:27`의 `Runner`는 picker와 clock만 들고 있어 IOP backend adapter나 operation event appender를 받을 수 없다.
|
||||
|
||||
```go
|
||||
// services/core/internal/worker/runner.go:27
|
||||
type Runner struct {
|
||||
cfg config.Config
|
||||
logger *slog.Logger
|
||||
picker OperationPicker
|
||||
clock Clock
|
||||
}
|
||||
```
|
||||
|
||||
- `services/core/internal/worker/runner.go:61` 이후는 operation을 집어 로그만 남기고, `agent_run` type을 분기하지 않는다.
|
||||
|
||||
```go
|
||||
// services/core/internal/worker/runner.go:61
|
||||
op, ok, err := r.picker.PickQueuedOperation(ctx, r.clock.Now())
|
||||
if err != nil {
|
||||
r.logger.Error("failed to pick operation", "error", err)
|
||||
return fmt.Errorf("pick operation: %w", err)
|
||||
}
|
||||
|
||||
if !ok {
|
||||
r.logger.Debug("no pending operation found")
|
||||
return nil
|
||||
}
|
||||
|
||||
r.logger.Info("picked operation", "operation_id", op.ID, "type", op.Type)
|
||||
return nil
|
||||
```
|
||||
|
||||
- durable log primitive는 이미 있다: `services/core/internal/storage/storage.go:64`의 operation lifecycle interface와 `services/core/internal/storage/storage.go:81`의 event store.
|
||||
|
||||
#### 해결 방법
|
||||
|
||||
- `worker` package에 IOP adapter용 transport-independent types를 둔다. raw secret/log를 보관하지 않는다.
|
||||
|
||||
```go
|
||||
type AgentRunInput struct {
|
||||
OperationID string
|
||||
RepoID string
|
||||
Branch string
|
||||
WorkspacePath string
|
||||
Instruction string
|
||||
PolicyContext map[string]any
|
||||
ExpectedRevision string
|
||||
CredentialRefs map[string]string
|
||||
}
|
||||
|
||||
type AgentRunState string
|
||||
type AgentRunResult struct {
|
||||
State AgentRunState
|
||||
Message string
|
||||
}
|
||||
|
||||
type AgentRunInputLoader interface {
|
||||
LoadAgentRunInput(ctx context.Context, op core.Operation) (AgentRunInput, error)
|
||||
}
|
||||
|
||||
type AgentRunInvoker interface {
|
||||
InvokeAgentRun(ctx context.Context, input AgentRunInput) (AgentRunResult, error)
|
||||
}
|
||||
```
|
||||
|
||||
- 기존 `NewRunner(cfg, logger, picker, clock)`는 유지한다. 새 constructor나 option으로 `OperationStateStore`, `OperationEventStore`, `AgentRunInputLoader`, `AgentRunInvoker`를 주입한다.
|
||||
- `RunOnce`는 `op.Type != core.OperationAgentRun`이면 기존 동작을 유지한다.
|
||||
- `agent_run`이면 input loader로 `AgentRunInput`을 만들고 invoker를 호출한다. result state에 따라 `SucceedOperation`, `FailOperation`, `CancelOperation` 중 하나를 호출한다.
|
||||
- started/terminal operation event를 JSON object payload로 append한다. event type은 기존 `events.OperationStarted`, `events.OperationCompleted`, `events.OperationFailed`, `events.AgentRunStarted`, `events.AgentRunCompleted`만 사용한다. cancellation-specific event naming은 후속 cancellation Milestone으로 남긴다.
|
||||
|
||||
#### 수정 파일 및 체크리스트
|
||||
|
||||
- [ ] `services/core/internal/worker/runner.go`: IOP input/result types, optional constructor, `agent_run` dispatch, operation event append helper를 추가한다.
|
||||
- [ ] `services/core/internal/worker/runner_test.go`: fake loader/invoker/lifecycle/event store를 추가하고 success/failure path를 검증한다.
|
||||
- [ ] `services/core/internal/events/events.go`: 새 event constant가 정말 필요할 때만 추가한다. 기본 계획은 기존 constants를 재사용한다.
|
||||
- [ ] `services/core/cmd/worker/main.go`: 기존 constructor call을 깨지 않는지 확인한다. real IOP wiring은 하지 않는다.
|
||||
|
||||
#### 테스트 작성
|
||||
|
||||
- 작성: `services/core/internal/worker/runner_test.go`.
|
||||
- 테스트 이름:
|
||||
- `TestRunnerRunOnceAgentRunInvokesBackendAndLogsEvents`
|
||||
- `TestRunnerRunOnceAgentRunFailsWhenBackendFails`
|
||||
- `TestRunnerRunOnceNonAgentOperationKeepsExistingBehavior`
|
||||
- assertion 목표: fake backend result가 terminal operation state와 ordered operation events를 남기고, backend error는 sanitized failure event를 남긴다.
|
||||
|
||||
#### 중간 검증
|
||||
|
||||
```bash
|
||||
cd services/core && go test -count=1 ./internal/worker ./internal/storage ./internal/core
|
||||
```
|
||||
|
||||
기대 결과: 세 package 모두 `ok`.
|
||||
|
||||
## 수정 파일 요약
|
||||
|
||||
| 파일 | 항목 |
|
||||
| --- | --- |
|
||||
| `services/core/internal/worker/runner.go` | API-1 |
|
||||
| `services/core/internal/worker/runner_test.go` | API-1 |
|
||||
| `services/core/internal/events/events.go` | API-1 |
|
||||
| `services/core/cmd/worker/main.go` | API-1 |
|
||||
|
||||
## 최종 검증
|
||||
|
||||
```bash
|
||||
cd services/core && go test -count=1 ./internal/worker ./internal/storage ./internal/core
|
||||
```
|
||||
|
||||
기대 결과: `worker`, `storage`, `core` package가 모두 통과한다. Go test cache output은 허용하지 않는다.
|
||||
|
||||
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
<!-- task=m-iop-agent-run-bridge/01_iop_invocation_contract plan=1 tag=REVIEW_API -->
|
||||
|
||||
# Plan - REVIEW_API
|
||||
|
||||
## 이 파일을 읽는 구현 에이전트에게
|
||||
|
||||
구현 완료의 마지막 단계는 active `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우는 것이다. 검증을 실행하고 실제 stdout/stderr를 붙인 뒤 active 파일을 그대로 둔 채 리뷰 준비 상태로 보고한다. 최종 판정, log rename, `complete.log`, task archive 이동은 code-review-skill 전용이다. 구현 중 사용자만 결정할 수 있는 IOP CLI/profile/credential 방식, 외부 secret 준비, 범위 충돌이 필요하면 review stub의 `사용자 리뷰 요청`에 증거와 재개 조건을 기록하고 멈춘다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하거나 `USER_REVIEW.md`/`complete.log`를 만들지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 증거 공백은 사용자 리뷰 요청이 아니다.
|
||||
|
||||
## 배경
|
||||
|
||||
G05 리뷰에서 `agent_run` 처리 경계가 실제 storage 계약과 맞지 않는 Required 이슈가 확인되었다. `pgOperationStore.PickQueuedOperation`은 queued operation을 이미 `running`으로 전환해 반환하는데, 새 `runAgentRunOperation`이 다시 `StartOperation`을 호출해 실제 Postgres store에서는 `running -> running` invalid transition으로 backend invoke 전에 실패한다. 기존 worker unit test의 fake store가 이 transition rule을 반영하지 않아 제출 검증은 통과했지만 실제 store 계약 회귀를 잡지 못했다.
|
||||
|
||||
## 사용자 리뷰 요청 흐름
|
||||
|
||||
구현 중 blocker는 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 직접 사용자 prompt는 금지이며, code-review가 해당 요청을 검증하고 실제 `USER_REVIEW.md` 작성 여부를 결정한다.
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/agent-shell-iop-execution/milestones/iop-agent-run-bridge.md`
|
||||
- Task ids:
|
||||
- `invoke`: Gito가 직접 IOP CLI를 실행하지 않고 IOP backend invocation/result 계약을 호출하거나 수신하는 형식을 정한다.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 이전 리뷰 이슈
|
||||
|
||||
- `code_review_local_G05_0.log`: Required - `services/core/internal/worker/runner.go:181`의 `StartOperation` 재호출이 실제 `pgOperationStore.PickQueuedOperation`의 이미-running 계약과 충돌한다. `services/core/internal/worker/runner_test.go:56`의 fake store도 실제 transition rule을 반영하지 못한다.
|
||||
|
||||
## 분석 결과
|
||||
|
||||
### 읽은 파일
|
||||
|
||||
- `agent-task/m-iop-agent-run-bridge/01_iop_invocation_contract/plan_local_G05_0.log`
|
||||
- `agent-task/m-iop-agent-run-bridge/01_iop_invocation_contract/code_review_local_G05_0.log`
|
||||
- `services/core/internal/worker/runner.go`
|
||||
- `services/core/internal/worker/runner_test.go`
|
||||
- `services/core/internal/storage/storage.go`
|
||||
- `services/core/internal/storage/postgres.go`
|
||||
- `services/core/internal/core/model.go`
|
||||
|
||||
### 범위 결정 근거
|
||||
|
||||
- 이번 루프는 G05 Required 이슈만 수정한다.
|
||||
- concrete IOP CLI/profile/credential, production `cmd/worker` wiring, operation metadata schema, Git evidence convergence는 범위 밖이다.
|
||||
- `PickQueuedOperation`의 existing storage contract는 유지한다. worker handler가 picked operation의 `running` 상태를 수용하도록 맞춘다.
|
||||
|
||||
### 빌드 등급
|
||||
|
||||
- `local-G06`: 실제 store 계약과 fake test fidelity를 함께 조정해야 하지만, 수정 범위는 worker runner와 unit test에 한정되고 deterministic Go tests로 검증 가능하다.
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [ ] `PickQueuedOperation`이 이미 `running`으로 전환한 `agent_run`을 `StartOperation`으로 다시 시작하지 않도록 runner 상태 전환 경계를 수정한다.
|
||||
- [ ] worker test fake를 실제 operation transition rule에 맞게 강화하고, picked `agent_run`에서 `StartOperation`이 호출되지 않아도 backend result가 terminal state/event로 기록되는 테스트를 추가/수정한다. 검증: `cd services/core && go test -count=1 ./internal/worker ./internal/storage ./internal/core`.
|
||||
- [ ] local core smoke baseline을 실행하고 실제 stdout/stderr를 review stub에 기록한다. 검증: `cd services/core && go test ./...`.
|
||||
- [ ] `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
### [REVIEW_API-1] Runner agent_run state transition contract
|
||||
|
||||
#### 문제
|
||||
|
||||
- `services/core/internal/storage/postgres.go:624`의 `PickQueuedOperation`은 DB에서 queued row를 `running`으로 업데이트한 뒤 operation을 반환한다.
|
||||
- `services/core/internal/worker/runner.go:181`은 같은 operation에 `StartOperation`을 다시 호출한다.
|
||||
- `services/core/internal/core/model.go:91`의 transition rule은 동일 상태 전환을 거부하므로 실제 store에서는 backend invoke 전에 invalid transition이 발생한다.
|
||||
- `services/core/internal/worker/runner_test.go:56` fake store는 이 rule을 반영하지 않아 회귀를 놓쳤다.
|
||||
|
||||
#### 해결 방법
|
||||
|
||||
- `runAgentRunOperation`에서 picked operation을 이미 running으로 간주하고 중복 `StartOperation` 호출을 제거한다.
|
||||
- started event append 순서는 유지하되, operation terminal 전환은 `SucceedOperation`/`FailOperation`/`CancelOperation`만 수행한다.
|
||||
- 테스트 fake는 current state를 추적하거나 `StartOperation` 호출 자체를 실패하도록 구성해, agent_run path가 중복 start를 시도하지 않는지 검증한다.
|
||||
- 성공 path와 backend error path 모두 terminal operation state와 ordered operation events를 계속 검증한다.
|
||||
|
||||
#### 수정 파일 및 체크리스트
|
||||
|
||||
- [ ] `services/core/internal/worker/runner.go`: 중복 `StartOperation` 호출 제거 또는 동일 효과의 단일-transition 계약으로 수정한다.
|
||||
- [ ] `services/core/internal/worker/runner_test.go`: fake store fidelity를 높이고 중복 start 회귀 검증을 추가한다.
|
||||
|
||||
#### 테스트 작성
|
||||
|
||||
- 수정/보강: `services/core/internal/worker/runner_test.go`.
|
||||
- assertion 목표:
|
||||
- picked `agent_run` operation은 backend invoke 전에 `StartOperation`을 다시 호출하지 않는다.
|
||||
- success result는 `SucceedOperation`과 `operation.completed`/`agent.run.completed` event를 남긴다.
|
||||
- backend error result는 sanitized message와 `OperationFailed` terminal state/event를 남긴다.
|
||||
|
||||
#### 중간 검증
|
||||
|
||||
```bash
|
||||
cd services/core && go test -count=1 ./internal/worker ./internal/storage ./internal/core
|
||||
```
|
||||
|
||||
기대 결과: 세 package 모두 `ok`.
|
||||
|
||||
## 수정 파일 요약
|
||||
|
||||
| 파일 | 항목 |
|
||||
| --- | --- |
|
||||
| `services/core/internal/worker/runner.go` | REVIEW_API-1 |
|
||||
| `services/core/internal/worker/runner_test.go` | REVIEW_API-1 |
|
||||
|
||||
## 최종 검증
|
||||
|
||||
```bash
|
||||
cd services/core && go test ./...
|
||||
```
|
||||
|
||||
기대 결과: 모든 core Go package가 통과한다.
|
||||
|
||||
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
<!-- task=m-iop-agent-run-bridge/02+01_git_result_convergence plan=0 tag=API -->
|
||||
|
||||
# Code Review Reference - API
|
||||
|
||||
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
|
||||
> The task is NOT complete until every implementation-owned section below is filled in.
|
||||
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
|
||||
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
|
||||
> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves.
|
||||
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review.
|
||||
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
|
||||
> Follow the ownership table at the bottom of this file for which sections you own.
|
||||
|
||||
## 개요
|
||||
|
||||
date=2026-06-16
|
||||
task=m-iop-agent-run-bridge/02+01_git_result_convergence, plan=0, tag=API
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/agent-shell-iop-execution/milestones/iop-agent-run-bridge.md`
|
||||
- Task ids:
|
||||
- `result`: run 이후 Git status와 revision diff로 성공 여부를 확인한다.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 이 파일을 읽는 리뷰 에이전트에게
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
|
||||
|
||||
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
|
||||
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
|
||||
|
||||
1. 판정을 append한다.
|
||||
2. `CODE_REVIEW-local-G06.md` → `code_review_local_G06_N.log`, `PLAN-local-G06.md` → `plan_local_G06_M.log`로 아카이브한다.
|
||||
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-iop-agent-run-bridge/02+01_git_result_convergence/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
|
||||
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
|
||||
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
|
||||
|
||||
---
|
||||
|
||||
## 구현 항목별 완료 여부
|
||||
|
||||
| 항목 | 완료 여부 |
|
||||
|------|---------|
|
||||
| [API-1] Git Evidence For Agent Run Result | [ ] |
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [ ] `01_iop_invocation_contract`의 `complete.log`가 있는지 확인하고, 없으면 구현을 시작하지 않는다.
|
||||
- [ ] IOP succeeded result path에서 Git status, before/after revision, changed files evidence를 수집해 operation completion payload에 남긴다.
|
||||
- [ ] dirty workspace policy, commit/push, push 이후 revision event 발행은 구현하지 않고 후속 Epic 범위로 남긴다.
|
||||
- [ ] result evidence unit test를 추가한다. 검증: `cd services/core && go test -count=1 ./internal/worker ./internal/controlplane ./internal/gitengine ./internal/storage`.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
## 코드리뷰 전용 체크리스트
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
|
||||
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
|
||||
|
||||
- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
|
||||
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
|
||||
- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G06_N.log`로 아카이브한다.
|
||||
- [ ] active `PLAN-*-G??.md`를 `plan_local_G06_M.log`로 아카이브한다.
|
||||
- [ ] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
|
||||
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
|
||||
- [ ] PASS이면 active task 디렉터리 `agent-task/m-iop-agent-run-bridge/02+01_git_result_convergence/`를 `agent-task/archive/YYYY/MM/m-iop-agent-run-bridge/02+01_git_result_convergence/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
|
||||
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
|
||||
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-iop-agent-run-bridge/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
|
||||
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G06.md`와 `CODE_REVIEW-local-G06.md`를 작성하고 `complete.log`를 작성하지 않는다.
|
||||
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
|
||||
- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
|
||||
|
||||
## 계획 대비 변경 사항
|
||||
|
||||
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
|
||||
|
||||
## 주요 설계 결정
|
||||
|
||||
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
|
||||
|
||||
## 사용자 리뷰 요청
|
||||
|
||||
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
|
||||
|
||||
- 상태: 없음
|
||||
- 사유 유형: 없음
|
||||
- 결정 필요: 없음
|
||||
- 차단 근거: 없음
|
||||
- 실행한 검증/명령: 없음
|
||||
- 자동 후속 불가 이유: 없음
|
||||
- 재개 조건: 없음
|
||||
|
||||
## 리뷰어를 위한 체크포인트
|
||||
|
||||
- predecessor `01_iop_invocation_contract` completion이 확인되었는지 본다.
|
||||
- IOP success가 Git evidence collection 실패를 무시하고 성공 처리하지 않는지 확인한다.
|
||||
- dirty policy, commit/push, revision-event 발행이 이 plan 범위를 넘어 구현되지 않았는지 확인한다.
|
||||
|
||||
## 검증 결과
|
||||
|
||||
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
|
||||
|
||||
필수 규칙:
|
||||
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
|
||||
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
|
||||
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
|
||||
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
|
||||
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
|
||||
|
||||
### API-1 중간 검증
|
||||
```
|
||||
$ cd services/core && go test -count=1 ./internal/worker ./internal/controlplane ./internal/gitengine ./internal/storage
|
||||
(output)
|
||||
```
|
||||
|
||||
### 최종 검증
|
||||
```
|
||||
$ cd services/core && go test -count=1 ./internal/worker ./internal/controlplane ./internal/gitengine ./internal/storage
|
||||
(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?**
|
||||
> If anything is blank, go back and fill it in before saving this file.
|
||||
> Leave review-agent-only sections unchanged.
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
<!-- task=m-iop-agent-run-bridge/02+01_git_result_convergence plan=0 tag=API -->
|
||||
|
||||
# Plan - API
|
||||
|
||||
## 이 파일을 읽는 구현 에이전트에게
|
||||
|
||||
구현 완료의 마지막 단계는 active `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우는 것이다. 검증을 실행하고 실제 stdout/stderr를 붙인 뒤 active 파일을 그대로 둔 채 리뷰 준비 상태로 보고한다. 최종 판정, log rename, `complete.log`, task archive 이동은 code-review-skill 전용이다. 구현 중 사용자만 결정할 수 있는 IOP CLI/profile/credential 방식, 외부 secret 준비, 범위 충돌이 필요하면 review stub의 `사용자 리뷰 요청`에 증거와 재개 조건을 기록하고 멈춘다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하거나 `USER_REVIEW.md`/`complete.log`를 만들지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 증거 공백은 사용자 리뷰 요청이 아니다.
|
||||
|
||||
## 배경
|
||||
|
||||
`IOP Agent Run Bridge`는 구조화된 IOP success만으로 operation을 성공 처리하지 않기로 정했다. 이 후속 작업은 `01_iop_invocation_contract`가 만든 fake IOP result path에 Git status와 before/after revision evidence를 붙인다. dirty workspace policy, commit/push, push 이후 revision event 발행은 같은 Milestone의 `git-result` Epic에 남긴다.
|
||||
|
||||
## 사용자 리뷰 요청 흐름
|
||||
|
||||
구현 중 blocker는 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 직접 사용자 prompt는 금지이며, code-review가 해당 요청을 검증하고 실제 `USER_REVIEW.md` 작성 여부를 결정한다.
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/agent-shell-iop-execution/milestones/iop-agent-run-bridge.md`
|
||||
- Task ids:
|
||||
- `result`: run 이후 Git status와 revision diff로 성공 여부를 확인한다.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 분석 결과
|
||||
|
||||
### 읽은 파일
|
||||
|
||||
- `agent-roadmap/current.md`
|
||||
- `agent-roadmap/phase/agent-shell-iop-execution/PHASE.md`
|
||||
- `agent-roadmap/phase/agent-shell-iop-execution/milestones/iop-agent-run-bridge.md`
|
||||
- `packages/contracts/notes/gito-control-plane.md`
|
||||
- `services/core/internal/worker/runner.go`
|
||||
- `services/core/internal/worker/runner_test.go`
|
||||
- `services/core/internal/gitengine/command.go`
|
||||
- `services/core/internal/gitengine/command_test.go`
|
||||
- `services/core/internal/controlplane/runtime.go`
|
||||
- `services/core/internal/controlplane/runtime_test.go`
|
||||
- `services/core/internal/storage/storage.go`
|
||||
- `services/core/internal/storage/postgres.go`
|
||||
- `services/core/internal/storage/postgres_test.go`
|
||||
- `services/core/internal/storage/postgres_repo_test.go`
|
||||
- `services/core/internal/core/model.go`
|
||||
- `services/core/internal/core/model_test.go`
|
||||
- `agent-test/local/rules.md`
|
||||
- `agent-test/local/core-smoke.md`
|
||||
- `agent-test/local/contracts-smoke.md`
|
||||
- `agent-test/local/workspace-ops-smoke.md`
|
||||
|
||||
### 테스트 환경 규칙
|
||||
|
||||
- `test_env=local`.
|
||||
- `agent-test/local/rules.md`를 읽었고 core/contract/workspace-ops 라우팅이 적용된다.
|
||||
- 매칭 프로필: `agent-test/local/core-smoke.md`, `agent-test/local/contracts-smoke.md`, `agent-test/local/workspace-ops-smoke.md`.
|
||||
- 적용 명령: core behavior 변경은 `cd services/core && go test ./...`; 새 behavior 검증은 fresh run을 위해 `-count=1`을 사용한다.
|
||||
- 확인한 명령: `cd services/core && go test -count=1 ./internal/worker ./internal/controlplane ./internal/gitengine ./internal/storage`는 현재 레이아웃에서 성공했다.
|
||||
- `<확인 필요>` 값은 없었다. 실제 external IOP runner는 이 plan 범위가 아니다.
|
||||
|
||||
### 테스트 커버리지 공백
|
||||
|
||||
- 기존 `gitengine` tests는 status, diff, head revision, changed files를 검증한다.
|
||||
- 기존 `controlplane` tests는 `ScanBranchRevision`이 before/after revision과 changed files를 만든다는 점을 검증한다.
|
||||
- 공백: IOP result path에서 Git status/revision evidence를 operation completion payload로 붙이는 테스트가 없다. 이 plan에서 worker 중심 unit test를 추가한다.
|
||||
|
||||
### 심볼 참조
|
||||
|
||||
- renamed/removed symbol 없음.
|
||||
- 이 plan은 `01_iop_invocation_contract`가 추가한 worker types를 확장할 수 있으므로, 구현 전에 active/archived predecessor의 최종 type 이름을 확인한다.
|
||||
|
||||
### 분할 판단
|
||||
|
||||
- split decision policy를 먼저 평가했다.
|
||||
- 공유 task group: `agent-task/m-iop-agent-run-bridge`.
|
||||
- 현재 subtask: `02+01_git_result_convergence`.
|
||||
- predecessor `01`: `agent-task/m-iop-agent-run-bridge/01_iop_invocation_contract/complete.log`가 아직 없다. 이 plan은 작성만 해두며, 구현은 predecessor PASS 후 시작해야 한다.
|
||||
|
||||
### 범위 결정 근거
|
||||
|
||||
- 이 plan은 `iop-run` Epic의 `[result]`만 완료 대상으로 둔다.
|
||||
- `git-result` Epic의 `[dirty-guard]`, `[commit-push]`, `[revision-event]`는 제외한다.
|
||||
- dirty workspace를 성공/실패로 최종 정책화하지 않는다. 이 plan은 evidence 수집과 operation payload 기록까지 수행하고, dirty policy는 후속 Epic에서 결정한다.
|
||||
- 실제 IOP CLI/profile/credential, Flutter UI, provider PR/MR은 제외한다.
|
||||
|
||||
### 빌드 등급
|
||||
|
||||
- `local-G06`: predecessor type에 의존하고 worker/gitengine/controlplane context가 함께 필요하지만, 테스트 가능한 platformless Git evidence path로 범위를 묶을 수 있다.
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [ ] `01_iop_invocation_contract`의 `complete.log`가 있는지 확인하고, 없으면 구현을 시작하지 않는다.
|
||||
- [ ] IOP succeeded result path에서 Git status, before/after revision, changed files evidence를 수집해 operation completion payload에 남긴다.
|
||||
- [ ] dirty workspace policy, commit/push, push 이후 revision event 발행은 구현하지 않고 후속 Epic 범위로 남긴다.
|
||||
- [ ] result evidence unit test를 추가한다. 검증: `cd services/core && go test -count=1 ./internal/worker ./internal/controlplane ./internal/gitengine ./internal/storage`.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
## 의존 관계 및 구현 순서
|
||||
|
||||
이 subtask directory 이름 `02+01_git_result_convergence`는 predecessor index `01`을 요구한다. 구현 전 `agent-task/m-iop-agent-run-bridge/01_iop_invocation_contract/complete.log` 또는 matching archive path의 `complete.log`를 확인한다. 현재 분석 시점에는 predecessor completion이 missing이다.
|
||||
|
||||
### [API-1] Git Evidence For Agent Run Result
|
||||
|
||||
#### 문제
|
||||
|
||||
- 계약 노트는 IOP `succeeded`가 Git evidence 확인 뒤에만 Gito `succeeded`가 된다고 정한다.
|
||||
|
||||
```markdown
|
||||
<!-- packages/contracts/notes/gito-control-plane.md:89 -->
|
||||
| `succeeded` | IOP reports successful execution. Gito marks `succeeded` only after the Git result confirmation step accepts workspace status and revision evidence. |
|
||||
```
|
||||
|
||||
- `gitengine`에는 필요한 primitive가 이미 있지만 worker result path에 연결되어 있지 않다.
|
||||
|
||||
```go
|
||||
// services/core/internal/gitengine/command.go:128
|
||||
func Status(runner CommandRunner, workdir string) (StatusResult, error) {
|
||||
if strings.TrimSpace(workdir) == "" {
|
||||
return StatusResult{}, ErrInvalidGitInput
|
||||
}
|
||||
output, err := runner.Run(workdir, "status", "--porcelain")
|
||||
```
|
||||
|
||||
```go
|
||||
// services/core/internal/gitengine/command.go:189
|
||||
func HeadRevision(runner CommandRunner, workdir string) (string, error) {
|
||||
if strings.TrimSpace(workdir) == "" {
|
||||
return "", ErrInvalidGitInput
|
||||
}
|
||||
output, err := runner.Run(workdir, "rev-parse", "HEAD")
|
||||
```
|
||||
|
||||
```go
|
||||
// services/core/internal/gitengine/command.go:225
|
||||
func ChangedFilesWithStatus(runner CommandRunner, workdir, before, after string) ([]ChangedFile, error) {
|
||||
before = strings.TrimSpace(before)
|
||||
after = strings.TrimSpace(after)
|
||||
if strings.TrimSpace(workdir) == "" || before == "" || after == "" {
|
||||
```
|
||||
|
||||
- `controlplane.ScanBranchRevision` already builds revision events, but it is watch/cursor-oriented rather than operation result-oriented.
|
||||
|
||||
#### 해결 방법
|
||||
|
||||
- `01_iop_invocation_contract`에서 추가된 worker result path를 확장한다.
|
||||
- `AgentRunInput.ExpectedRevision`과 `AgentRunInput.WorkspacePath`가 있으면 `gitengine.Status`, `gitengine.HeadRevision`, `gitengine.ChangedFilesWithStatus`를 실행한다.
|
||||
- evidence struct를 operation completion payload에 넣는다.
|
||||
|
||||
```go
|
||||
type AgentRunGitEvidence struct {
|
||||
StatusClean bool
|
||||
StatusOutput string
|
||||
BeforeRevision string
|
||||
AfterRevision string
|
||||
ChangedFiles []gitengine.ChangedFile
|
||||
}
|
||||
```
|
||||
|
||||
- Git evidence 수집 실패는 IOP success를 신뢰하지 않고 operation failure로 기록한다.
|
||||
- dirty status는 payload에 남기되, dirty policy에 따라 실패/lease dirty 처리하는 로직은 후속 `dirty-guard` Task로 남긴다.
|
||||
|
||||
#### 수정 파일 및 체크리스트
|
||||
|
||||
- [ ] `services/core/internal/worker/runner.go`: IOP success path에 Git evidence collector를 연결한다.
|
||||
- [ ] `services/core/internal/worker/runner_test.go`: fake git runner로 clean/no-change, clean/changed-revision, evidence collection failure를 검증한다.
|
||||
- [ ] `services/core/internal/gitengine/command.go`: 새 helper가 꼭 필요할 때만 추가하고 기존 primitive 재사용을 우선한다.
|
||||
- [ ] `services/core/internal/controlplane/runtime.go`: 직접 수정하지 않는 것을 기본으로 하되, changed-file conversion helper 재사용이 필요하면 중복을 최소화한다.
|
||||
|
||||
#### 테스트 작성
|
||||
|
||||
- 작성: `services/core/internal/worker/runner_test.go`.
|
||||
- 테스트 이름:
|
||||
- `TestRunnerRunOnceAgentRunRecordsGitEvidence`
|
||||
- `TestRunnerRunOnceAgentRunFailsWhenGitEvidenceFails`
|
||||
- assertion 목표: completed payload에 `status_clean`, `before_revision`, `after_revision`, `changed_files`가 있고, evidence collection error는 failed operation event로 수렴한다.
|
||||
|
||||
#### 중간 검증
|
||||
|
||||
```bash
|
||||
cd services/core && go test -count=1 ./internal/worker ./internal/controlplane ./internal/gitengine ./internal/storage
|
||||
```
|
||||
|
||||
기대 결과: 네 package 모두 `ok`.
|
||||
|
||||
## 수정 파일 요약
|
||||
|
||||
| 파일 | 항목 |
|
||||
| --- | --- |
|
||||
| `services/core/internal/worker/runner.go` | API-1 |
|
||||
| `services/core/internal/worker/runner_test.go` | API-1 |
|
||||
| `services/core/internal/gitengine/command.go` | API-1 |
|
||||
| `services/core/internal/controlplane/runtime.go` | API-1 |
|
||||
|
||||
## 최종 검증
|
||||
|
||||
```bash
|
||||
cd services/core && go test -count=1 ./internal/worker ./internal/controlplane ./internal/gitengine ./internal/storage
|
||||
```
|
||||
|
||||
기대 결과: `worker`, `controlplane`, `gitengine`, `storage` package가 모두 통과한다. Go test cache output은 허용하지 않는다.
|
||||
|
||||
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -55,8 +55,44 @@ provider-neutral Git results. IOP owns agent execution, shell command execution,
|
|||
model/runtime routing, command policy, cancellation, and log redaction source of
|
||||
truth. Gito may persist IOP results only after they are expressed as operation
|
||||
state, Git revision/diff, workspace lease state, or normalized event records.
|
||||
Gito must not expose a competing agent execution API from this contract note
|
||||
unless a later Milestone explicitly defines an IOP bridge contract.
|
||||
The IOP bridge below is the transport-independent contract candidate for this
|
||||
Milestone. It does not choose the concrete IOP CLI command, profile/model,
|
||||
stdin/input-file shape, or secret injection mechanism.
|
||||
|
||||
## IOP Agent Run Bridge
|
||||
|
||||
Gito creates and owns an `agent_run` Operation, then hands execution intent to
|
||||
IOP through an adapter boundary. Gito must not execute the IOP CLI or arbitrary
|
||||
workspace commands directly. IOP may run through CLI, Edge, Node, or another
|
||||
runtime, but Gito only records the result once it is expressed as operation
|
||||
state, Git workspace evidence, or normalized event records.
|
||||
|
||||
### AgentRunInput
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `operation_id` | Existing Gito operation id with `type=agent_run`. |
|
||||
| `repo` | Repo snapshot containing `id`, `remote_url`, `default_branch`, and optional `credential_ref`; raw credential values are forbidden. |
|
||||
| `branch` | Branch checked out in the leased workspace. |
|
||||
| `workspace_path` | Absolute path of the leased workspace slot that IOP may use. |
|
||||
| `instruction` | User or automation instruction text for the run. |
|
||||
| `policy_context` | JSON object with Gito-known policy context, approvals, and allowed result actions; IOP remains source of truth for command policy decisions. |
|
||||
| `expected_revision` | Optional workspace HEAD before IOP starts, used later for revision diff checks. |
|
||||
| `credential_refs` | Optional list or map of credential references. Values point to secrets outside tracked contracts and logs. |
|
||||
|
||||
### AgentRunState Mapping
|
||||
|
||||
| IOP run state | Gito operation lifecycle |
|
||||
| --- | --- |
|
||||
| `queued` | Operation exists as `queued`; IOP execution has not been accepted yet. |
|
||||
| `running` | Operation moves to `running`; Gito may append `operation.started` and `agent.run.started` records. |
|
||||
| `succeeded` | IOP reports successful execution. Gito marks `succeeded` only after the Git result confirmation step accepts workspace status and revision evidence. |
|
||||
| `failed` | Operation moves to `failed`; Gito records sanitized failure context without raw logs or secrets. |
|
||||
| `cancelled` | Operation moves to terminal `cancelled`; cancellation policy/event details belong to the command policy and cancellation Milestone. |
|
||||
|
||||
Structured IOP output is advisory. The success source of truth for this bridge is
|
||||
the Git workspace after the run: status, before/after revision, changed files,
|
||||
and any later commit/push result allowed by policy.
|
||||
|
||||
## MVP: Forgejo Branch Event Flow
|
||||
|
||||
|
|
|
|||
|
|
@ -2,12 +2,16 @@ package worker
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.toki-labs.com/toki/gito/services/core/internal/config"
|
||||
"git.toki-labs.com/toki/gito/services/core/internal/core"
|
||||
"git.toki-labs.com/toki/gito/services/core/internal/events"
|
||||
"git.toki-labs.com/toki/gito/services/core/internal/storage"
|
||||
)
|
||||
|
||||
type OperationPicker interface {
|
||||
|
|
@ -24,22 +28,76 @@ func (realClock) Now() time.Time {
|
|||
return time.Now()
|
||||
}
|
||||
|
||||
type AgentRunInput struct {
|
||||
OperationID string
|
||||
RepoID string
|
||||
Branch string
|
||||
WorkspacePath string
|
||||
Instruction string
|
||||
PolicyContext map[string]any
|
||||
ExpectedRevision string
|
||||
CredentialRefs map[string]string
|
||||
}
|
||||
|
||||
type AgentRunState string
|
||||
|
||||
const (
|
||||
AgentRunStateSucceeded AgentRunState = "succeeded"
|
||||
AgentRunStateFailed AgentRunState = "failed"
|
||||
AgentRunStateCancelled AgentRunState = "cancelled"
|
||||
)
|
||||
|
||||
type AgentRunResult struct {
|
||||
State AgentRunState
|
||||
Message string
|
||||
}
|
||||
|
||||
type AgentRunInputLoader interface {
|
||||
LoadAgentRunInput(ctx context.Context, op core.Operation) (AgentRunInput, error)
|
||||
}
|
||||
|
||||
type AgentRunInvoker interface {
|
||||
InvokeAgentRun(ctx context.Context, input AgentRunInput) (AgentRunResult, error)
|
||||
}
|
||||
|
||||
type RunnerDependencies struct {
|
||||
OperationStore storage.OperationStore
|
||||
OperationEventStore storage.OperationEventStore
|
||||
AgentRunInputLoader AgentRunInputLoader
|
||||
AgentRunInvoker AgentRunInvoker
|
||||
}
|
||||
|
||||
type Runner struct {
|
||||
cfg config.Config
|
||||
logger *slog.Logger
|
||||
picker OperationPicker
|
||||
clock Clock
|
||||
cfg config.Config
|
||||
logger *slog.Logger
|
||||
picker OperationPicker
|
||||
clock Clock
|
||||
operationStore storage.OperationStore
|
||||
operationEventStore storage.OperationEventStore
|
||||
agentRunInputLoader AgentRunInputLoader
|
||||
agentRunInvoker AgentRunInvoker
|
||||
}
|
||||
|
||||
func NewRunner(cfg config.Config, logger *slog.Logger, picker OperationPicker, clock Clock) *Runner {
|
||||
return NewRunnerWithDependencies(cfg, logger, picker, clock, RunnerDependencies{})
|
||||
}
|
||||
|
||||
func NewRunnerWithDependencies(cfg config.Config, logger *slog.Logger, picker OperationPicker, clock Clock, deps RunnerDependencies) *Runner {
|
||||
if clock == nil {
|
||||
clock = realClock{}
|
||||
}
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &Runner{
|
||||
cfg: cfg,
|
||||
logger: logger,
|
||||
picker: picker,
|
||||
clock: clock,
|
||||
cfg: cfg,
|
||||
logger: logger,
|
||||
picker: picker,
|
||||
clock: clock,
|
||||
operationStore: deps.OperationStore,
|
||||
operationEventStore: deps.OperationEventStore,
|
||||
agentRunInputLoader: deps.AgentRunInputLoader,
|
||||
agentRunInvoker: deps.AgentRunInvoker,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -70,5 +128,171 @@ func (r *Runner) RunOnce(ctx context.Context) error {
|
|||
}
|
||||
|
||||
r.logger.Info("picked operation", "operation_id", op.ID, "type", op.Type)
|
||||
if op.Type == core.OperationAgentRun {
|
||||
return r.runAgentRunOperation(ctx, op)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Runner) runAgentRunOperation(ctx context.Context, op core.Operation) error {
|
||||
if r.operationStore == nil || r.operationEventStore == nil || r.agentRunInputLoader == nil || r.agentRunInvoker == nil {
|
||||
return fmt.Errorf("agent_run handler is not fully configured")
|
||||
}
|
||||
|
||||
startedAt := r.clock.Now().UTC()
|
||||
started := storage.OperationEvent{
|
||||
OperationID: op.ID,
|
||||
Event: events.Event{
|
||||
ID: eventID(op.ID, events.OperationStarted, startedAt),
|
||||
Type: events.OperationStarted,
|
||||
Subject: operationEventSubject(op.ID),
|
||||
},
|
||||
}
|
||||
startPayload, err := agentRunEventPayload(op, core.OperationRunning, startedAt, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
started.Event.Payload = startPayload
|
||||
started.Event.CreatedAt = startedAt
|
||||
|
||||
if err := r.operationEventStore.AppendEvent(ctx, started); err != nil {
|
||||
return fmt.Errorf("append agent_run started event: %w", err)
|
||||
}
|
||||
|
||||
agentStarted := storage.OperationEvent{
|
||||
OperationID: op.ID,
|
||||
Event: events.Event{
|
||||
ID: eventID(op.ID, events.AgentRunStarted, startedAt),
|
||||
Type: events.AgentRunStarted,
|
||||
Subject: operationEventSubject(op.ID),
|
||||
},
|
||||
}
|
||||
agentRunStartedPayload, err := agentRunEventPayload(op, core.OperationRunning, startedAt, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
agentStarted.Event.Payload = agentRunStartedPayload
|
||||
agentStarted.Event.CreatedAt = startedAt
|
||||
|
||||
if err := r.operationEventStore.AppendEvent(ctx, agentStarted); err != nil {
|
||||
return fmt.Errorf("append agent_run_started event: %w", err)
|
||||
}
|
||||
|
||||
input, err := r.agentRunInputLoader.LoadAgentRunInput(ctx, op)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load agent_run input: %w", err)
|
||||
}
|
||||
|
||||
result, err := r.agentRunInvoker.InvokeAgentRun(ctx, input)
|
||||
terminalState := core.OperationFailed
|
||||
completionMessage := strings.TrimSpace(result.Message)
|
||||
// If backend invocation fails, fail the operation with a stable, sanitized message.
|
||||
if err != nil {
|
||||
completionMessage = "agent run invocation failed"
|
||||
} else {
|
||||
terminalState, err = toTerminalOperationState(result.State)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unsupported agent_run result state: %w", err)
|
||||
}
|
||||
}
|
||||
if completionMessage == "" {
|
||||
completionMessage = string(terminalState)
|
||||
}
|
||||
|
||||
terminalAt := r.clock.Now().UTC()
|
||||
var updatedOperation core.Operation
|
||||
switch terminalState {
|
||||
case core.OperationSucceeded:
|
||||
updatedOperation, err = r.operationStore.SucceedOperation(ctx, op.ID, terminalAt)
|
||||
case core.OperationFailed:
|
||||
updatedOperation, err = r.operationStore.FailOperation(ctx, op.ID, terminalAt)
|
||||
case core.OperationCancelled:
|
||||
updatedOperation, err = r.operationStore.CancelOperation(ctx, op.ID, terminalAt)
|
||||
default:
|
||||
err = fmt.Errorf("unsupported terminal state %q", terminalState)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark agent_run operation terminal: %w", err)
|
||||
}
|
||||
|
||||
completionEventType := events.OperationCompleted
|
||||
if terminalState == core.OperationFailed {
|
||||
completionEventType = events.OperationFailed
|
||||
}
|
||||
completedAt := storage.OperationEvent{
|
||||
OperationID: op.ID,
|
||||
Event: events.Event{
|
||||
ID: eventID(op.ID, completionEventType, terminalAt),
|
||||
Type: completionEventType,
|
||||
Subject: operationEventSubject(op.ID),
|
||||
},
|
||||
}
|
||||
completedPayload, err := agentRunEventPayload(updatedOperation, terminalState, terminalAt, map[string]any{"message": completionMessage})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
completedAt.Event.Payload = completedPayload
|
||||
completedAt.Event.CreatedAt = terminalAt
|
||||
if err := r.operationEventStore.AppendEvent(ctx, completedAt); err != nil {
|
||||
return fmt.Errorf("append agent_run completion event: %w", err)
|
||||
}
|
||||
|
||||
agentCompleted := storage.OperationEvent{
|
||||
OperationID: op.ID,
|
||||
Event: events.Event{
|
||||
ID: eventID(op.ID, events.AgentRunCompleted, terminalAt),
|
||||
Type: events.AgentRunCompleted,
|
||||
Subject: operationEventSubject(op.ID),
|
||||
},
|
||||
}
|
||||
agentCompletedPayload, err := agentRunEventPayload(updatedOperation, terminalState, terminalAt, map[string]any{"message": completionMessage})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
agentCompleted.Event.Payload = agentCompletedPayload
|
||||
agentCompleted.Event.CreatedAt = terminalAt
|
||||
if err := r.operationEventStore.AppendEvent(ctx, agentCompleted); err != nil {
|
||||
return fmt.Errorf("append agent_run_completed event: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func operationEventSubject(operationID string) string {
|
||||
return "operation:" + operationID
|
||||
}
|
||||
|
||||
func eventID(operationID, eventType string, at time.Time) string {
|
||||
return fmt.Sprintf("agent-run:%s:%s:%d", operationID, eventType, at.UnixNano())
|
||||
}
|
||||
|
||||
func agentRunEventPayload(op core.Operation, state core.OperationState, at time.Time, extra map[string]any) ([]byte, error) {
|
||||
payload := map[string]any{
|
||||
"operation_id": op.ID,
|
||||
"repo_id": op.RepoID,
|
||||
"type": op.Type,
|
||||
"state": state,
|
||||
"at": at.Format(time.RFC3339Nano),
|
||||
}
|
||||
for key, value := range extra {
|
||||
payload[key] = value
|
||||
}
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("serialize agent run event payload: %w", err)
|
||||
}
|
||||
return encoded, nil
|
||||
}
|
||||
|
||||
func toTerminalOperationState(state AgentRunState) (core.OperationState, error) {
|
||||
switch state {
|
||||
case AgentRunStateSucceeded:
|
||||
return core.OperationSucceeded, nil
|
||||
case AgentRunStateFailed:
|
||||
return core.OperationFailed, nil
|
||||
case AgentRunStateCancelled:
|
||||
return core.OperationCancelled, nil
|
||||
default:
|
||||
return "", fmt.Errorf("invalid agent run state %q", state)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package worker_test
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
|
|
@ -10,6 +11,8 @@ import (
|
|||
|
||||
"git.toki-labs.com/toki/gito/services/core/internal/config"
|
||||
"git.toki-labs.com/toki/gito/services/core/internal/core"
|
||||
"git.toki-labs.com/toki/gito/services/core/internal/events"
|
||||
"git.toki-labs.com/toki/gito/services/core/internal/storage"
|
||||
"git.toki-labs.com/toki/gito/services/core/internal/worker"
|
||||
)
|
||||
|
||||
|
|
@ -34,6 +37,125 @@ func (f fakeClock) Now() time.Time {
|
|||
return f.now
|
||||
}
|
||||
|
||||
type fakeOperationStore struct {
|
||||
operation core.Operation
|
||||
startedCalls int
|
||||
succeededCalls int
|
||||
failedCalls int
|
||||
cancelledCalls int
|
||||
}
|
||||
|
||||
func (f *fakeOperationStore) CreateOperation(_ context.Context, op core.Operation) (storage.OperationCreateResult, error) {
|
||||
f.operation = op
|
||||
return storage.OperationCreateResult{Operation: op, Created: true}, nil
|
||||
}
|
||||
func (f *fakeOperationStore) GetOperation(_ context.Context, id string) (core.Operation, error) {
|
||||
if f.operation.ID != id {
|
||||
return core.Operation{}, storage.ErrOperationNotFound
|
||||
}
|
||||
return f.operation, nil
|
||||
}
|
||||
func (f *fakeOperationStore) ListOperations(_ context.Context, _ string) ([]core.Operation, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeOperationStore) StartOperation(_ context.Context, id string, now time.Time) (core.Operation, error) {
|
||||
f.startedCalls++
|
||||
return f.transition(id, core.OperationRunning, now)
|
||||
}
|
||||
func (f *fakeOperationStore) SucceedOperation(_ context.Context, id string, now time.Time) (core.Operation, error) {
|
||||
f.succeededCalls++
|
||||
return f.transition(id, core.OperationSucceeded, now)
|
||||
}
|
||||
func (f *fakeOperationStore) FailOperation(_ context.Context, id string, now time.Time) (core.Operation, error) {
|
||||
f.failedCalls++
|
||||
return f.transition(id, core.OperationFailed, now)
|
||||
}
|
||||
func (f *fakeOperationStore) CancelOperation(_ context.Context, id string, now time.Time) (core.Operation, error) {
|
||||
f.cancelledCalls++
|
||||
return f.transition(id, core.OperationCancelled, now)
|
||||
}
|
||||
func (f *fakeOperationStore) PickQueuedOperation(_ context.Context, _ time.Time) (core.Operation, bool, error) {
|
||||
return core.Operation{}, false, nil
|
||||
}
|
||||
func (f *fakeOperationStore) transition(id string, next core.OperationState, now time.Time) (core.Operation, error) {
|
||||
if f.operation.ID != id {
|
||||
return core.Operation{}, storage.ErrOperationNotFound
|
||||
}
|
||||
if !f.operation.State.CanTransitionTo(next) {
|
||||
return core.Operation{}, storage.ErrInvalidOperation
|
||||
}
|
||||
f.operation.State = next
|
||||
f.operation.UpdatedAt = now
|
||||
return f.operation, nil
|
||||
}
|
||||
|
||||
type fakeOperationEventStore struct {
|
||||
events []storage.OperationEvent
|
||||
}
|
||||
|
||||
func (f *fakeOperationEventStore) AppendEvent(_ context.Context, ev storage.OperationEvent) error {
|
||||
f.events = append(f.events, ev)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeOperationEventStore) ListEvents(_ context.Context, _ string) ([]storage.OperationEvent, error) {
|
||||
return f.events, nil
|
||||
}
|
||||
func (f *fakeOperationEventStore) ListPendingEvents(_ context.Context, _ int) ([]storage.OperationEvent, error) {
|
||||
return f.events, nil
|
||||
}
|
||||
func (f *fakeOperationEventStore) MarkPublished(_ context.Context, _ string, _ time.Time) (storage.OperationEvent, error) {
|
||||
return storage.OperationEvent{}, nil
|
||||
}
|
||||
|
||||
type fakeAgentRunInputLoader struct {
|
||||
input worker.AgentRunInput
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeAgentRunInputLoader) LoadAgentRunInput(_ context.Context, op core.Operation) (worker.AgentRunInput, error) {
|
||||
f.calls++
|
||||
if f.err != nil {
|
||||
return worker.AgentRunInput{}, f.err
|
||||
}
|
||||
if f.input.OperationID == "" {
|
||||
f.input.OperationID = op.ID
|
||||
}
|
||||
if f.input.RepoID == "" {
|
||||
f.input.RepoID = op.RepoID
|
||||
}
|
||||
return f.input, nil
|
||||
}
|
||||
|
||||
type fakeAgentRunInvoker struct {
|
||||
result worker.AgentRunResult
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeAgentRunInvoker) InvokeAgentRun(_ context.Context, _ worker.AgentRunInput) (worker.AgentRunResult, error) {
|
||||
f.calls++
|
||||
return f.result, f.err
|
||||
}
|
||||
|
||||
func decodePayload(t *testing.T, raw []byte) map[string]any {
|
||||
t.Helper()
|
||||
payload := map[string]any{}
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
t.Fatalf("decode payload: %v", err)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func runningAgentRunOperation(id string) core.Operation {
|
||||
return core.Operation{
|
||||
ID: id,
|
||||
RepoID: "repo-1",
|
||||
Type: core.OperationAgentRun,
|
||||
State: core.OperationRunning,
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerRunOnceSkipsWhenDisabled(t *testing.T) {
|
||||
cfg := config.Config{WorkerEnabled: false}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
|
|
@ -82,9 +204,11 @@ func TestRunnerRunOnceReportsNoWork(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestRunnerRunOncePicksOperation(t *testing.T) {
|
||||
func TestRunnerRunOnceNonAgentOperationKeepsExistingBehavior(t *testing.T) {
|
||||
cfg := config.Config{WorkerEnabled: true}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
opStore := &fakeOperationStore{}
|
||||
eventStore := &fakeOperationEventStore{}
|
||||
expectedOp := core.Operation{ID: "op-test-1", Type: core.OperationClone}
|
||||
picker := &fakePicker{
|
||||
pickFn: func(ctx context.Context, now time.Time) (core.Operation, bool, error) {
|
||||
|
|
@ -93,7 +217,12 @@ func TestRunnerRunOncePicksOperation(t *testing.T) {
|
|||
}
|
||||
clock := fakeClock{now: time.Now()}
|
||||
|
||||
runner := worker.NewRunner(cfg, logger, picker, clock)
|
||||
runner := worker.NewRunnerWithDependencies(cfg, logger, picker, clock, worker.RunnerDependencies{
|
||||
OperationStore: opStore,
|
||||
OperationEventStore: eventStore,
|
||||
AgentRunInputLoader: &fakeAgentRunInputLoader{},
|
||||
AgentRunInvoker: &fakeAgentRunInvoker{},
|
||||
})
|
||||
err := runner.RunOnce(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
|
|
@ -101,6 +230,12 @@ func TestRunnerRunOncePicksOperation(t *testing.T) {
|
|||
if picker.calls != 1 {
|
||||
t.Errorf("expected picker to be called once, got %d", picker.calls)
|
||||
}
|
||||
if opStore.startedCalls != 0 || opStore.succeededCalls != 0 || opStore.failedCalls != 0 || opStore.cancelledCalls != 0 {
|
||||
t.Errorf("agent_run state transitions should not run for non-agent operation")
|
||||
}
|
||||
if len(eventStore.events) != 0 {
|
||||
t.Fatalf("expected no events for non-agent operation, got %d", len(eventStore.events))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerRunOnceReturnsPickerError(t *testing.T) {
|
||||
|
|
@ -120,3 +255,152 @@ func TestRunnerRunOnceReturnsPickerError(t *testing.T) {
|
|||
t.Fatalf("expected error %v, got %v", expectedErr, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerRunOnceAgentRunInvokesBackendAndLogsEvents(t *testing.T) {
|
||||
cfg := config.Config{WorkerEnabled: true}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
now := time.Now().UTC().Truncate(time.Microsecond)
|
||||
op := runningAgentRunOperation("agent-run-success")
|
||||
picker := &fakePicker{
|
||||
pickFn: func(ctx context.Context, now time.Time) (core.Operation, bool, error) {
|
||||
return op, true, nil
|
||||
},
|
||||
}
|
||||
clock := fakeClock{now: now}
|
||||
opStore := &fakeOperationStore{operation: op}
|
||||
eventStore := &fakeOperationEventStore{}
|
||||
loader := &fakeAgentRunInputLoader{
|
||||
input: worker.AgentRunInput{
|
||||
Branch: "main",
|
||||
WorkspacePath: "/tmp/workspace",
|
||||
},
|
||||
}
|
||||
invoker := &fakeAgentRunInvoker{
|
||||
result: worker.AgentRunResult{
|
||||
State: worker.AgentRunStateSucceeded,
|
||||
Message: "done",
|
||||
},
|
||||
}
|
||||
runner := worker.NewRunnerWithDependencies(cfg, logger, picker, clock, worker.RunnerDependencies{
|
||||
OperationStore: opStore,
|
||||
OperationEventStore: eventStore,
|
||||
AgentRunInputLoader: loader,
|
||||
AgentRunInvoker: invoker,
|
||||
})
|
||||
|
||||
err := runner.RunOnce(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if loader.calls != 1 {
|
||||
t.Fatalf("expected loader call count 1, got %d", loader.calls)
|
||||
}
|
||||
if invoker.calls != 1 {
|
||||
t.Fatalf("expected invoker call count 1, got %d", invoker.calls)
|
||||
}
|
||||
if opStore.startedCalls != 0 || opStore.succeededCalls != 1 {
|
||||
t.Fatalf("expected no start and one succeed call, got start=%d succeed=%d", opStore.startedCalls, opStore.succeededCalls)
|
||||
}
|
||||
if opStore.failedCalls != 0 || opStore.cancelledCalls != 0 {
|
||||
t.Fatalf("did not expect failure/cancel calls, got fail=%d cancel=%d", opStore.failedCalls, opStore.cancelledCalls)
|
||||
}
|
||||
if opStore.operation.State != core.OperationSucceeded {
|
||||
t.Fatalf("operation state mismatch: got %q want %q", opStore.operation.State, core.OperationSucceeded)
|
||||
}
|
||||
if len(eventStore.events) != 4 {
|
||||
t.Fatalf("expected 4 events, got %d", len(eventStore.events))
|
||||
}
|
||||
|
||||
wantEventTypes := []string{
|
||||
events.OperationStarted,
|
||||
events.AgentRunStarted,
|
||||
events.OperationCompleted,
|
||||
events.AgentRunCompleted,
|
||||
}
|
||||
for idx, ev := range eventStore.events {
|
||||
if ev.Event.Type != wantEventTypes[idx] {
|
||||
t.Fatalf("event[%d]: got %q want %q", idx, ev.Event.Type, wantEventTypes[idx])
|
||||
}
|
||||
payload := decodePayload(t, ev.Event.Payload)
|
||||
if got := payload["operation_id"]; got != "agent-run-success" {
|
||||
t.Fatalf("event[%d]: operation_id mismatch: got %v want %v", idx, got, "agent-run-success")
|
||||
}
|
||||
}
|
||||
|
||||
startState := decodePayload(t, eventStore.events[0].Event.Payload)["state"]
|
||||
if startState != string(core.OperationRunning) {
|
||||
t.Fatalf("event started state mismatch: got %v want %q", startState, core.OperationRunning)
|
||||
}
|
||||
endState := decodePayload(t, eventStore.events[2].Event.Payload)["state"]
|
||||
if endState != string(core.OperationSucceeded) {
|
||||
t.Fatalf("event completed state mismatch: got %v want %q", endState, core.OperationSucceeded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerRunOnceAgentRunFailsWhenBackendFails(t *testing.T) {
|
||||
cfg := config.Config{WorkerEnabled: true}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
now := time.Now().UTC().Truncate(time.Microsecond)
|
||||
op := runningAgentRunOperation("agent-run-fail")
|
||||
picker := &fakePicker{
|
||||
pickFn: func(ctx context.Context, now time.Time) (core.Operation, bool, error) {
|
||||
return op, true, nil
|
||||
},
|
||||
}
|
||||
clock := fakeClock{now: now}
|
||||
opStore := &fakeOperationStore{operation: op}
|
||||
eventStore := &fakeOperationEventStore{}
|
||||
loader := &fakeAgentRunInputLoader{
|
||||
input: worker.AgentRunInput{
|
||||
Instruction: "run-failing-task",
|
||||
},
|
||||
}
|
||||
invoker := &fakeAgentRunInvoker{
|
||||
result: worker.AgentRunResult{
|
||||
State: worker.AgentRunStateFailed,
|
||||
Message: "should not be forwarded",
|
||||
},
|
||||
err: errors.New("backend transport issue"),
|
||||
}
|
||||
|
||||
runner := worker.NewRunnerWithDependencies(cfg, logger, picker, clock, worker.RunnerDependencies{
|
||||
OperationStore: opStore,
|
||||
OperationEventStore: eventStore,
|
||||
AgentRunInputLoader: loader,
|
||||
AgentRunInvoker: invoker,
|
||||
})
|
||||
err := runner.RunOnce(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if invoker.calls != 1 || loader.calls != 1 {
|
||||
t.Fatalf("expected one input load and one invoke call; got load=%d invoke=%d", loader.calls, invoker.calls)
|
||||
}
|
||||
if opStore.startedCalls != 0 || opStore.failedCalls != 1 {
|
||||
t.Fatalf("expected no start and one fail call, got start=%d fail=%d", opStore.startedCalls, opStore.failedCalls)
|
||||
}
|
||||
if opStore.operation.State != core.OperationFailed {
|
||||
t.Fatalf("operation state mismatch: got %q want %q", opStore.operation.State, core.OperationFailed)
|
||||
}
|
||||
if len(eventStore.events) != 4 {
|
||||
t.Fatalf("expected 4 events, got %d", len(eventStore.events))
|
||||
}
|
||||
wantEventTypes := []string{
|
||||
events.OperationStarted,
|
||||
events.AgentRunStarted,
|
||||
events.OperationFailed,
|
||||
events.AgentRunCompleted,
|
||||
}
|
||||
for idx, ev := range eventStore.events {
|
||||
if ev.Event.Type != wantEventTypes[idx] {
|
||||
t.Fatalf("event[%d]: got %q want %q", idx, ev.Event.Type, wantEventTypes[idx])
|
||||
}
|
||||
}
|
||||
completedPayload := decodePayload(t, eventStore.events[2].Event.Payload)
|
||||
if completedPayload["state"] != string(core.OperationFailed) {
|
||||
t.Fatalf("completion state mismatch: got %v want %q", completedPayload["state"], core.OperationFailed)
|
||||
}
|
||||
if completedPayload["message"] != "agent run invocation failed" {
|
||||
t.Fatalf("expected sanitized failure message, got %v", completedPayload["message"])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue