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:
toki 2026-06-16 10:53:06 +09:00
parent 42176cac8e
commit 7adbe11048
11 changed files with 1547 additions and 15 deletions

View file

@ -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 전이를 이미 검증한다.

View file

@ -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 이슈를 좁게 반영한다.

View file

@ -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로 이동한다.

View file

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

View file

@ -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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

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

View file

@ -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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

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

View file

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

View file

@ -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"])
}
}