diff --git a/agent-roadmap/phase/agent-shell-iop-execution/milestones/iop-agent-run-bridge.md b/agent-roadmap/phase/agent-shell-iop-execution/milestones/iop-agent-run-bridge.md index 8fc17e3..8e641f6 100644 --- a/agent-roadmap/phase/agent-shell-iop-execution/milestones/iop-agent-run-bridge.md +++ b/agent-roadmap/phase/agent-shell-iop-execution/milestones/iop-agent-run-bridge.md @@ -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 전이를 이미 검증한다. diff --git a/agent-task/archive/2026/06/m-iop-agent-run-bridge/01_iop_invocation_contract/code_review_local_G05_0.log b/agent-task/archive/2026/06/m-iop-agent-run-bridge/01_iop_invocation_contract/code_review_local_G05_0.log new file mode 100644 index 0000000..2f98974 --- /dev/null +++ b/agent-task/archive/2026/06/m-iop-agent-run-bridge/01_iop_invocation_contract/code_review_local_G05_0.log @@ -0,0 +1,147 @@ + + +# 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-`이면 완료 이벤트 메타데이터를 보고한다. 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-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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 이슈를 좁게 반영한다. diff --git a/agent-task/archive/2026/06/m-iop-agent-run-bridge/01_iop_invocation_contract/code_review_local_G06_1.log b/agent-task/archive/2026/06/m-iop-agent-run-bridge/01_iop_invocation_contract/code_review_local_G06_1.log new file mode 100644 index 0000000..c70b72b --- /dev/null +++ b/agent-task/archive/2026/06/m-iop-agent-run-bridge/01_iop_invocation_contract/code_review_local_G06_1.log @@ -0,0 +1,154 @@ + + +# 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-`이면 완료 이벤트 메타데이터를 보고한다. 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-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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로 이동한다. diff --git a/agent-task/archive/2026/06/m-iop-agent-run-bridge/01_iop_invocation_contract/complete.log b/agent-task/archive/2026/06/m-iop-agent-run-bridge/01_iop_invocation_contract/complete.log new file mode 100644 index 0000000..d6b43d4 --- /dev/null +++ b/agent-task/archive/2026/06/m-iop-agent-run-bridge/01_iop_invocation_contract/complete.log @@ -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 + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/archive/2026/06/m-iop-agent-run-bridge/01_iop_invocation_contract/plan_local_G05_0.log b/agent-task/archive/2026/06/m-iop-agent-run-bridge/01_iop_invocation_contract/plan_local_G05_0.log new file mode 100644 index 0000000..554a126 --- /dev/null +++ b/agent-task/archive/2026/06/m-iop-agent-run-bridge/01_iop_invocation_contract/plan_local_G05_0.log @@ -0,0 +1,209 @@ + + +# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/archive/2026/06/m-iop-agent-run-bridge/01_iop_invocation_contract/plan_local_G06_1.log b/agent-task/archive/2026/06/m-iop-agent-run-bridge/01_iop_invocation_contract/plan_local_G06_1.log new file mode 100644 index 0000000..08e9589 --- /dev/null +++ b/agent-task/archive/2026/06/m-iop-agent-run-bridge/01_iop_invocation_contract/plan_local_G06_1.log @@ -0,0 +1,109 @@ + + +# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/m-iop-agent-run-bridge/02+01_git_result_convergence/CODE_REVIEW-local-G06.md b/agent-task/m-iop-agent-run-bridge/02+01_git_result_convergence/CODE_REVIEW-local-G06.md new file mode 100644 index 0000000..d9f288a --- /dev/null +++ b/agent-task/m-iop-agent-run-bridge/02+01_git_result_convergence/CODE_REVIEW-local-G06.md @@ -0,0 +1,126 @@ + + +# 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-`이면 완료 이벤트 메타데이터를 보고한다. 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-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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. diff --git a/agent-task/m-iop-agent-run-bridge/02+01_git_result_convergence/PLAN-local-G06.md b/agent-task/m-iop-agent-run-bridge/02+01_git_result_convergence/PLAN-local-G06.md new file mode 100644 index 0000000..0fd7fec --- /dev/null +++ b/agent-task/m-iop-agent-run-bridge/02+01_git_result_convergence/PLAN-local-G06.md @@ -0,0 +1,199 @@ + + +# 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 + +| `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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/packages/contracts/notes/gito-control-plane.md b/packages/contracts/notes/gito-control-plane.md index a56ee28..ecbe6df 100644 --- a/packages/contracts/notes/gito-control-plane.md +++ b/packages/contracts/notes/gito-control-plane.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 diff --git a/services/core/internal/worker/runner.go b/services/core/internal/worker/runner.go index 1fcf336..c50d29b 100644 --- a/services/core/internal/worker/runner.go +++ b/services/core/internal/worker/runner.go @@ -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) + } +} diff --git a/services/core/internal/worker/runner_test.go b/services/core/internal/worker/runner_test.go index 77e9b63..ad03768 100644 --- a/services/core/internal/worker/runner_test.go +++ b/services/core/internal/worker/runner_test.go @@ -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"]) + } +}