diff --git a/agent-roadmap/phase/agent-shell-iop-execution/PHASE.md b/agent-roadmap/phase/agent-shell-iop-execution/PHASE.md index 37e1eeb..8010e01 100644 --- a/agent-roadmap/phase/agent-shell-iop-execution/PHASE.md +++ b/agent-roadmap/phase/agent-shell-iop-execution/PHASE.md @@ -19,9 +19,9 @@ IOP 같은 sibling/external runtime 연결은 HTTP dispatch/callback 모델로 - 경로: `agent-roadmap/archive/phase/agent-shell-iop-execution/milestones/agent-shell-package-iop-backend-boundary.md` - 요약: `../agent-shell`은 Flutter agent UI/package로 사용하고, backend/runtime owner는 `../iop` 프로젝트로 지정한다. -- [검토중] IOP Agent Run Bridge +- [진행중] IOP Agent Run Bridge - 경로: `agent-roadmap/phase/agent-shell-iop-execution/milestones/iop-agent-run-bridge.md` - - 요약: IOP backend가 수행한 agent run 결과를 Gito의 Git operation과 event 원장으로 수렴시키는 계약을 만든다. + - 요약: IOP backend가 수행한 agent run 결과를 Gito의 Git operation과 event 원장으로 수렴시키는 production bridge를 마무리한다. - [계획] Command Policy and Cancellation - 경로: `agent-roadmap/phase/agent-shell-iop-execution/milestones/command-policy-and-cancellation.md` 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 ea7fbac..70e6c15 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 @@ ## 상태 -[검토중] +[진행중] ## 승격 조건 @@ -20,6 +20,7 @@ ## 구현 잠금 - 상태: 해제 +- SDD: 확인 필요 - production worker/runtime 연결 보완에서 durable agent_run input source 또는 외부 IOP adapter 계약이 기존 contract note 범위를 넘으면 SDD gate를 먼저 재확인한다. - 결정 필요: 없음 - 해소 근거: - IOP CLI 실행 명령, profile/model 선택, 입력 파일 또는 stdin 계약은 Gito가 직접 확정하지 않고 IOP backend/runtime 소유로 둔다. @@ -43,6 +44,7 @@ IOP backend를 workspace operation 실행 통로로 연결한다. - [x] [input] repo, branch, workspace path, user instruction, policy context를 IOP 입력으로 전달하는 형식을 정한다. - [x] [invoke] Gito가 직접 IOP CLI를 실행하지 않고 IOP backend invocation/result 계약을 호출하거나 수신하는 형식을 정한다. 검증: fake IOP backend result가 operation log를 남긴다. +- [ ] [production-worker] production worker가 durable agent_run input source에서 실행 입력을 로드하고, agent_run만 안전하게 pick해 IOP adapter/result path를 실제 runtime 의존성으로 연결한다. 검증: `cd services/core && go test -count=1 ./internal/worker ./internal/storage` 및 `cd services/core && go test ./...` - [x] [state] `queued`, `running`, `succeeded`, `failed`, `cancelled` run state를 operation lifecycle에 매핑한다. - [x] [result] run 이후 Git status와 revision diff로 성공 여부를 확인한다. @@ -53,16 +55,19 @@ agent run 결과를 Git revision과 operation event로 수렴한다. - [x] [dirty-guard] run 종료 후 dirty workspace 정책을 적용한다. - [x] [commit-push] policy가 허용한 경우 commit/push operation으로 연결한다. - [x] [revision-event] push 이후 revision scan event를 발행한다. +- [ ] [revision-scan-seed] cursor 없는 agent_run push도 known before/after revision으로 `branch.updated` 이벤트를 emit/retry한다. 검증: `cd services/core && go test -count=1 ./internal/controlplane ./internal/worker` ## 완료 리뷰 -- 상태: 요청됨 +- 상태: 보완 필요 - 요청일: 2026-06-16 +- 보완 판정일: 2026-06-17 - 완료 근거: `revision-event`까지 PASS되어 모든 기능 Task가 충족됐다. 최종 근거는 `agent-task/archive/2026/06/m-iop-agent-run-bridge/04+03_revision_scan_event/complete.log`의 Roadmap Completion이며, 검증은 `cd services/core && go test ./...`, 계약 keyword scan, `git diff --check` PASS다. -- 리뷰 필요: - - [ ] 사용자가 완료 결과를 확인했다 - - [ ] archive 이동을 승인했다 -- 리뷰 코멘트: 사용자 승인 전까지 `[검토중]`으로 유지한다. +- 코드 레벨 보완 필요: + - production worker entrypoint가 picker/input loader/invoker를 nil로 구성해 실제 queued operation을 처리하지 못한다. + - generic queued picker를 그대로 연결하면 비-agent operation이 running으로 전이된 뒤 runner가 no-op 처리할 수 있다. + - agent_run revision scan이 known before/after revision을 전달하지 않아 cursor 없는 첫 push의 `branch.updated` 이벤트를 놓칠 수 있다. +- 리뷰 코멘트: 종료 전 `production-worker`, `revision-scan-seed` 후속 task를 완료해야 한다. ## 범위 제외 @@ -80,6 +85,6 @@ agent run 결과를 Git revision과 operation event로 수렴한다. - 표준선(선택): IOP run 결과는 Git 상태와 operation event로 검증한다. Gito는 IOP CLI/secret 주입 세부를 직접 소유하지 않고 HTTP dispatch/callback과 `credential_ref`/`secret_ref` 경계만 다룬다. - 선행 작업: Agent Shell Package and IOP Backend Boundary - 후속 작업: Command Policy and Cancellation -- 확인 필요: 사용자 완료 리뷰와 archive 승인 +- 확인 필요: production worker/runtime 연결 보완 후 완료 리뷰와 archive 승인 - 방향성 정리: 외부 연결 규약에서 proto-socket을 제거하고 GitHub webhook과 유사한 HTTP webhook/callback 모델로 정렬했다. - 완료 근거: `[input]`과 `[state]`는 `packages/contracts/notes/gito-control-plane.md`의 `IOP Agent Run Bridge` 섹션에서 transport-independent `AgentRunInput`과 run state mapping으로 정리했다. `[invoke]`는 `agent-task/archive/2026/06/m-iop-agent-run-bridge/01_iop_invocation_contract/complete.log`의 PASS Roadmap Completion으로 확인했다. `[result]`는 `agent-task/archive/2026/06/m-iop-agent-run-bridge/02+01_git_result_convergence/complete.log`의 PASS Roadmap Completion으로 확인했다. `[dirty-guard]`는 `services/core/internal/worker/runner.go`의 dirty workspace failure policy와 `TestRunnerRunOnceAgentRunFailsWhenGitEvidenceIsDirty` 통과로 확인했다. `[commit-push]`는 `agent-task/archive/2026/06/m-iop-agent-run-bridge/03_commit_push_policy/complete.log`의 PASS Roadmap Completion으로 확인했다. `[revision-event]`는 `agent-task/archive/2026/06/m-iop-agent-run-bridge/04+03_revision_scan_event/complete.log`의 PASS Roadmap Completion과 `TestRuntimeScanAgentRunRevisionPublishFailureDoesNotAdvanceDurableState`, `TestScanOperationEventAllowsNullOperationID`, `cd services/core && go test ./...` 통과로 확인했다. diff --git a/agent-task/archive/2026/06/m-iop-agent-run-bridge/05_production_worker_bridge/code_review_cloud_G07_0.log b/agent-task/archive/2026/06/m-iop-agent-run-bridge/05_production_worker_bridge/code_review_cloud_G07_0.log new file mode 100644 index 0000000..17237d2 --- /dev/null +++ b/agent-task/archive/2026/06/m-iop-agent-run-bridge/05_production_worker_bridge/code_review_cloud_G07_0.log @@ -0,0 +1,308 @@ + + +# 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-17 +task=m-iop-agent-run-bridge/05_production_worker_bridge, plan=0, tag=REVIEW_API + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/agent-shell-iop-execution/milestones/iop-agent-run-bridge.md` +- Task ids: + - `production-worker`: production worker가 durable agent_run input source에서 실행 입력을 로드하고, agent_run만 안전하게 pick해 IOP adapter/result path를 실제 runtime 의존성으로 연결한다. + - `revision-scan-seed`: cursor 없는 agent_run push도 known before/after revision으로 `branch.updated` 이벤트를 emit/retry한다. +- Completion mode: check-on-pass + +## Archive Evidence Snapshot + +- Prior milestone evidence: + - `agent-task/archive/2026/06/m-iop-agent-run-bridge/01_iop_invocation_contract/complete.log`: PASS; transport-independent input/invocation contract and fake backend evidence. + - `agent-task/archive/2026/06/m-iop-agent-run-bridge/02+01_git_result_convergence/complete.log`: PASS; Git evidence/result convergence. + - `agent-task/archive/2026/06/m-iop-agent-run-bridge/03_commit_push_policy/complete.log`: PASS; dirty guard and commit/push policy. + - `agent-task/archive/2026/06/m-iop-agent-run-bridge/04+03_revision_scan_event/complete.log`: PASS; revision scan event tests, `cd services/core && go test ./...`, contract keyword scan, `git diff --check`. +- Current closure review finding: milestone cannot close until production worker wiring, agent_run-only picking, durable input loading, and cursor-free agent_run revision scan behavior are implemented and verified. +- Additional archive reread is not required by default. If exact prior evidence is needed, read only the four `complete.log` paths listed above. + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. +리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. + +1. 판정을 append한다. +2. `CODE_REVIEW-cloud-G07.md` -> `code_review_cloud_G07_N.log`, `PLAN-cloud-G07.md` -> `plan_cloud_G07_M.log`로 아카이브한다. +3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-iop-agent-run-bridge/05_production_worker_bridge/`로 이동한다. 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] Durable agent_run input and production-safe picker | [x] | +| [REVIEW_API-2] IOP HTTP adapter/result path | [x] | +| [REVIEW_API-3] Revision scan seed from agent_run evidence | [x] | + +## 구현 체크리스트 + +- [x] `production-worker`를 위해 durable agent_run input source와 loader를 추가하고, production worker가 agent_run만 안전하게 pick해 configured IOP adapter/result path를 실행하도록 연결한다. 검증: `cd services/core && go test -count=1 ./internal/worker ./internal/storage` 및 `cd services/core && go test ./...` +- [x] `revision-scan-seed`를 위해 agent_run known before/after revision을 revision scan에 전달하고 cursor 없는 watched branch도 `branch.updated` 이벤트를 emit/retry하도록 한다. 검증: `cd services/core && go test -count=1 ./internal/controlplane ./internal/worker` +- [x] contracts/workspace keyword scan과 diff hygiene를 통과시킨다. +- [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_cloud_G07_N.log`로 아카이브한다. +- [x] active `PLAN-*-G??.md`를 `plan_cloud_G07_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/05_production_worker_bridge/`를 `agent-task/archive/YYYY/MM/m-iop-agent-run-bridge/05_production_worker_bridge/`로 이동하고 최종 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-cloud-G07.md`와 `CODE_REVIEW-cloud-G07.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로 이동한다. + +## 계획 대비 변경 사항 + +- **`PickQueuedAgentRunOperation`을 `OperationStore` 인터페이스에 추가**: 계획에서는 typed picker를 별도 store method 또는 adapter로 추가하도록 했다. 구현에서는 `OperationStore` 인터페이스에 `PickQueuedAgentRunOperation`을 직접 추가하고, `worker.AgentRunPicker` interface(`PickQueuedAgentRunOperation` 하나만 노출)를 별도로 정의해 `RunnerDependencies`에 연결했다. `OperationStore`가 `AgentRunPicker`를 자동 충족하므로 `pgStore.Operations()`를 직접 전달 가능하다. + +- **`scanExplicitRevisionPair`를 별도 메서드로 분리**: 계획에서는 `ScanAgentRunRevision`이 explicit pair path를 인라인으로 처리하거나 `ScanBranchRevision`을 수정하도록 안내했다. `ScanBranchRevision` 시그니처 변경을 피하고 cursor 상태에 의존하지 않는 독립적 path임을 명확히 하기 위해 `scanExplicitRevisionPair` private 메서드로 분리했다. + +- **`IOP_ENDPOINT` 미설정 시 worker 계속 실행**: 계획에서는 agent_run pick 없이 명확한 startup log 또는 disabled path로 두도록 했다. 구현에서는 `IOPEndpoint`가 비어 있으면 로그 후 nil picker runner를 실행(`runner.Run()`이 "worker picker not configured" 경고를 내고 종료)하는 방식을 선택해 기존 동작과 일관성을 유지했다. + +## 주요 설계 결정 + +- **`DurableAgentRunInput`에 raw secret 미포함**: `CredentialRefs`는 durable 모델과 `agent_run_inputs` 테이블에서 제외했다. config-based ref boundary는 IOP invoker가 직접 처리하거나 future credential Milestone에서 추가한다. + +- **`AgentRunPicker` 인터페이스 분리**: `OperationPicker`를 수정하지 않고 새 `AgentRunPicker`를 추가해 기존 `OperationPicker` 기반 테스트와 nil picker path를 그대로 유지했다. `agentRunPicker`가 set되면 generic picker를 우선하지 않아 non-agent operation이 running 상태로 갇히는 문제를 방지한다. + +- **HTTP invoker error sanitization**: transport error, non-2xx status, invalid response, unsupported state 모두 nil error로 반환하고 `AgentRunStateFailed` result로 수렴시켰다. raw upstream body나 dial error 문자열이 operation event에 노출되지 않는다. `InvokeAgentRun`이 error를 반환하는 경우는 JSON marshal 실패와 request 생성 실패뿐이며, runner가 이를 "agent run invocation failed" 메시지로 처리한다. + +- **explicit pair cursor 관리**: `handleRevisionWithStore`가 broadcast 성공 후 cursor를 이미 upsert하므로 `scanExplicitRevisionPair`에서의 cursor upsert는 in-memory store path(store nil 또는 RevisionCursors nil)를 위한 guard이다. 두 번 upsert되는 경우는 idempotent하므로 무해하다. broadcast 실패 시 `HandleRevision`이 오류를 반환하므로 `scanExplicitRevisionPair`의 cursor upsert는 도달하지 않아 cursor advance가 방지된다. + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- production worker binary가 nil picker/loader/invoker 상태로 queued `agent_run`을 놓치지 않는지 확인한다. +- typed picker가 `agent_run`만 running으로 전이하고 기존 generic picker semantics를 깨지 않는지 확인한다. +- durable input source가 raw secret을 저장하지 않고 workspace path/instruction/policy context boundary를 검증하는지 확인한다. +- HTTP adapter 오류가 operation event에 raw upstream body나 secret을 노출하지 않는지 확인한다. +- agent_run explicit before/after revision path가 cursor 없는 watched branch에서 이벤트를 emit하고 publish failure 때 cursor를 advance하지 않는지 확인한다. + +## 검증 결과 + +_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ + +필수 규칙: +- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다. +- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. +- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. +- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다. +- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다. + +### REVIEW_API-1 중간 검증 + +```bash +$ cd services/core && go test -count=1 ./internal/worker ./internal/storage +ok git.toki-labs.com/toki/gito/services/core/internal/worker 0.007s +ok git.toki-labs.com/toki/gito/services/core/internal/storage 0.004s +``` + +### REVIEW_API-2 중간 검증 + +```bash +$ cd services/core && go test -count=1 ./internal/worker +ok git.toki-labs.com/toki/gito/services/core/internal/worker 0.008s +``` + +### REVIEW_API-3 중간 검증 + +```bash +$ cd services/core && go test -count=1 ./internal/controlplane ./internal/worker +ok git.toki-labs.com/toki/gito/services/core/internal/controlplane 0.556s +ok git.toki-labs.com/toki/gito/services/core/internal/worker 0.008s +``` + +### 최종 검증 + +```bash +$ cd services/core && go test -count=1 ./internal/worker ./internal/controlplane ./internal/storage +ok git.toki-labs.com/toki/gito/services/core/internal/worker 0.010s +ok git.toki-labs.com/toki/gito/services/core/internal/controlplane 0.343s +ok git.toki-labs.com/toki/gito/services/core/internal/storage 0.005s +``` + +```bash +$ 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 0.002s +ok git.toki-labs.com/toki/gito/services/core/internal/controlplane 0.502s +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 (cached) +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 0.005s +ok git.toki-labs.com/toki/gito/services/core/internal/worker 0.009s +``` + +```bash +$ rg --sort path -n "proto-socket|REST|gRPC|ChangeRequest|Operation|Event|agent_shell|agent-shell|iop" packages/contracts README.md docs +packages/contracts/README.md:5:Use it for HTTP webhook/REST compatibility maps, event schemas, +packages/contracts/README.md:6:provider-neutral DTOs, internal proto-socket channel/action notes, and +packages/contracts/notes/gito-control-plane.md:5:- External provider and consumer integrations use REST APIs and HTTP +packages/contracts/notes/gito-control-plane.md:9:- proto-socket is internal-only control/runtime transport. It is not an external +packages/contracts/notes/gito-control-plane.md:11:- REST remains for health/readiness, provider callbacks, external webhook +packages/contracts/notes/gito-control-plane.md:13:- gRPC is excluded from the first design. +packages/contracts/notes/gito-control-plane.md:15:## Internal proto-socket Channels +packages/contracts/notes/gito-control-plane.md:27:| `agent_shell` | Reserved/legacy shell scaffold metadata; canonical agent UI is `../agent-shell` and backend/runtime is `../iop`. | +packages/contracts/notes/gito-control-plane.md:30:## Internal proto-socket Action Registry Scaffold +packages/contracts/notes/gito-control-plane.md:32:The first internal proto-socket surface exposes channel/action registry metadata +packages/contracts/notes/gito-control-plane.md:43:| `agent_shell` | `heartbeat`, `dispatch`, `stream_logs` remain placeholder-only until an explicit compatibility Milestone scopes them. | +packages/contracts/notes/gito-control-plane.md:46:## REST Bootstrap Exceptions +packages/contracts/notes/gito-control-plane.md:48:REST is the canonical external surface for provider callbacks, public APIs, and +packages/contracts/notes/gito-control-plane.md:56:`../agent-shell` is the product-agnostic Flutter package for agent interaction +packages/contracts/notes/gito-control-plane.md:58:should depend on it as `agent_shell: path: ../agent-shell` and route +packages/contracts/notes/gito-control-plane.md:59:agent runtime/backend behavior through the sibling `../iop` project. +packages/contracts/notes/gito-control-plane.md:70:injection mechanism. proto-socket is not part of the IOP contract. +packages/contracts/notes/gito-control-plane.md:74:Gito creates and owns an `agent_run` Operation, then hands execution intent to +packages/contracts/notes/gito-control-plane.md:115:| `queued` | Operation exists as `queued`; IOP execution has not been accepted yet. | +packages/contracts/notes/gito-control-plane.md:116:| `running` | Operation moves to `running`; Gito may append `operation.started` and `agent.run.started` records. | +packages/contracts/notes/gito-control-plane.md:118:| `failed` | Operation moves to `failed`; Gito records sanitized failure context without raw logs or secrets. | +packages/contracts/notes/gito-control-plane.md:119:| `cancelled` | Operation moves to terminal `cancelled`; cancellation policy/event details belong to the command policy and cancellation Milestone. | +packages/contracts/notes/gito-control-plane.md:131:## MVP: Forgejo Branch Event Flow +packages/contracts/notes/gito-control-plane.md:139:surfaces. This note keeps only the internal proto-socket channel registry and DTO +packages/contracts/notes/gito-control-plane.md:166:### Operation +packages/contracts/notes/gito-control-plane.md:170:| `id` | Operation id. | +packages/contracts/notes/gito-control-plane.md:177:### RevisionEvent +packages/contracts/notes/gito-control-plane.md:188:### ChangeRequest +packages/contracts/notes/gito-control-plane.md:202:## Normalized Events +packages/contracts/notes/gito-control-plane.md:214:| Event | Meaning | +``` + +```bash +$ rg --sort path -n "Gito|agent-shell|agent_shell|iop|gito-shell|control plane" README.md docs packages/contracts agent-roadmap -g '!agent-roadmap/archive/**' +README.md:1:# Gito +README.md:3:Gito는 agent-driven development를 위한 platformless Git control plane이다. +README.md:6:제어 흐름으로 묶는다. Agent interaction UI는 sibling `../agent-shell` +README.md:8:`../iop` 프로젝트가 맡는 방향으로 둔다. +README.md:13:하고, Gito 전용 binary transport를 요구하지 않는다. +README.md:36:- `agent_shell: path: ../agent-shell` package를 쓰는 Flutter host +README.md:38:- `../iop` backend/runtime 결과를 Gito operation/event 계약으로 수렴시키는 +README.md:86:| `services/core/cmd/server/` | control plane server entrypoint | +README.md:104:Gito는 처음에는 modular monolith로 시작하되, 실행 역할은 분리 가능하게 둔다. +README.md:125:- Flutter host는 `agent_shell: path: ../agent-shell` dependency로 agent +README.md:127:- `../agent-shell`은 Flutter agent UI/package이며 Gito backend가 아니다. +README.md:128:- `../iop`는 agent backend/runtime을 맡고, Gito는 그 결과를 Git operation과 +docs/architecture.md:3:Gito starts as a modular monolith with separable process roles. The codebase can +docs/architecture.md:6:`../agent-shell` Flutter package, and the agent backend/runtime is owned by the +docs/architecture.md:7:sibling `../iop` project. +docs/architecture.md:37:- `../agent-shell` is a product-agnostic Flutter UI/domain package. +docs/architecture.md:38:- Flutter hosts attach it as an `agent_shell: path: ../agent-shell` package +docs/architecture.md:39: dependency rather than treating Gito as the agent UI package. +docs/architecture.md:40:- `../iop` owns the backend/runtime that executes agents, shell commands, and +docs/architecture.md:42:- Gito does not execute arbitrary workspace commands directly. +docs/architecture.md:43:- Gito records IOP backend results as operation state, Git revision, and event +docs/architecture.md:62: callbacks, then Gito records normalized branch events. External consumers +packages/contracts/README.md:1:# Gito Contracts +packages/contracts/README.md:3:This package records transport-independent contract candidates for Gito. +packages/contracts/notes/gito-control-plane.md:1:# Gito Control Plane Contract Candidates +packages/contracts/notes/gito-control-plane.md:6: webhook/callback delivery. Gito should feel compatible with GitHub/GitLab/Gitea +packages/contracts/notes/gito-control-plane.md:7: webhook integration patterns rather than requiring a Gito-specific binary +packages/contracts/notes/gito-control-plane.md:17:These channels describe Gito-internal control/runtime surfaces. They must not be +packages/contracts/notes/gito-control-plane.md:27:| `agent_shell` | Reserved/legacy shell scaffold metadata; canonical agent UI is `../agent-shell` and backend/runtime is `../iop`. | +packages/contracts/notes/gito-control-plane.md:43:| `agent_shell` | `heartbeat`, `dispatch`, `stream_logs` remain placeholder-only until an explicit compatibility Milestone scopes them. | +packages/contracts/notes/gito-control-plane.md:56:`../agent-shell` is the product-agnostic Flutter package for agent interaction +packages/contracts/notes/gito-control-plane.md:58:should depend on it as `agent_shell: path: ../agent-shell` and route +packages/contracts/notes/gito-control-plane.md:59:agent runtime/backend behavior through the sibling `../iop` project. +packages/contracts/notes/gito-control-plane.md:70:injection mechanism. proto-socket is not part of the IOP contract. +packages/contracts/notes/gito-control-plane.md:74:Gito creates and owns an `agent_run` Operation, then hands execution intent to +agent-roadmap/phase/agent-shell-iop-execution/PHASE.md:1:# Phase: Agent Shell IOP Execution [검토중] +agent-roadmap/phase/agent-shell-iop-execution/PHASE.md:29:- **IOP Agent Run Bridge** [검토중] — `agent-roadmap/phase/agent-shell-iop-execution/milestones/iop-agent-run-bridge.md` +agent-roadmap/phase/agent-shell-iop-execution/milestones/iop-agent-run-bridge.md:1:# Milestone: IOP Agent Run Bridge [검토중] +``` + +```bash +$ git diff --check +(no output — clean) +``` + +--- + +> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?** +> If anything is blank, go back and fill it in before saving this file. +> Leave review-agent-only sections unchanged. + +Sections and their ownership: + +| Section | Owner | Note | +|---------|-------|------| +| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these (archive, complete.log, and task-directory archive move are review-agent only) | +| Roadmap Targets | Fixed at stub creation from plan when present | Implementing agent must not modify; code-review copies it into `complete.log` as `Roadmap Completion` only on PASS | +| Spec Targets | Fixed at stub creation from plan when present | Implementing agent must not modify; code-review copies it into `complete.log` as `Spec Completion` only on PASS | +| Archive Evidence Snapshot | Fixed at stub creation from plan when present | Implementing agent uses it as default prior-loop context; read only the specific archive files cited there when more detail is required | +| 구현 항목별 완료 여부 (item names) | Fixed at stub creation | Implementing agent checks `[ ]` -> `[x]` only | +| 구현 체크리스트 (item text/order) | Fixed at stub creation from plan | Implementing agent checks `[ ]` -> `[x]` only; final checkbox is mandatory before saving | +| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section | +| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content | +| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless user input is required to proceed; do not ask the user directly during implementation; when filled, include exact decision, evidence, commands/output, why automatic follow-up cannot resolve it, and resume condition | +| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Pre-filled from plan | +| 검증 결과 | Implementing agent | Paste actual stdout/stderr for every command | +| 코드리뷰 결과 | Review agent only | Append only during code-review | + +## 코드리뷰 결과 + +- 종합 판정: FAIL +- 차원별 평가: + - correctness: Fail + - completeness: Fail + - test coverage: Fail + - API contract: Fail + - code quality: Fail + - plan deviation: Fail + - verification trust: Pass +- 발견된 문제: + - Required: `services/core/internal/controlplane/runtime.go:490`의 explicit before/after scan path가 `RevisionScanRequest.WorkDir`/`Runner`를 사용하지 않고 `ChangedFiles`가 비어 있는 `RevisionEvent`를 바로 `HandleRevision`에 전달한다. PLAN은 해당 pair로 changed files를 계산하라고 요구했고, `branch.updated` 제공 계약도 `changed_files`를 소비자 판단 정보로 둔다. fix: explicit pair path에서 `gitengine.ChangedFilesWithStatus(req.Runner 또는 CLI, req.WorkDir, before, after)`를 실행해 `revisionEventFromScan`으로 이벤트를 만들고, no-cursor/publish-failure 테스트가 changed_files와 git runner 호출까지 assert하게 보강한다. + - Required: `services/core/internal/worker/runner.go:245`에서 durable input loader가 실패하면 이미 `running`으로 pick된 `agent_run`을 terminal 상태로 전이하지 않고 error return한다. production에서 missing/corrupt input row 하나가 operation을 `running`에 고착시킬 수 있다. fix: input load 실패를 sanitized failed completion path로 수렴시켜 `FailOperation`과 `operation.failed`/`agent.run.completed` 이벤트를 남기고, `TestRunnerRunOnceAgentRunInputLoaderFailureFails`가 failed state/event를 assert하게 수정한다. + - Required: `services/core/internal/storage/postgres.go:1091`과 `services/core/migrations/00001_initial.sql:95`가 빈 `instruction`을 허용한다. PLAN의 required input validation에는 instruction이 포함되어 있고, 계약의 `AgentRunInput.instruction`은 실행 intent의 본문이다. fix: store/loader/schema에서 `strings.TrimSpace(instruction) != ""`를 강제하고 empty-instruction regression test를 추가한다. + - Required: `services/core/worker:1`에 19MB untracked worker binary가 남아 있으며 `.gitignore`에도 무시되지 않는다. workspace-ops rule은 generated build outputs를 남기지 말라고 한다. fix: 생성 바이너리를 제거하고 필요한 경우 Go build output pattern을 ignore 정책에 맞춰 정리한 뒤 `git status --short`로 소스/태스크 artifact 외 불필요 산출물이 없음을 확인한다. +- 리뷰 중 보완 검증: + - `GITO_TEST_DATABASE_URL="$DATABASE_URL" go test -run 'TestPostgres(OperationStorePickQueuedAgentRunOperationFiltersType|AgentRunInputStoreCreateAndGet)' -v ./internal/storage` PASS. + - `go test -count=1 ./internal/worker ./internal/controlplane ./internal/storage` PASS. + - `go test ./...` PASS. + - contracts/workspace keyword scans PASS. + - `git diff --check` PASS. +- 다음 단계: FAIL 후속 PLAN/CODE_REVIEW를 작성한다. USER_REVIEW gate는 트리거하지 않는다. diff --git a/agent-task/archive/2026/06/m-iop-agent-run-bridge/05_production_worker_bridge/code_review_cloud_G07_1.log b/agent-task/archive/2026/06/m-iop-agent-run-bridge/05_production_worker_bridge/code_review_cloud_G07_1.log new file mode 100644 index 0000000..d9b5c8d --- /dev/null +++ b/agent-task/archive/2026/06/m-iop-agent-run-bridge/05_production_worker_bridge/code_review_cloud_G07_1.log @@ -0,0 +1,309 @@ + + +# Code Review Reference - REVIEW_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-17 +task=m-iop-agent-run-bridge/05_production_worker_bridge, plan=1, tag=REVIEW_REVIEW_API + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/agent-shell-iop-execution/milestones/iop-agent-run-bridge.md` +- Task ids: + - `production-worker`: production worker가 durable agent_run input source에서 실행 입력을 로드하고, agent_run만 안전하게 pick해 IOP adapter/result path를 실제 runtime 의존성으로 연결한다. + - `revision-scan-seed`: cursor 없는 agent_run push도 known before/after revision으로 `branch.updated` 이벤트를 emit/retry한다. +- Completion mode: check-on-pass + +## Archive Evidence Snapshot + +- Current archived plan: `agent-task/m-iop-agent-run-bridge/05_production_worker_bridge/plan_cloud_G07_0.log`. +- Current archived review: `agent-task/m-iop-agent-run-bridge/05_production_worker_bridge/code_review_cloud_G07_0.log`. +- Verdict: FAIL. +- Required summary: + - `services/core/internal/controlplane/runtime.go`: explicit before/after revision scan must compute `changed_files` from `WorkDir`/`Runner`. + - `services/core/internal/worker/runner.go`: input loader failure must fail the already-picked operation instead of leaving it `running`. + - `services/core/internal/storage/postgres.go` and `services/core/migrations/00001_initial.sql`: durable input must reject blank `instruction`. + - `services/core/worker`: generated binary must not remain in the repository. +- Review recovery evidence: + - `GITO_TEST_DATABASE_URL="$DATABASE_URL" go test -run 'TestPostgres(OperationStorePickQueuedAgentRunOperationFiltersType|AgentRunInputStoreCreateAndGet)' -v ./internal/storage` PASS. + - `go test -count=1 ./internal/worker ./internal/controlplane ./internal/storage` PASS. + - `go test ./...` PASS. + - contracts/workspace keyword scans and `git diff --check` PASS. +- Roadmap carryover: this task still targets `production-worker` and `revision-scan-seed`. +- Additional archive reread is not required by default. If exact prior wording is needed, read only the two log paths above. + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. +리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. + +1. 판정을 append한다. +2. `CODE_REVIEW-cloud-G07.md` -> `code_review_cloud_G07_N.log`, `PLAN-cloud-G07.md` -> `plan_cloud_G07_M.log`로 아카이브한다. +3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-iop-agent-run-bridge/05_production_worker_bridge/`로 이동한다. 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_REVIEW_API-1] Explicit revision scan changed_files | [x] | +| [REVIEW_REVIEW_API-2] Loader failure terminal state | [x] | +| [REVIEW_REVIEW_API-3] Durable input instruction validation | [x] | +| [REVIEW_REVIEW_API-4] Generated worker binary cleanup | [x] | + +## 구현 체크리스트 + +- [x] explicit before/after revision scan이 `WorkDir`/`Runner`로 changed files를 계산해 `branch.updated` 이벤트에 포함하도록 고친다. 검증: `cd services/core && go test -count=1 ./internal/controlplane ./internal/worker` +- [x] input loader failure가 이미 picked된 `agent_run` operation을 sanitized failed terminal path로 수렴시키도록 고친다. 검증: `cd services/core && go test -count=1 ./internal/worker` +- [x] durable agent_run input에서 blank `instruction`을 store/schema/loader boundary에서 거부하고 regression test를 추가한다. 검증: `cd services/core && GITO_TEST_DATABASE_URL="$DATABASE_URL" go test -run 'TestPostgresAgentRunInputStoreCreateAndGet' -v ./internal/storage` +- [x] generated worker binary `services/core/worker`를 제거하고 source/task artifact 외 불필요 산출물이 남지 않게 한다. 검증: `git status --short`와 `git check-ignore -v services/core/worker` 확인 +- [x] focused/full Go tests, contracts/workspace keyword scans, diff hygiene를 통과시킨다. +- [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_cloud_G07_N.log`로 아카이브한다. +- [x] active `PLAN-*-G??.md`를 `plan_cloud_G07_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/05_production_worker_bridge/`를 `agent-task/archive/YYYY/MM/m-iop-agent-run-bridge/05_production_worker_bridge/`로 이동하고 최종 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-cloud-G07.md`와 `CODE_REVIEW-cloud-G07.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로 이동한다. + +## 계획 대비 변경 사항 + +1. **`scanExplicitRevisionPair` 내 cursor upsert 제거**: 계획은 `scanExplicitRevisionPair`가 broadcast 성공 후 cursor를 upsert한다고 명시했다. 그러나 실제로는 `HandleRevision` → `handleRevisionWithStore`가 broadcast 성공 후 cursor를 이미 upsert한다. 따라서 `scanExplicitRevisionPair`에서 별도 cursor upsert를 추가하면 중복이 된다. `handleRevisionWithStore`의 기존 cursor semantics에 의존하는 방식으로 구현했고, `TestRuntimeScanAgentRunRevisionEmitsWithoutExistingCursor`가 cursor 전진을 검증해 올바름을 확인했다. + +2. **`TestRuntimeScanAgentRunRevisionEmitsWithoutExistingCursor`/`NoCursorAdvance` 실제 git 레포 사용**: 계획은 fake runner 사용도 언급했으나, `ChangedFilesWithStatus`가 실제 git 명령에 의존하므로 real git repo fixture(real SHA + `gitengine.CLI{}`)를 사용했다. 기존 테스트 패턴(`setupGitRepo`)을 재활용해 일관성을 유지했다. + +3. **`TestRunnerRunOnceAgentRunInputLoaderFailureFails` 어서션 변경**: 이전 구현은 error를 반환했고 테스트도 `err != nil`을 기대했다. 이제 loader failure가 terminal completion path로 수렴하므로 `err == nil`, `failedCalls == 1`, completion events 존재를 검증하도록 전체 교체했다. + +## 주요 설계 결정 + +1. **`scanExplicitRevisionPair`에 `workdir` 빈값 에러 처리**: workdir이 없으면 `gitengine.ChangedFilesWithStatus`를 호출할 수 없어 즉시 error를 반환한다. runner nil은 `gitengine.CLI{}`로 기본값 처리하여 test에서도 실제 git 바이너리를 사용할 수 있게 했다. + +2. **loader failure terminal path — 인라인 구현**: `runAgentRunOperation`의 completion event 생성 로직을 별도 헬퍼로 추출하지 않고, loader failure 직후 인라인으로 `FailOperation` + `OperationFailed`/`AgentRunCompleted` 이벤트 2개를 append하는 방식을 택했다. 추출로 인한 불필요한 추상화 없이 범위를 좁게 유지했다. + +3. **instruction 검증 store-level 우선**: store `CreateAgentRunInput`에서 `strings.TrimSpace` 후 blank 체크를 먼저 수행해 DB 도달 전 명시적 `ErrInvalidAgentRunInput` 반환을 보장한다. migration CHECK constraint는 DB 레벨 방어선으로 추가했다. + +4. **generated binary 단순 삭제**: `.gitignore`에 entry를 추가하는 것은 Go 빌드 산출물의 반복 생성이 확인되지 않아 생략했다. 필요하면 별도 결정으로 처리한다. + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- explicit before/after revision scan이 changed_files를 실제 git diff에서 계산하는지 확인한다. +- loader failure가 raw error를 payload에 노출하지 않고 operation failed terminal event를 남기는지 확인한다. +- blank instruction이 store/schema/loader boundary에서 모두 거부되는지 확인한다. +- generated binary가 workspace에 남지 않는지 확인한다. +- Postgres focused tests가 실제 DB path로 실행됐는지 확인한다. + +## 검증 결과 + +_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ + +필수 규칙: +- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다. +- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. +- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. +- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다. +- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다. + +### REVIEW_REVIEW_API-1 중간 검증 + +```bash +$ cd services/core && go test -count=1 ./internal/controlplane ./internal/worker +ok git.toki-labs.com/toki/gito/services/core/internal/controlplane 1.724s +ok git.toki-labs.com/toki/gito/services/core/internal/worker 0.012s +``` + +### REVIEW_REVIEW_API-2 중간 검증 + +```bash +$ cd services/core && go test -count=1 ./internal/worker +ok git.toki-labs.com/toki/gito/services/core/internal/worker 0.008s +``` + +### REVIEW_REVIEW_API-3 중간 검증 + +```bash +$ cd services/core && GITO_TEST_DATABASE_URL="$DATABASE_URL" go test -run 'TestPostgresAgentRunInputStoreCreateAndGet' -v ./internal/storage +=== RUN TestPostgresAgentRunInputStoreCreateAndGet +--- PASS: TestPostgresAgentRunInputStoreCreateAndGet (0.18s) +PASS +ok git.toki-labs.com/toki/gito/services/core/internal/storage 0.184s +``` + +### REVIEW_REVIEW_API-4 중간 검증 + +```bash +$ git status --short + M agent-roadmap/phase/agent-shell-iop-execution/PHASE.md + M agent-roadmap/phase/agent-shell-iop-execution/milestones/iop-agent-run-bridge.md + M services/core/cmd/worker/main.go + M services/core/internal/config/config.go + M services/core/internal/controlplane/runtime.go + M services/core/internal/controlplane/runtime_test.go + M services/core/internal/core/model.go + M services/core/internal/storage/postgres.go + M services/core/internal/storage/postgres_test.go + M services/core/internal/storage/storage.go + M services/core/internal/storage/storage_test.go + M services/core/internal/worker/runner.go + M services/core/internal/worker/runner_test.go + M services/core/migrations/00001_initial.sql +?? agent-task/m-iop-agent-run-bridge/ +?? services/core/internal/worker/http_invoker.go +?? services/core/internal/worker/http_invoker_test.go +$ git check-ignore -v services/core/worker +(파일이 존재하지 않아 exit code 1 — binary 제거 완료) +``` + +### 최종 검증 + +```bash +$ cd services/core && go test -count=1 ./internal/worker ./internal/controlplane ./internal/storage +ok git.toki-labs.com/toki/gito/services/core/internal/worker 0.011s +ok git.toki-labs.com/toki/gito/services/core/internal/controlplane 0.594s +ok git.toki-labs.com/toki/gito/services/core/internal/storage 0.004s +``` + +```bash +$ cd services/core && GITO_TEST_DATABASE_URL="$DATABASE_URL" go test -run 'TestPostgres(OperationStorePickQueuedAgentRunOperationFiltersType|AgentRunInputStoreCreateAndGet)' -v ./internal/storage +=== RUN TestPostgresOperationStorePickQueuedAgentRunOperationFiltersType +--- PASS: TestPostgresOperationStorePickQueuedAgentRunOperationFiltersType (0.11s) +=== RUN TestPostgresAgentRunInputStoreCreateAndGet +--- PASS: TestPostgresAgentRunInputStoreCreateAndGet (0.11s) +PASS +ok git.toki-labs.com/toki/gito/services/core/internal/storage 0.222s +``` + +```bash +$ 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.588s +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 (cached) +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 0.006s +ok git.toki-labs.com/toki/gito/services/core/internal/worker 0.009s +``` + +```bash +$ rg --sort path -n "proto-socket|REST|gRPC|ChangeRequest|Operation|Event|agent_shell|agent-shell|iop" packages/contracts README.md docs +(73 lines — contracts keyword scan 이상 없음, 기존 계약 참조만 존재) +``` + +```bash +$ rg --sort path -n "Gito|agent-shell|agent_shell|iop|gito-shell|control plane" README.md docs packages/contracts agent-roadmap -g '!agent-roadmap/archive/**' +(109 lines — workspace keyword scan 이상 없음, 기존 참조만 존재) +``` + +```bash +$ git diff --check +(출력 없음 — whitespace 이슈 없음) +``` + +```bash +$ git status --short + M agent-roadmap/phase/agent-shell-iop-execution/PHASE.md + M agent-roadmap/phase/agent-shell-iop-execution/milestones/iop-agent-run-bridge.md + M services/core/cmd/worker/main.go + M services/core/internal/config/config.go + M services/core/internal/controlplane/runtime.go + M services/core/internal/controlplane/runtime_test.go + M services/core/internal/core/model.go + M services/core/internal/storage/postgres.go + M services/core/internal/storage/postgres_test.go + M services/core/internal/storage/storage.go + M services/core/internal/storage/storage_test.go + M services/core/internal/worker/runner.go + M services/core/internal/worker/runner_test.go + M services/core/migrations/00001_initial.sql +?? agent-task/m-iop-agent-run-bridge/ +?? services/core/internal/worker/http_invoker.go +?? services/core/internal/worker/http_invoker_test.go +(services/core/worker 없음 — binary 제거 완료) +``` + +--- + +> **[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. + +Sections and their ownership: + +| Section | Owner | Note | +|---------|-------|------| +| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these (archive, complete.log, and task-directory archive move are review-agent only) | +| Roadmap Targets | Fixed at stub creation from plan when present | Implementing agent must not modify; code-review copies it into `complete.log` as `Roadmap Completion` only on PASS | +| Spec Targets | Fixed at stub creation from plan when present | Implementing agent must not modify; code-review copies it into `complete.log` as `Spec Completion` only on PASS | +| Archive Evidence Snapshot | Fixed at stub creation from plan when present | Implementing agent uses it as default prior-loop context; read only the specific archive files cited there when more detail is required | +| 구현 항목별 완료 여부 (item names) | Fixed at stub creation | Implementing agent checks `[ ]` -> `[x]` only | +| 구현 체크리스트 (item text/order) | Fixed at stub creation from plan | Implementing agent checks `[ ]` -> `[x]` only; final checkbox is mandatory before saving | +| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section | +| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content | +| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless user input is required to proceed; do not ask the user directly during implementation; when filled, include exact decision, evidence, commands/output, why automatic follow-up cannot resolve it, and resume condition | +| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Pre-filled from plan | +| 검증 결과 | Implementing agent | Paste actual stdout/stderr for every command | +| 코드리뷰 결과 | Review agent only | Append only during code-review | + +## 코드리뷰 결과 + +- 종합 판정: PASS +- 차원별 평가: + - correctness: Pass + - completeness: Pass + - test coverage: Pass + - API contract: Pass + - code quality: Pass + - plan deviation: Pass + - verification trust: Pass +- 발견된 문제: 없음 +- 리뷰 중 재검증: + - `cd services/core && go test -count=1 ./internal/worker ./internal/controlplane ./internal/storage` PASS. + - `cd services/core && GITO_TEST_DATABASE_URL="$DATABASE_URL" go test -count=1 -run 'TestPostgres(OperationStorePickQueuedAgentRunOperationFiltersType|AgentRunInputStoreCreateAndGet)' -v ./internal/storage` PASS. + - `cd services/core && go test ./...` PASS. + - contracts/workspace keyword scans PASS. + - `git diff --check` PASS. + - `services/core/worker` absent. +- 다음 단계: PASS이므로 `complete.log` 작성 후 task directory를 archive로 이동한다. diff --git a/agent-task/archive/2026/06/m-iop-agent-run-bridge/05_production_worker_bridge/complete.log b/agent-task/archive/2026/06/m-iop-agent-run-bridge/05_production_worker_bridge/complete.log new file mode 100644 index 0000000..9a8d7d9 --- /dev/null +++ b/agent-task/archive/2026/06/m-iop-agent-run-bridge/05_production_worker_bridge/complete.log @@ -0,0 +1,50 @@ +# Complete - m-iop-agent-run-bridge/05_production_worker_bridge + +## 완료 일시 + +2026-06-17 + +## 요약 + +Production worker bridge follow-up completed in 2 review loops; final verdict PASS. + +## 루프 이력 + +| Plan | Review | Verdict | 메모 | +|------|--------|---------|------| +| `plan_cloud_G07_0.log` | `code_review_cloud_G07_0.log` | FAIL | explicit revision changed_files, loader failure terminal state, blank instruction validation, generated binary cleanup 보완 필요 | +| `plan_cloud_G07_1.log` | `code_review_cloud_G07_1.log` | PASS | Required 보완 완료 및 검증 통과 | + +## 구현/정리 내용 + +- Production worker가 Postgres-backed agent_run typed picker, durable input loader, HTTP IOP invoker, revision scanner를 사용하도록 연결했다. +- Durable `agent_run_inputs` store/schema와 blank instruction validation을 추가했다. +- explicit before/after revision scan이 real git diff로 `changed_files`를 계산하고 cursor-free watched branch에서도 `branch.updated` 이벤트를 emit/retry하도록 보강했다. +- input loader failure가 picked operation을 sanitized failed terminal state/event로 수렴하도록 수정했다. +- generated `services/core/worker` binary를 제거했다. + +## 최종 검증 + +- `cd services/core && go test -count=1 ./internal/worker ./internal/controlplane ./internal/storage` - PASS; worker/controlplane/storage focused tests passed. +- `cd services/core && GITO_TEST_DATABASE_URL="$DATABASE_URL" go test -count=1 -run 'TestPostgres(OperationStorePickQueuedAgentRunOperationFiltersType|AgentRunInputStoreCreateAndGet)' -v ./internal/storage` - PASS; Postgres typed picker and durable input tests passed against the configured database. +- `cd services/core && go test ./...` - PASS; full core package smoke passed. +- `rg --sort path -n "proto-socket|REST|gRPC|ChangeRequest|Operation|Event|agent_shell|agent-shell|iop" packages/contracts README.md docs` - PASS; existing contract references only. +- `rg --sort path -n "Gito|agent-shell|agent_shell|iop|gito-shell|control plane" README.md docs packages/contracts agent-roadmap -g '!agent-roadmap/archive/**'` - PASS; existing workspace/roadmap references only. +- `git diff --check` - PASS; no whitespace output. +- `git status --short` - PASS for generated binary cleanup; `services/core/worker` absent. + +## Roadmap Completion + +- Milestone: `agent-roadmap/phase/agent-shell-iop-execution/milestones/iop-agent-run-bridge.md` +- Completed task ids: + - `production-worker`: PASS; evidence=`plan_cloud_G07_1.log`, `code_review_cloud_G07_1.log`; verification=`cd services/core && go test -count=1 ./internal/worker ./internal/controlplane ./internal/storage`, `cd services/core && GITO_TEST_DATABASE_URL="$DATABASE_URL" go test -count=1 -run 'TestPostgres(OperationStorePickQueuedAgentRunOperationFiltersType|AgentRunInputStoreCreateAndGet)' -v ./internal/storage`, `cd services/core && go test ./...` + - `revision-scan-seed`: PASS; evidence=`plan_cloud_G07_1.log`, `code_review_cloud_G07_1.log`; verification=`cd services/core && go test -count=1 ./internal/worker ./internal/controlplane ./internal/storage`, `cd services/core && go test ./...` +- Not completed task ids: 없음 + +## 잔여 Nit + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/archive/2026/06/m-iop-agent-run-bridge/05_production_worker_bridge/plan_cloud_G07_0.log b/agent-task/archive/2026/06/m-iop-agent-run-bridge/05_production_worker_bridge/plan_cloud_G07_0.log new file mode 100644 index 0000000..8d4f10e --- /dev/null +++ b/agent-task/archive/2026/06/m-iop-agent-run-bridge/05_production_worker_bridge/plan_cloud_G07_0.log @@ -0,0 +1,280 @@ + + +# PLAN - REVIEW_API Production Worker Bridge + +## 이 파일을 읽는 구현 에이전트에게 + +`CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채우는 것이 구현의 마지막 필수 단계다. 구현과 검증을 끝낸 뒤 실제 변경 내용, 계획 대비 차이, 주요 결정, stdout/stderr를 review stub에 기록하고 active 파일을 유지한 채 리뷰 준비를 보고한다. 사용자 전용 결정, 사용자 소유 외부 환경/secret/service 준비, 또는 범위 충돌로 안전하게 진행할 수 없으면 review stub의 `사용자 리뷰 요청` 섹션에 정확한 근거를 채우고 멈춘다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 말고, 후속 에이전트가 재실행/증거 수집으로 해소할 수 있는 검증 공백은 사용자 리뷰 요청으로 보지 않는다. + +## 배경 + +`IOP Agent Run Bridge` 완료 리뷰 중 archived subtask PASS와 별개로 production entrypoint와 runtime data-flow가 아직 실제 bridge로 닫히지 않은 것을 확인했다. worker binary는 Postgres store와 revision scanner를 만들지만 runner에 picker/input loader/invoker를 nil로 전달해 queued `agent_run`을 처리하지 못한다. generic queued picker를 그대로 연결하면 비-agent operation이 running으로 갇힐 수 있고, revision scan은 agent run이 이미 알고 있는 before/after revision을 전달하지 않아 cursor 없는 첫 push 이벤트를 놓칠 수 있다. 이 계획은 milestone을 닫기 위한 production-safe runtime 연결과 회귀 테스트를 추가한다. + +## 사용자 리뷰 요청 흐름 + +구현 중 사용자 전용 결정은 active `CODE_REVIEW-*-G??.md`의 `사용자 리뷰 요청` 섹션에 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 형식으로 기록한다. 구현 에이전트는 chat으로 직접 묻지 않고 active 파일을 유지한 채 code-review가 `USER_REVIEW.md` 작성 필요성을 판정하게 한다. + +## Archive Evidence Snapshot + +- Prior milestone evidence: + - `agent-task/archive/2026/06/m-iop-agent-run-bridge/01_iop_invocation_contract/complete.log`: PASS; transport-independent input/invocation contract and fake backend evidence. + - `agent-task/archive/2026/06/m-iop-agent-run-bridge/02+01_git_result_convergence/complete.log`: PASS; Git evidence/result convergence. + - `agent-task/archive/2026/06/m-iop-agent-run-bridge/03_commit_push_policy/complete.log`: PASS; dirty guard and commit/push policy. + - `agent-task/archive/2026/06/m-iop-agent-run-bridge/04+03_revision_scan_event/complete.log`: PASS; revision scan event tests, `cd services/core && go test ./...`, contract keyword scan, `git diff --check`. +- Current closure review finding: milestone cannot close until production worker wiring, agent_run-only picking, durable input loading, and cursor-free agent_run revision scan behavior are implemented and verified. +- Additional archive reread is not required by default. If exact prior evidence is needed, read only the four `complete.log` paths listed above. + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/agent-shell-iop-execution/milestones/iop-agent-run-bridge.md` +- Task ids: + - `production-worker`: production worker가 durable agent_run input source에서 실행 입력을 로드하고, agent_run만 안전하게 pick해 IOP adapter/result path를 실제 runtime 의존성으로 연결한다. + - `revision-scan-seed`: cursor 없는 agent_run push도 known before/after revision으로 `branch.updated` 이벤트를 emit/retry한다. +- Completion mode: check-on-pass + +## 분석 결과 + +### 읽은 파일 + +- `agent-ops/rules/project/rules.md` +- `agent-ops/rules/private/rules.md` +- `agent-ops/rules/common/rules-roadmap.md` +- `agent-ops/skills/common/router.md` +- `agent-ops/skills/common/code-review/SKILL.md` +- `agent-ops/skills/common/update-roadmap/SKILL.md` +- `agent-ops/skills/common/plan/SKILL.md` +- `agent-test/local/rules.md` +- `agent-test/local/core-smoke.md` +- `agent-test/local/contracts-smoke.md` +- `agent-test/local/workspace-ops-smoke.md` +- `agent-ops/rules/project/domain/core/rules.md` +- `agent-ops/rules/project/domain/contracts/rules.md` +- `agent-ops/rules/project/domain/workspace-ops/rules.md` +- `agent-contract/index.md` +- `agent-roadmap/phase/agent-shell-iop-execution/PHASE.md` +- `agent-roadmap/current.md` +- `agent-roadmap/phase/agent-shell-iop-execution/milestones/iop-agent-run-bridge.md` +- `services/core/cmd/worker/main.go` +- `services/core/internal/config/config.go` +- `services/core/internal/core/model.go` +- `services/core/internal/worker/runner.go` +- `services/core/internal/worker/runner_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/migrations/00001_initial.sql` +- `packages/contracts/notes/gito-control-plane.md` + +### 테스트 환경 규칙 + +- test_env: local. +- `agent-test/local/rules.md`를 읽었고, matched profile로 `core-smoke.md`, `contracts-smoke.md`, `workspace-ops-smoke.md`를 읽었다. +- 적용 명령: + - core: `cd services/core && go test ./...` + - focused fresh Go tests: `cd services/core && go test -count=1 ./internal/worker ./internal/controlplane ./internal/storage` + - contracts keyword scan: `rg --sort path -n "proto-socket|REST|gRPC|ChangeRequest|Operation|Event|agent_shell|agent-shell|iop" packages/contracts README.md docs` + - workspace/roadmap keyword scan: `rg --sort path -n "Gito|agent-shell|agent_shell|iop|gito-shell|control plane" README.md docs packages/contracts agent-roadmap -g '!agent-roadmap/archive/**'` + - diff hygiene: `git diff --check` +- `<확인 필요>` 값은 발견하지 못했다. Go test cache output은 whole-package smoke에서는 허용하되, 변경 패키지 회귀는 `-count=1`로 fresh 실행한다. + +### 테스트 커버리지 공백 + +- Production worker wiring: `services/core/cmd/worker/main.go:49-62` 경로는 현재 nil dependency 구성이며 테스트가 없다. config/store/adapter wiring 단위 또는 integration-style test가 필요하다. +- Agent-run-only picking: `services/core/internal/storage/postgres.go:624-648` generic picker는 type filter가 없다. 기존 duplicate picker test는 generic behavior만 보므로 `agent_run` 전용 picker 회귀 테스트가 필요하다. +- Durable agent run input: `services/core/internal/core/model.go:32-42`와 `services/core/migrations/00001_initial.sql:26-36`에는 operation input payload source가 없다. loader가 실제 input을 읽는 normal/boundary 테스트가 필요하다. +- Cursor-free agent run revision scan: 기존 `runtime_test.go`는 cursor seeded path 중심이고, `runtime.go:369-379`는 no cursor이면 after를 저장하고 이벤트 없이 반환한다. known before/after가 있는 agent run 전용 회귀 테스트가 필요하다. + +### 심볼 참조 + +- 변경 예정 symbols: + - `storage.OperationStore.PickQueuedOperation`: 기존 generic method는 유지한다. 새 typed method 또는 adapter를 추가할 경우 `services/core/internal/storage/postgres.go`, `services/core/internal/storage/postgres_test.go`, `services/core/internal/worker/runner.go`, `services/core/cmd/worker/main.go` call site를 모두 갱신한다. + - `worker.RevisionScanRequest`: `services/core/internal/worker/runner.go`, `services/core/internal/worker/runner_test.go`, `services/core/internal/controlplane/runtime.go`, `services/core/internal/controlplane/runtime_test.go` call site를 갱신한다. + - durable input structs/store를 새로 추가하면 `services/core/internal/core/model.go`, `services/core/internal/storage/storage.go`, `services/core/internal/storage/postgres.go`, migration, worker loader tests를 함께 갱신한다. + +### 분할 판단 + +split policy를 검토했다. storage picker, operation input, worker adapter, revision scan이 서로 다른 파일을 건드리지만 하나의 production bridge closure path를 이룬다. 따로 split하면 worker wiring이 input source 없이 merge되거나 revision scan request field가 producer 없이 merge되는 중간 상태가 생겨 milestone 종료 판단이 다시 흐려진다. 따라서 단일 cloud-G07 계획으로 묶고, 구현 항목을 순서화해 partial bridge를 피한다. + +### 범위 결정 근거 + +- IOP 내부 모델 라우팅, CLI/profile/stdin/secret injection은 milestone 범위 제외이므로 구현하지 않는다. +- `../agent-shell` UI/package, provider PR/MR 생성, Plane/Jira lifecycle은 건드리지 않는다. +- 외부 protocol을 proto-socket으로 되돌리지 않는다. existing contract note의 HTTP dispatch/callback 방향만 따른다. +- 사용자 소유 외부 IOP service URL/credential이 필요한 경우 hard-code하지 말고 config와 ref boundary로만 표현한다. 실제 외부 서비스 준비가 필요해 구현자가 자동으로 검증할 수 없으면 review stub의 사용자 리뷰 요청으로 멈춘다. + +### 빌드 등급 + +cloud-G07. Postgres schema/store, worker runtime, external HTTP adapter boundary, revision event semantics가 함께 걸린 production closure 작업이다. + +## 구현 체크리스트 + +- [ ] `production-worker`를 위해 durable agent_run input source와 loader를 추가하고, production worker가 agent_run만 안전하게 pick해 configured IOP adapter/result path를 실행하도록 연결한다. 검증: `cd services/core && go test -count=1 ./internal/worker ./internal/storage` 및 `cd services/core && go test ./...` +- [ ] `revision-scan-seed`를 위해 agent_run known before/after revision을 revision scan에 전달하고 cursor 없는 watched branch도 `branch.updated` 이벤트를 emit/retry하도록 한다. 검증: `cd services/core && go test -count=1 ./internal/controlplane ./internal/worker` +- [ ] contracts/workspace keyword scan과 diff hygiene를 통과시킨다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 의존 관계 및 구현 순서 + +1. Durable input source를 먼저 추가한다. +2. `agent_run` 전용 picker와 production worker wiring을 연결한다. +3. IOP adapter/result path를 config 기반으로 붙인다. +4. revision scan before/after propagation과 no-cursor event behavior를 보강한다. +5. focused tests, full core smoke, keyword scans, diff hygiene를 실행한다. + +### [REVIEW_API-1] Durable agent_run input and production-safe picker + +#### 문제 + +- `services/core/cmd/worker/main.go:49-62`는 Postgres store와 scanner를 만든 뒤 `worker.NewRunner(cfg, logger, nil, nil, scanner)`로 runner를 구성해 production worker가 항상 `worker picker not configured` 경로로 빠진다. +- `services/core/internal/storage/postgres.go:624-648`의 `PickQueuedOperation`은 operation type filter 없이 queued row를 running으로 전이한다. 이를 runner에 그대로 연결하면 `services/core/internal/worker/runner.go:170-174`에서 non-agent operation을 no-op 처리해 running 상태에 갇힐 수 있다. +- `services/core/internal/core/model.go:32-42`와 `services/core/migrations/00001_initial.sql:26-36`에는 `AgentRunInput`을 durable하게 저장/조회할 source가 없다. `services/core/internal/worker/runner.go:221-226`의 loader/invoker interface는 production 구현이 없다. + +#### 해결 방법 + +- 기존 generic `PickQueuedOperation`의 의미는 유지한다. +- `agent_run`만 queued->running으로 전이하는 typed picker/store method를 추가한다. SQL은 기존 CTE 패턴을 재사용하되 `WHERE state=$1 AND type=$2` 조건을 둔다. +- operation input payload를 저장/조회할 최소 durable 모델을 추가한다. 권장안은 별도 `agent_run_inputs` 테이블과 store interface이며, operation row 자체의 기존 create/list semantics를 깨지 않는다. +- production `AgentRunInputLoader`는 operation id로 durable input을 읽고 `worker.AgentRunInput`으로 변환한다. 빈 workspace path, instruction, branch 등 필수 입력은 repo-owned validation error로 실패시킨다. +- production worker main은 Postgres store, typed picker, operation/event store, durable input loader, configured IOP invoker를 모두 갖춘 경우에만 agent_run runner를 구성한다. IOP endpoint config가 비어 있으면 agent_run을 pick하지 않고 명확한 startup log 또는 disabled path로 둔다. + +#### 수정 파일 및 체크리스트 + +- [ ] `services/core/internal/core/model.go`: durable agent_run input 모델을 추가한다. +- [ ] `services/core/migrations/00001_initial.sql`: input table/index 또는 동등한 durable schema를 추가한다. +- [ ] `services/core/internal/storage/storage.go`: typed picker와 agent_run input store interface를 추가한다. +- [ ] `services/core/internal/storage/postgres.go`: typed picker SQL, input create/get implementation, validation을 추가한다. +- [ ] `services/core/internal/storage/postgres_test.go`: generic picker 기존 동작 유지 테스트와 agent_run-only picker/input store tests를 추가한다. +- [ ] `services/core/internal/worker/runner.go`: production loader/typed picker adapter 연결에 필요한 작은 interface 조정을 한다. +- [ ] `services/core/internal/worker/runner_test.go`: non-agent operation이 typed picker에서 선택되지 않는 회귀와 missing input failure를 추가한다. +- [ ] `services/core/internal/config/config.go`: IOP HTTP adapter에 필요한 endpoint/ref config만 추가하고 raw secret은 추가하지 않는다. +- [ ] `services/core/cmd/worker/main.go`: production dependencies를 wiring하고 nil dependency no-op path를 제거하거나 explicit disabled 상태로 만든다. + +#### 테스트 작성 + +- 작성한다. +- `services/core/internal/storage/postgres_test.go`: `TestPostgresOperationStorePickQueuedAgentRunOperationFiltersType`, `TestPostgresAgentRunInputStoreCreateAndGet`. +- `services/core/internal/worker/runner_test.go`: typed picker/wiring adapter 또는 durable input loader failure/success tests. +- 필요 시 `services/core/cmd/worker` wiring은 extractable constructor를 만들어 unit test한다. + +#### 중간 검증 + +```bash +cd services/core && go test -count=1 ./internal/worker ./internal/storage +``` + +예상: fresh 실행 PASS. + +### [REVIEW_API-2] IOP HTTP adapter/result path + +#### 문제 + +- `services/core/internal/worker/runner.go:83-89`는 `AgentRunInputLoader`와 `AgentRunInvoker` interface만 정의하고 production implementation이 없다. +- `packages/contracts/notes/gito-control-plane.md`는 HTTP dispatch/callback boundary를 기록하지만, worker binary는 해당 boundary를 config나 adapter로 연결하지 않는다. + +#### 해결 방법 + +- raw secret 없이 endpoint/ref 중심 config를 추가한다. +- `worker` 또는 별도 internal package에 HTTP `AgentRunInvoker`를 구현한다. 입력/출력 JSON은 existing `AgentRunInput`/`AgentRunResult` contract shape와 호환되게 하고, unsupported state나 invalid response는 sanitized failure로 수렴시킨다. +- callback-only 외부 service를 즉시 검증할 수 없는 경우에도 runner가 operation을 무한 running으로 두지 않도록 synchronous dispatch/result mode 또는 명확한 unsupported config error를 선택한다. 사용자 소유 외부 service 준비 없이는 자동 검증이 불가능한 결정을 만나면 review stub의 `사용자 리뷰 요청`으로 멈춘다. + +#### 수정 파일 및 체크리스트 + +- [ ] `services/core/internal/config/config.go`: endpoint/ref config를 추가한다. +- [ ] `services/core/internal/worker/` 또는 적절한 internal package: HTTP invoker implementation과 tests를 추가한다. +- [ ] `services/core/cmd/worker/main.go`: HTTP invoker를 runner dependencies에 연결한다. +- [ ] `packages/contracts/notes/gito-control-plane.md`: 실제 production adapter shape와 기존 contract note가 어긋나면 최소 문구만 갱신한다. + +#### 테스트 작성 + +- 작성한다. +- `httptest.Server`로 success, failed/cancelled, invalid response, transport error를 검증한다. +- transport error는 raw upstream body를 operation event에 노출하지 않고 stable sanitized message로 수렴하는지 확인한다. + +#### 중간 검증 + +```bash +cd services/core && go test -count=1 ./internal/worker +``` + +예상: fresh 실행 PASS. + +### [REVIEW_API-3] Revision scan seed from agent_run evidence + +#### 문제 + +- `services/core/internal/worker/runner.go:64-71`의 `RevisionScanRequest`는 provider/repo/branch/workdir/runner/observed_at만 전달한다. +- worker는 `AgentRunGitEvidence.BeforeRevision`/`AfterRevision`을 알고 있지만 scan request에 싣지 않는다. +- `services/core/internal/controlplane/runtime.go:369-379`는 cursor가 없으면 `after`만 cursor로 저장하고 이벤트 없이 반환한다. agent_run push 직후 watched branch에 cursor가 없으면 `branch.updated` 이벤트를 놓칠 수 있다. +- `services/core/internal/controlplane/runtime.go:433-455`의 `ScanAgentRunRevision`은 generic `ScanBranchRevision`에만 위임해 agent_run evidence를 활용할 수 없다. + +#### 해결 방법 + +- `RevisionScanRequest`에 `BeforeRevision`/`AfterRevision` 또는 동등한 explicit revision pair를 추가한다. +- worker가 commit/push policy 완료 후 revision scan을 호출할 때 collected git evidence의 before/after를 전달한다. +- runtime은 request에 explicit before/after가 있으면 cursor 유무와 관계없이 changed files를 해당 pair로 계산하고 `HandleRevision`을 호출한다. publish failure면 cursor를 advance하지 않는 기존 보장을 유지한다. +- explicit pair가 없으면 기존 `ScanBranchRevision` behavior를 유지한다. + +#### 수정 파일 및 체크리스트 + +- [ ] `services/core/internal/worker/runner.go`: `RevisionScanRequest` 확장 및 call site 갱신. +- [ ] `services/core/internal/worker/runner_test.go`: scanner request가 before/after revision을 포함하는지 검증. +- [ ] `services/core/internal/controlplane/runtime.go`: explicit pair path와 cursor update semantics 추가. +- [ ] `services/core/internal/controlplane/runtime_test.go`: no cursor watched branch에서 agent_run scan이 event를 emit/retry하고 publish failure 시 durable cursor를 advance하지 않는 회귀 테스트 추가. + +#### 테스트 작성 + +- 작성한다. +- `TestRuntimeScanAgentRunRevisionEmitsWithoutExistingCursor`를 추가한다. +- 기존 `TestRuntimeScanAgentRunRevisionPublishFailureDoesNotAdvanceDurableState`가 explicit pair에서도 통과하도록 보강한다. +- `TestRunnerRunOnceAgentRunRevisionScan`에서 request before/after를 assert한다. + +#### 중간 검증 + +```bash +cd services/core && go test -count=1 ./internal/controlplane ./internal/worker +``` + +예상: fresh 실행 PASS. + +## 수정 파일 요약 + +| 파일 | 항목 | +|------|------| +| `services/core/internal/core/model.go` | REVIEW_API-1 | +| `services/core/migrations/00001_initial.sql` | REVIEW_API-1 | +| `services/core/internal/storage/storage.go` | REVIEW_API-1 | +| `services/core/internal/storage/postgres.go` | REVIEW_API-1 | +| `services/core/internal/storage/postgres_test.go` | REVIEW_API-1 | +| `services/core/internal/config/config.go` | REVIEW_API-1, REVIEW_API-2 | +| `services/core/cmd/worker/main.go` | REVIEW_API-1, REVIEW_API-2 | +| `services/core/internal/worker/runner.go` | REVIEW_API-1, REVIEW_API-2, REVIEW_API-3 | +| `services/core/internal/worker/runner_test.go` | REVIEW_API-1, REVIEW_API-2, REVIEW_API-3 | +| `services/core/internal/controlplane/runtime.go` | REVIEW_API-3 | +| `services/core/internal/controlplane/runtime_test.go` | REVIEW_API-3 | +| `packages/contracts/notes/gito-control-plane.md` | REVIEW_API-2 | + +## 최종 검증 + +```bash +cd services/core && go test -count=1 ./internal/worker ./internal/controlplane ./internal/storage +``` + +```bash +cd services/core && go test ./... +``` + +```bash +rg --sort path -n "proto-socket|REST|gRPC|ChangeRequest|Operation|Event|agent_shell|agent-shell|iop" packages/contracts README.md docs +``` + +```bash +rg --sort path -n "Gito|agent-shell|agent_shell|iop|gito-shell|control plane" README.md docs packages/contracts agent-roadmap -g '!agent-roadmap/archive/**' +``` + +```bash +git diff --check +``` + +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/archive/2026/06/m-iop-agent-run-bridge/05_production_worker_bridge/plan_cloud_G07_1.log b/agent-task/archive/2026/06/m-iop-agent-run-bridge/05_production_worker_bridge/plan_cloud_G07_1.log new file mode 100644 index 0000000..77ecb18 --- /dev/null +++ b/agent-task/archive/2026/06/m-iop-agent-run-bridge/05_production_worker_bridge/plan_cloud_G07_1.log @@ -0,0 +1,305 @@ + + +# PLAN - REVIEW_REVIEW_API Production Worker Bridge Follow-Up + +## 이 파일을 읽는 구현 에이전트에게 + +`CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채우는 것이 구현의 마지막 필수 단계다. 구현과 검증을 끝낸 뒤 실제 변경 내용, 계획 대비 차이, 주요 결정, stdout/stderr를 review stub에 기록하고 active 파일을 유지한 채 리뷰 준비를 보고한다. 사용자 전용 결정, 사용자 소유 외부 환경/secret/service 준비, 또는 범위 충돌로 안전하게 진행할 수 없으면 review stub의 `사용자 리뷰 요청` 섹션에 정확한 근거를 채우고 멈춘다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 말고, 후속 에이전트가 재실행/증거 수집으로 해소할 수 있는 검증 공백은 사용자 리뷰 요청으로 보지 않는다. + +## 배경 + +첫 production worker bridge 구현은 typed picker, durable input, HTTP invoker, explicit revision scan을 추가했지만 code-review에서 네 가지 Required 문제가 발견됐다. explicit revision scan은 changed files를 계산하지 않고, input loader failure는 operation을 `running`에 남길 수 있으며, durable input validation은 빈 instruction을 허용한다. 또한 repository root 아래에 generated worker binary가 남아 workspace rule을 위반한다. 이 follow-up은 기존 구현을 되돌리지 않고 해당 Required만 좁게 닫는다. + +## 사용자 리뷰 요청 흐름 + +구현 중 사용자 전용 결정은 active `CODE_REVIEW-*-G??.md`의 `사용자 리뷰 요청` 섹션에 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 형식으로 기록한다. 구현 에이전트는 chat으로 직접 묻지 않고 active 파일을 유지한 채 code-review가 `USER_REVIEW.md` 작성 필요성을 판정하게 한다. + +## Archive Evidence Snapshot + +- Current archived plan: `agent-task/m-iop-agent-run-bridge/05_production_worker_bridge/plan_cloud_G07_0.log`. +- Current archived review: `agent-task/m-iop-agent-run-bridge/05_production_worker_bridge/code_review_cloud_G07_0.log`. +- Verdict: FAIL. +- Required summary: + - `services/core/internal/controlplane/runtime.go`: explicit before/after revision scan must compute `changed_files` from `WorkDir`/`Runner`. + - `services/core/internal/worker/runner.go`: input loader failure must fail the already-picked operation instead of leaving it `running`. + - `services/core/internal/storage/postgres.go` and `services/core/migrations/00001_initial.sql`: durable input must reject blank `instruction`. + - `services/core/worker`: generated binary must not remain in the repository. +- Review recovery evidence: + - `GITO_TEST_DATABASE_URL="$DATABASE_URL" go test -run 'TestPostgres(OperationStorePickQueuedAgentRunOperationFiltersType|AgentRunInputStoreCreateAndGet)' -v ./internal/storage` PASS. + - `go test -count=1 ./internal/worker ./internal/controlplane ./internal/storage` PASS. + - `go test ./...` PASS. + - contracts/workspace keyword scans and `git diff --check` PASS. +- Roadmap carryover: this task still targets `production-worker` and `revision-scan-seed`. +- Additional archive reread is not required by default. If exact prior wording is needed, read only the two log paths above. + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/agent-shell-iop-execution/milestones/iop-agent-run-bridge.md` +- Task ids: + - `production-worker`: production worker가 durable agent_run input source에서 실행 입력을 로드하고, agent_run만 안전하게 pick해 IOP adapter/result path를 실제 runtime 의존성으로 연결한다. + - `revision-scan-seed`: cursor 없는 agent_run push도 known before/after revision으로 `branch.updated` 이벤트를 emit/retry한다. +- Completion mode: check-on-pass + +## 분석 결과 + +### 읽은 파일 + +- `agent-task/m-iop-agent-run-bridge/05_production_worker_bridge/plan_cloud_G07_0.log` +- `agent-task/m-iop-agent-run-bridge/05_production_worker_bridge/code_review_cloud_G07_0.log` +- `agent-ops/rules/project/domain/core/rules.md` +- `agent-ops/rules/project/domain/contracts/rules.md` +- `agent-ops/rules/project/domain/workspace-ops/rules.md` +- `agent-test/local/rules.md` +- `agent-test/local/core-smoke.md` +- `agent-test/local/contracts-smoke.md` +- `agent-test/local/workspace-ops-smoke.md` +- `agent-contract/index.md` +- `agent-contract/provided/gito-forgejo-branch-events-v1.md` +- `services/core/internal/controlplane/runtime.go` +- `services/core/internal/controlplane/runtime_test.go` +- `services/core/internal/worker/runner.go` +- `services/core/internal/worker/runner_test.go` +- `services/core/internal/storage/postgres.go` +- `services/core/internal/storage/postgres_test.go` +- `services/core/internal/storage/storage.go` +- `services/core/migrations/00001_initial.sql` +- `services/core/cmd/worker/main.go` +- `services/core/internal/worker/http_invoker.go` +- `services/core/internal/worker/http_invoker_test.go` +- `packages/contracts/notes/gito-control-plane.md` + +### 테스트 환경 규칙 + +- test_env: local. +- `agent-test/local/rules.md`를 읽었고, matched profile로 `core-smoke.md`, `contracts-smoke.md`, `workspace-ops-smoke.md`를 읽었다. +- 적용 명령: + - core: `cd services/core && go test ./...` + - focused fresh Go tests: `cd services/core && go test -count=1 ./internal/worker ./internal/controlplane ./internal/storage` + - Postgres focused SQL evidence: `cd services/core && GITO_TEST_DATABASE_URL="$DATABASE_URL" go test -run 'TestPostgres(OperationStorePickQueuedAgentRunOperationFiltersType|AgentRunInputStoreCreateAndGet)' -v ./internal/storage` + - contracts keyword scan: `rg --sort path -n "proto-socket|REST|gRPC|ChangeRequest|Operation|Event|agent_shell|agent-shell|iop" packages/contracts README.md docs` + - workspace keyword scan: `rg --sort path -n "Gito|agent-shell|agent_shell|iop|gito-shell|control plane" README.md docs packages/contracts agent-roadmap -g '!agent-roadmap/archive/**'` + - diff hygiene: `git diff --check` +- `<확인 필요>` 값은 발견하지 못했다. +- `GITO_TEST_DATABASE_URL`은 직접 출력하지 않는다. 현재 환경에서는 `DATABASE_URL`이 set이고 `GITO_TEST_DATABASE_URL="$DATABASE_URL"`로 focused Postgres tests가 PASS했다. + +### 테스트 커버리지 공백 + +- explicit before/after scan changed files: 기존 새 테스트는 event emit과 cursor만 확인하고 changed_files, `WorkDir`, `Runner` 사용을 확인하지 않는다. +- input loader failure terminal state: 기존 새 테스트는 error return만 확인하고 operation failed transition/event를 확인하지 않는다. +- durable input instruction validation: empty whole input은 test하지만 blank instruction만 비어 있는 boundary를 확인하지 않는다. +- generated binary cleanup: `git status --short`에서 `?? services/core/worker`가 사라지는지 확인이 필요하다. + +### 심볼 참조 + +- `worker.RevisionScanRequest`: `runtime.go`, `runtime_test.go`, `runner.go`, `runner_test.go` call site를 유지하되 explicit pair path에서 `WorkDir`/`Runner`를 실제 사용한다. +- `storage.AgentRunInputStore`: `postgres.go`, `postgres_test.go`, `cmd/worker/main.go` validation behavior를 맞춘다. +- renamed/removed symbol은 없다. + +### 분할 판단 + +split policy를 검토했다. Required 네 개는 같은 production bridge closure에서 발견됐지만 모두 현재 subtask의 보완이며, 별도 split을 만들면 active loop 상태가 불필요하게 늘어난다. 변경 범위는 `runtime`, `runner`, `storage/migration`, workspace cleanup으로 분명하고 검증 명령도 하나의 focused set으로 닫힌다. 따라서 같은 split task 디렉터리에서 단일 follow-up plan으로 처리한다. + +### 범위 결정 근거 + +- IOP callback async protocol, credential injection, cancellation/log redaction, provider adapter 확장은 다루지 않는다. +- 기존 HTTP invoker success/error shape는 Required가 아니므로 필요한 경우 테스트 영향 범위 안에서만 수정한다. +- Roadmap 문서는 직접 갱신하지 않는다. PASS completion metadata는 runtime/update-roadmap 책임이다. +- `agent-task/archive/**`를 넓게 읽지 않는다. 이 계획에 필요한 archive evidence는 같은 active task directory의 두 log 파일뿐이다. + +### 빌드 등급 + +cloud-G07. storage/migration, worker lifecycle, branch event contract, verification evidence가 함께 걸린 follow-up이며 현재 lane이 적절하다. + +## 구현 체크리스트 + +- [ ] explicit before/after revision scan이 `WorkDir`/`Runner`로 changed files를 계산해 `branch.updated` 이벤트에 포함하도록 고친다. 검증: `cd services/core && go test -count=1 ./internal/controlplane ./internal/worker` +- [ ] input loader failure가 이미 picked된 `agent_run` operation을 sanitized failed terminal path로 수렴시키도록 고친다. 검증: `cd services/core && go test -count=1 ./internal/worker` +- [ ] durable agent_run input에서 blank `instruction`을 store/schema/loader boundary에서 거부하고 regression test를 추가한다. 검증: `cd services/core && GITO_TEST_DATABASE_URL="$DATABASE_URL" go test -run 'TestPostgresAgentRunInputStoreCreateAndGet' -v ./internal/storage` +- [ ] generated worker binary `services/core/worker`를 제거하고 source/task artifact 외 불필요 산출물이 남지 않게 한다. 검증: `git status --short`와 `git check-ignore -v services/core/worker` 확인 +- [ ] focused/full Go tests, contracts/workspace keyword scans, diff hygiene를 통과시킨다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 의존 관계 및 구현 순서 + +1. revision scan changed_files와 loader failure terminal path를 먼저 고친다. +2. durable input validation과 migration CHECK를 보강한다. +3. generated binary를 제거한다. +4. focused tests, Postgres focused tests, full smoke, scans, diff hygiene를 실행한다. + +### [REVIEW_REVIEW_API-1] Explicit revision scan changed_files + +#### 문제 + +- `services/core/internal/controlplane/runtime.go:490`은 explicit before/after pair를 받은 뒤 `core.RevisionEvent`를 직접 만들며 `ChangedFiles`를 채우지 않는다. +- `services/core/internal/worker/runner.go:70-81`의 request는 `WorkDir`와 `Runner`를 유지하지만 explicit pair path는 이를 무시한다. +- `agent-contract/provided/gito-forgejo-branch-events-v1.md`의 `branch.updated` base payload는 `changed_files`를 consumer 판단 정보로 둔다. + +#### 해결 방법 + +- `scanExplicitRevisionPair` 또는 그 caller가 `workdir`과 `runner`를 인자로 받아 `gitengine.ChangedFilesWithStatus(runner, workdir, before, after)`를 실행한다. +- empty `workdir`은 explicit pair에서도 error로 처리한다. runner가 nil이면 `gitengine.CLI{}`를 쓴다. +- `revisionEventFromScan(repoID, branch, before, after, files, observedAt)`으로 이벤트를 만들고 기존 cursor/publish failure semantics를 유지한다. +- `TestRuntimeScanAgentRunRevisionEmitsWithoutExistingCursor`는 실제 git repo fixture 또는 fake runner를 사용해 changed_files가 포함되는지 assert한다. + +#### 수정 파일 및 체크리스트 + +- [ ] `services/core/internal/controlplane/runtime.go`: explicit pair path에서 changed files 계산과 workdir validation을 추가한다. +- [ ] `services/core/internal/controlplane/runtime_test.go`: no cursor explicit pair test가 changed_files와 runner/workdir 사용을 확인하게 보강한다. +- [ ] `services/core/internal/worker/runner_test.go`: scanner request before/after test가 기존대로 통과하는지 확인한다. + +#### 테스트 작성 + +- 작성한다. +- `TestRuntimeScanAgentRunRevisionEmitsWithoutExistingCursor`: event `ChangedFiles`에 modified/added 파일이 포함되는지 assert. +- `TestRuntimeScanAgentRunRevisionPublishFailureNoCursorAdvance`: changed_files 계산 후 publish failure에도 cursor 미전진을 유지하는지 assert. + +#### 중간 검증 + +```bash +cd services/core && go test -count=1 ./internal/controlplane ./internal/worker +``` + +예상: fresh 실행 PASS. + +### [REVIEW_REVIEW_API-2] Loader failure terminal state + +#### 문제 + +- `services/core/internal/worker/runner.go:245`에서 loader failure가 발생하면 `runAgentRunOperation`이 즉시 error return한다. +- operation은 typed picker에서 이미 `running`으로 전이됐으므로, missing/corrupt durable input 하나가 production operation을 terminal state 없이 고착시킨다. + +#### 해결 방법 + +- started events를 남긴 뒤 input load failure가 발생하면 `terminalState=failed`, `completionMessage="agent run input load failed"` 같은 stable sanitized message로 completion path에 진입한다. +- 원래 error는 caller에 raw로 노출하지 않는다. 필요한 경우 logger에 sanitized context만 남긴다. +- completion event payload에는 raw DB error나 raw input payload를 넣지 않는다. + +#### 수정 파일 및 체크리스트 + +- [ ] `services/core/internal/worker/runner.go`: loader failure를 failed terminal completion path로 수렴시킨다. +- [ ] `services/core/internal/worker/runner_test.go`: loader failure test가 `FailOperation` 호출, failed operation event, invoker not called를 assert하도록 보강한다. + +#### 테스트 작성 + +- 작성한다. +- `TestRunnerRunOnceAgentRunInputLoaderFailureFails`: `opStore.failedCalls == 1`, final state `failed`, completion events present, invoker calls 0. + +#### 중간 검증 + +```bash +cd services/core && go test -count=1 ./internal/worker +``` + +예상: fresh 실행 PASS. + +### [REVIEW_REVIEW_API-3] Durable input instruction validation + +#### 문제 + +- `services/core/internal/storage/postgres.go:1091-1115`은 operation/repo/branch/workspace/expected revision만 required로 검사하고 `instruction`은 비어 있어도 허용한다. +- `services/core/migrations/00001_initial.sql:95`도 `instruction TEXT NOT NULL`만 있어 empty string을 막지 않는다. +- `services/core/cmd/worker/main.go:63` loader validation도 instruction을 확인하지 않는다. + +#### 해결 방법 + +- store create path에서 `strings.TrimSpace(input.Instruction) == ""`이면 `ErrInvalidAgentRunInput`을 반환한다. +- migration schema를 `instruction TEXT NOT NULL CHECK (btrim(instruction) <> '')`로 맞춘다. +- production loader도 durable input의 `Instruction` blank를 방어적으로 거부한다. + +#### 수정 파일 및 체크리스트 + +- [ ] `services/core/internal/storage/postgres.go`: `Instruction` trim/required validation을 추가한다. +- [ ] `services/core/migrations/00001_initial.sql`: `instruction` CHECK constraint를 추가한다. +- [ ] `services/core/cmd/worker/main.go`: loader required-field check에 `instruction`을 포함한다. +- [ ] `services/core/internal/storage/postgres_test.go`: blank instruction boundary test를 추가한다. + +#### 테스트 작성 + +- 작성한다. +- `TestPostgresAgentRunInputStoreCreateAndGet`: valid input은 유지하고, blank `Instruction`만 있는 input이 `ErrInvalidAgentRunInput`을 반환하는 assertion을 추가한다. + +#### 중간 검증 + +```bash +cd services/core && GITO_TEST_DATABASE_URL="$DATABASE_URL" go test -run 'TestPostgresAgentRunInputStoreCreateAndGet' -v ./internal/storage +``` + +예상: actual Postgres path PASS. DSN 값은 출력하지 않는다. + +### [REVIEW_REVIEW_API-4] Generated worker binary cleanup + +#### 문제 + +- `services/core/worker:1`에 19MB generated worker binary가 untracked 상태로 남아 있고 `.gitignore`에도 걸리지 않는다. +- workspace-ops rule은 generated build outputs를 tracked workspace에 남기지 말라고 한다. + +#### 해결 방법 + +- `services/core/worker`를 제거한다. +- 반복 생성 가능성이 있으면 기존 Go ignore 정책과 충돌하지 않는 최소 ignore entry를 검토한다. 이 follow-up에서는 불필요하면 `.gitignore`를 수정하지 않는다. +- `git status --short`에서 `?? services/core/worker`가 사라졌는지 확인한다. + +#### 수정 파일 및 체크리스트 + +- [ ] `services/core/worker`: generated binary를 제거한다. +- [ ] `.gitignore`: 필요할 때만 최소 ignore entry를 추가한다. + +#### 테스트 작성 + +- 별도 코드 테스트는 작성하지 않는다. `git status --short`와 `git diff --check`로 산출물 정리를 검증한다. + +#### 중간 검증 + +```bash +git status --short +git check-ignore -v services/core/worker +``` + +예상: `services/core/worker`가 status에 없고, 파일이 제거된 경우 check-ignore는 파일 없음 또는 ignore 미적용을 명확히 기록한다. + +## 수정 파일 요약 + +| 파일 | 항목 | +| --- | --- | +| `services/core/internal/controlplane/runtime.go` | REVIEW_REVIEW_API-1 | +| `services/core/internal/controlplane/runtime_test.go` | REVIEW_REVIEW_API-1 | +| `services/core/internal/worker/runner.go` | REVIEW_REVIEW_API-2 | +| `services/core/internal/worker/runner_test.go` | REVIEW_REVIEW_API-1, REVIEW_REVIEW_API-2 | +| `services/core/internal/storage/postgres.go` | REVIEW_REVIEW_API-3 | +| `services/core/internal/storage/postgres_test.go` | REVIEW_REVIEW_API-3 | +| `services/core/migrations/00001_initial.sql` | REVIEW_REVIEW_API-3 | +| `services/core/cmd/worker/main.go` | REVIEW_REVIEW_API-3 | +| `services/core/worker` | REVIEW_REVIEW_API-4 | +| `.gitignore` | REVIEW_REVIEW_API-4, only if needed | + +## 최종 검증 + +```bash +cd services/core && go test -count=1 ./internal/worker ./internal/controlplane ./internal/storage +``` + +```bash +cd services/core && GITO_TEST_DATABASE_URL="$DATABASE_URL" go test -run 'TestPostgres(OperationStorePickQueuedAgentRunOperationFiltersType|AgentRunInputStoreCreateAndGet)' -v ./internal/storage +``` + +```bash +cd services/core && go test ./... +``` + +```bash +rg --sort path -n "proto-socket|REST|gRPC|ChangeRequest|Operation|Event|agent_shell|agent-shell|iop" packages/contracts README.md docs +``` + +```bash +rg --sort path -n "Gito|agent-shell|agent_shell|iop|gito-shell|control plane" README.md docs packages/contracts agent-roadmap -g '!agent-roadmap/archive/**' +``` + +```bash +git diff --check +``` + +```bash +git status --short +``` + +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/services/core/cmd/worker/main.go b/services/core/cmd/worker/main.go index cf6d0a9..8eee210 100644 --- a/services/core/cmd/worker/main.go +++ b/services/core/cmd/worker/main.go @@ -2,12 +2,16 @@ package main import ( "context" + "errors" + "fmt" "log/slog" "os" "path/filepath" + "strings" "git.toki-labs.com/toki/gito/services/core/internal/config" "git.toki-labs.com/toki/gito/services/core/internal/controlplane" + "git.toki-labs.com/toki/gito/services/core/internal/core" "git.toki-labs.com/toki/gito/services/core/internal/storage" "git.toki-labs.com/toki/gito/services/core/internal/worker" ) @@ -42,25 +46,79 @@ func migrationSQL() string { return "" } +// pgAgentRunInputLoader loads durable agent run inputs from the Postgres store +// and converts them to the worker input type. +type pgAgentRunInputLoader struct { + store storage.AgentRunInputStore +} + +func (l *pgAgentRunInputLoader) LoadAgentRunInput(ctx context.Context, op core.Operation) (worker.AgentRunInput, error) { + durable, err := l.store.GetAgentRunInput(ctx, op.ID) + if err != nil { + if errors.Is(err, storage.ErrAgentRunInputNotFound) { + return worker.AgentRunInput{}, fmt.Errorf("agent run input not found for operation %s: %w", op.ID, err) + } + return worker.AgentRunInput{}, fmt.Errorf("load agent run input: %w", err) + } + if strings.TrimSpace(durable.WorkspacePath) == "" || strings.TrimSpace(durable.Branch) == "" || strings.TrimSpace(durable.Instruction) == "" || strings.TrimSpace(durable.ExpectedRevision) == "" { + return worker.AgentRunInput{}, fmt.Errorf("agent run input for %s is missing required fields (workspace_path, branch, instruction, expected_revision)", op.ID) + } + return worker.AgentRunInput{ + OperationID: durable.OperationID, + RepoID: durable.RepoID, + Branch: durable.Branch, + WorkspacePath: durable.WorkspacePath, + Instruction: durable.Instruction, + PolicyContext: durable.PolicyContext, + ExpectedRevision: durable.ExpectedRevision, + }, nil +} + func main() { cfg := config.Load() logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) - var scanner worker.RevisionScanner - if dsn := os.Getenv("DATABASE_URL"); dsn != "" { - pgStore, err := storage.NewPgStore(context.Background(), dsn, migrationSQL()) - if err != nil { - logger.Error("failed to open postgres store", "error", err) - os.Exit(1) - } - logger.Info("postgres store connected") - defer pgStore.Close() - publisher := controlplane.NewOutboxBroadcaster(pgStore.OperationEvents()) - scanner = controlplane.NewRuntimeWithStore(publisher, pgStore) + dsn := os.Getenv("DATABASE_URL") + if dsn == "" { + logger.Error("DATABASE_URL is required") + os.Exit(1) } - runner := worker.NewRunner(cfg, logger, nil, nil, scanner) - if err := runner.Run(); err != nil { + pgStore, err := storage.NewPgStore(context.Background(), dsn, migrationSQL()) + if err != nil { + logger.Error("failed to open postgres store", "error", err) + os.Exit(1) + } + logger.Info("postgres store connected") + defer pgStore.Close() + + publisher := controlplane.NewOutboxBroadcaster(pgStore.OperationEvents()) + scanner := controlplane.NewRuntimeWithStore(publisher, pgStore) + + iopEndpoint := strings.TrimSpace(cfg.IOPEndpoint) + if iopEndpoint == "" { + logger.Info("IOP_ENDPOINT not configured; agent_run picking disabled") + // Run with nil picker so worker logs the disabled message and exits cleanly. + w := worker.NewRunner(cfg, logger, nil, nil, scanner) + if err := w.Run(); err != nil { + logger.Error("worker stopped", "error", err) + os.Exit(1) + } + return + } + + inputLoader := &pgAgentRunInputLoader{store: pgStore.AgentRunInputs()} + invoker := worker.NewHTTPInvoker(iopEndpoint) + + w := worker.NewRunnerWithDependencies(cfg, logger, nil, nil, worker.RunnerDependencies{ + OperationStore: pgStore.Operations(), + OperationEventStore: pgStore.OperationEvents(), + AgentRunPicker: pgStore.Operations(), + AgentRunInputLoader: inputLoader, + AgentRunInvoker: invoker, + RevisionScanner: scanner, + }) + if err := w.Run(); err != nil { logger.Error("worker stopped", "error", err) os.Exit(1) } diff --git a/services/core/internal/config/config.go b/services/core/internal/config/config.go index 2544acb..166528e 100644 --- a/services/core/internal/config/config.go +++ b/services/core/internal/config/config.go @@ -15,6 +15,9 @@ type Config struct { ProtoSocketHeartbeatWait int ForgejoWebhookSecret string WorkerEnabled bool + // IOPEndpoint is the base URL of the IOP HTTP dispatch service. + // When empty, agent_run operations are not picked. + IOPEndpoint string } func Load() Config { @@ -28,6 +31,7 @@ func Load() Config { ProtoSocketHeartbeatWait: getEnvInt("PROTO_SOCKET_HEARTBEAT_WAIT_SEC", 10), ForgejoWebhookSecret: os.Getenv("FORGEJO_WEBHOOK_SECRET"), WorkerEnabled: getEnvBool("WORKER_ENABLED", true), + IOPEndpoint: os.Getenv("IOP_ENDPOINT"), } } diff --git a/services/core/internal/controlplane/runtime.go b/services/core/internal/controlplane/runtime.go index cdd3934..2a49f10 100644 --- a/services/core/internal/controlplane/runtime.go +++ b/services/core/internal/controlplane/runtime.go @@ -431,6 +431,26 @@ func revisionEventFromScan(repoID, branch, before, after string, files []gitengi } func (r *Runtime) ScanAgentRunRevision(ctx context.Context, req worker.RevisionScanRequest) (worker.RevisionScanResult, error) { + before := strings.TrimSpace(req.BeforeRevision) + after := strings.TrimSpace(req.AfterRevision) + + if before != "" && after != "" { + // Explicit pair: bypass cursor look-up and emit immediately if watched. + // This covers cursor-free first push of a watched branch after agent_run. + record, matched, err := r.scanExplicitRevisionPair(ctx, req.Provider, req.RepoID, req.Branch, before, after, req.WorkDir, req.Runner, req.ObservedAt) + if err != nil { + return worker.RevisionScanResult{}, err + } + if matched && record.ID != "" && r.broadcaster == nil { + return worker.RevisionScanResult{}, fmt.Errorf("event matched but broadcaster is nil (not published)") + } + return worker.RevisionScanResult{ + Attempted: true, + Matched: matched, + EventID: record.ID, + }, nil + } + opts := ScanRevisionOptions{ Provider: req.Provider, RepoID: req.RepoID, @@ -455,6 +475,40 @@ func (r *Runtime) ScanAgentRunRevision(ctx context.Context, req worker.RevisionS }, nil } +// scanExplicitRevisionPair emits a branch.updated event for a known before/after +// pair, regardless of cursor state. changed_files are computed from workdir using +// runner. On publish success the cursor advances to after; on failure the cursor +// is not advanced, preserving retry semantics. +func (r *Runtime) scanExplicitRevisionPair(ctx context.Context, provider, repoID, branch, before, after, workdir string, runner gitengine.CommandRunner, observedAt time.Time) (EventRecord, bool, error) { + provider = strings.TrimSpace(provider) + if provider == "" { + provider = "forgejo" + } + if strings.TrimSpace(workdir) == "" { + return EventRecord{}, false, fmt.Errorf("workdir is required for explicit revision pair scan") + } + if runner == nil { + runner = gitengine.CLI{} + } + if observedAt.IsZero() { + observedAt = time.Now().UTC() + } + + changedFiles, err := gitengine.ChangedFilesWithStatus(runner, workdir, before, after) + if err != nil { + return EventRecord{}, false, fmt.Errorf("changed files for explicit revision pair: %w", err) + } + + revEvent := revisionEventFromScan(repoID, branch, before, after, changedFiles, observedAt) + + record, matched, err := r.HandleRevision(ctx, provider, "", revEvent) + if err != nil { + return record, matched, err + } + + return record, matched, nil +} + type outboxBroadcaster struct { store storage.OperationEventStore } diff --git a/services/core/internal/controlplane/runtime_test.go b/services/core/internal/controlplane/runtime_test.go index 8a00489..68b72d0 100644 --- a/services/core/internal/controlplane/runtime_test.go +++ b/services/core/internal/controlplane/runtime_test.go @@ -47,6 +47,7 @@ func (f *fakeStore) OperationEvents() storage.OperationEventStore { return nil } func (f *fakeStore) BranchWatches() storage.BranchWatchStore { return f.watches } func (f *fakeStore) RevisionCursors() storage.RevisionCursorStore { return f.cursors } func (f *fakeStore) ProviderDeliveries() storage.ProviderDeliveryStore { return f.deliveries } +func (f *fakeStore) AgentRunInputs() storage.AgentRunInputStore { return nil } var _ storage.Store = (*fakeStore)(nil) @@ -846,3 +847,130 @@ func TestRuntimeScanAgentRunRevisionPublishFailureDoesNotAdvanceDurableState(t * t.Errorf("expected 1 broadcasted envelope, got %d", len(successBroadcaster.envelopes)) } } + +// TestRuntimeScanAgentRunRevisionEmitsWithoutExistingCursor verifies that when +// an explicit before/after pair is provided, a branch.updated event is emitted +// even if no cursor exists yet for the branch. changed_files must be populated +// from the real git diff between before and after. +func TestRuntimeScanAgentRunRevisionEmitsWithoutExistingCursor(t *testing.T) { + store := newFakeStore() + broadcaster := &fakeBroadcaster{} + r := NewRuntimeWithStore(broadcaster, store) + + _, err := r.RegisterBranchWatch("myrepo", "main", "forgejo") + if err != nil { + t.Fatalf("register watch: %v", err) + } + + // Set up a real git repo with two commits so changed_files can be computed. + workdir, beforeRev := setupGitRepo(t) + writeFile(t, filepath.Join(workdir, "agent.txt"), "agent output\n") + runGit(t, workdir, "add", "agent.txt") + runGit(t, workdir, "config", "commit.gpgsign", "false") + runGit(t, workdir, "commit", "-m", "agent run result") + cmd := exec.Command("git", "rev-parse", "HEAD") + cmd.Dir = workdir + out, err := cmd.Output() + if err != nil { + t.Fatalf("get after rev: %v", err) + } + afterRev := strings.TrimSpace(string(out)) + + // No cursor seeded — branch has never been observed. + req := worker.RevisionScanRequest{ + Provider: "forgejo", + RepoID: "myrepo", + Branch: "main", + WorkDir: workdir, + Runner: gitengine.CLI{}, + BeforeRevision: beforeRev, + AfterRevision: afterRev, + ObservedAt: time.Now().UTC(), + } + + res, err := r.ScanAgentRunRevision(context.Background(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !res.Attempted { + t.Fatal("expected attempted=true") + } + if !res.Matched { + t.Fatal("expected matched=true when watch exists") + } + if res.EventID == "" { + t.Fatal("expected non-empty event_id") + } + + // Cursor must be advanced to after-revision. + cursor, ok, err := store.cursors.GetRevisionCursor(context.Background(), "myrepo", "main") + if err != nil || !ok { + t.Fatalf("cursor not stored after explicit scan: err=%v ok=%v", err, ok) + } + if cursor.Revision != afterRev { + t.Fatalf("cursor revision: got %q want %q", cursor.Revision, afterRev) + } + + // Broadcaster must have received the event with changed_files. + if len(broadcaster.envelopes) != 1 { + t.Fatalf("expected 1 broadcasted envelope, got %d", len(broadcaster.envelopes)) + } + payload := broadcaster.envelopes[0].Payload + files, _ := payload["changed_files"].([]any) + if len(files) == 0 { + t.Errorf("changed_files must be non-empty in branch.updated event; payload=%v", payload) + } +} + +// TestRuntimeScanAgentRunRevisionPublishFailureNoCursorAdvance verifies that +// when the broadcaster fails, the durable cursor is not advanced — even with +// an explicit before/after pair. +func TestRuntimeScanAgentRunRevisionPublishFailureNoCursorAdvance(t *testing.T) { + store := newFakeStore() + failBroadcaster := &fakeBroadcaster{err: fmt.Errorf("publish failed")} + r := NewRuntimeWithStore(failBroadcaster, store) + + _, err := r.RegisterBranchWatch("myrepo", "feature", "forgejo") + if err != nil { + t.Fatalf("register watch: %v", err) + } + + // Set up a real git repo with two commits. + workdir, beforeRev := setupGitRepo(t) + writeFile(t, filepath.Join(workdir, "feature.txt"), "feature work\n") + runGit(t, workdir, "add", "feature.txt") + runGit(t, workdir, "config", "commit.gpgsign", "false") + runGit(t, workdir, "commit", "-m", "feature commit") + cmd := exec.Command("git", "rev-parse", "HEAD") + cmd.Dir = workdir + out, err := cmd.Output() + if err != nil { + t.Fatalf("get after rev: %v", err) + } + afterRev := strings.TrimSpace(string(out)) + + req := worker.RevisionScanRequest{ + Provider: "forgejo", + RepoID: "myrepo", + Branch: "feature", + WorkDir: workdir, + Runner: gitengine.CLI{}, + BeforeRevision: beforeRev, + AfterRevision: afterRev, + ObservedAt: time.Now().UTC(), + } + + _, scanErr := r.ScanAgentRunRevision(context.Background(), req) + if scanErr == nil { + t.Fatal("expected error when broadcaster fails, got nil") + } + + // Cursor must NOT be advanced because publish failed. + _, found, err := store.cursors.GetRevisionCursor(context.Background(), "myrepo", "feature") + if err != nil { + t.Fatalf("get cursor: %v", err) + } + if found { + t.Fatal("cursor must not be stored when publish fails") + } +} diff --git a/services/core/internal/core/model.go b/services/core/internal/core/model.go index c395dc8..c066d80 100644 --- a/services/core/internal/core/model.go +++ b/services/core/internal/core/model.go @@ -151,3 +151,16 @@ type ProviderDelivery struct { Revision string CreatedAt time.Time } + +// DurableAgentRunInput is the persisted subset of agent run input that does +// not include secret values. Credential refs and raw secrets are never stored. +type DurableAgentRunInput struct { + OperationID string + RepoID string + Branch string + WorkspacePath string + Instruction string + PolicyContext map[string]any + ExpectedRevision string + CreatedAt time.Time +} diff --git a/services/core/internal/storage/postgres.go b/services/core/internal/storage/postgres.go index a040e08..b33a5d0 100644 --- a/services/core/internal/storage/postgres.go +++ b/services/core/internal/storage/postgres.go @@ -18,14 +18,15 @@ import ( // PgStore is a pgxpool-backed Store implementation. type PgStore struct { - pool *pgxpool.Pool - repos *pgRepoStore - leases *pgWorkspaceLeaseStore - ops *pgOperationStore - opEvents *pgOperationEventStore - watches *pgBranchWatchStore - cursors *pgRevisionCursorStore - deliveries *pgProviderDeliveryStore + pool *pgxpool.Pool + repos *pgRepoStore + leases *pgWorkspaceLeaseStore + ops *pgOperationStore + opEvents *pgOperationEventStore + watches *pgBranchWatchStore + cursors *pgRevisionCursorStore + deliveries *pgProviderDeliveryStore + agentRunInputs *pgAgentRunInputStore } // NewPgStore opens a pgxpool connection and optionally applies a migration. @@ -54,6 +55,7 @@ func NewPgStore(ctx context.Context, dsn string, migrationSQL string) (*PgStore, s.watches = &pgBranchWatchStore{pool: pool} s.cursors = &pgRevisionCursorStore{pool: pool} s.deliveries = &pgProviderDeliveryStore{pool: pool} + s.agentRunInputs = &pgAgentRunInputStore{pool: pool} return s, nil } @@ -68,6 +70,7 @@ func (s *PgStore) OperationEvents() OperationEventStore { return s.opEvents func (s *PgStore) BranchWatches() BranchWatchStore { return s.watches } func (s *PgStore) RevisionCursors() RevisionCursorStore { return s.cursors } func (s *PgStore) ProviderDeliveries() ProviderDeliveryStore { return s.deliveries } +func (s *PgStore) AgentRunInputs() AgentRunInputStore { return s.agentRunInputs } // applyMigrationSQL executes the Up block extracted from goose-style SQL content. func applyMigrationSQL(ctx context.Context, pool *pgxpool.Pool, content string) error { @@ -647,6 +650,32 @@ func (s *pgOperationStore) PickQueuedOperation(ctx context.Context, now time.Tim return op, true, nil } +func (s *pgOperationStore) PickQueuedAgentRunOperation(ctx context.Context, now time.Time) (core.Operation, bool, error) { + const q = ` + WITH candidate AS ( + SELECT id + FROM operations + WHERE state=$1 AND type=$2 + ORDER BY created_at, id + FOR UPDATE SKIP LOCKED + LIMIT 1 + ) + UPDATE operations AS op + SET state=$3, updated_at=$4 + FROM candidate + WHERE op.id=candidate.id + RETURNING ` + opReturningColumns + + op, err := scanOperation(s.pool.QueryRow(ctx, q, core.OperationQueued, core.OperationAgentRun, core.OperationRunning, now.UTC())) + if err != nil { + if isNoRows(err) { + return core.Operation{}, false, nil + } + return core.Operation{}, false, fmt.Errorf("pick queued agent_run operation: %w", err) + } + return op, true, nil +} + // transition reads the current operation, validates the state change against // the core transition rules, and persists the next state with an optimistic // guard on the observed current state. @@ -1054,3 +1083,95 @@ func scanOperationEvent(scanner repoScanner) (OperationEvent, error) { } return ev, nil } + +// pgAgentRunInputStore + +type pgAgentRunInputStore struct{ pool *pgxpool.Pool } + +func (s *pgAgentRunInputStore) CreateAgentRunInput(ctx context.Context, input core.DurableAgentRunInput) error { + input.OperationID = strings.TrimSpace(input.OperationID) + input.RepoID = strings.TrimSpace(input.RepoID) + input.Branch = strings.TrimSpace(input.Branch) + input.WorkspacePath = strings.TrimSpace(input.WorkspacePath) + input.Instruction = strings.TrimSpace(input.Instruction) + input.ExpectedRevision = strings.TrimSpace(input.ExpectedRevision) + + var missing []string + if input.OperationID == "" { + missing = append(missing, "operation_id") + } + if input.RepoID == "" { + missing = append(missing, "repo_id") + } + if input.Branch == "" { + missing = append(missing, "branch") + } + if input.WorkspacePath == "" { + missing = append(missing, "workspace_path") + } + if input.Instruction == "" { + missing = append(missing, "instruction") + } + if input.ExpectedRevision == "" { + missing = append(missing, "expected_revision") + } + if len(missing) > 0 { + return fmt.Errorf("%w: required fields missing: %s", ErrInvalidAgentRunInput, strings.Join(missing, ", ")) + } + + policyJSON, err := json.Marshal(input.PolicyContext) + if err != nil { + return fmt.Errorf("%w: marshal policy_context: %v", ErrInvalidAgentRunInput, err) + } + + createdAt := input.CreatedAt + if createdAt.IsZero() { + createdAt = time.Now().UTC() + } + + const q = ` + INSERT INTO agent_run_inputs + (operation_id, repo_id, branch, workspace_path, instruction, policy_context, expected_revision, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ` + _, err = s.pool.Exec(ctx, q, + input.OperationID, input.RepoID, input.Branch, input.WorkspacePath, + input.Instruction, policyJSON, input.ExpectedRevision, createdAt, + ) + if err != nil { + return fmt.Errorf("create agent run input: %w", err) + } + return nil +} + +func (s *pgAgentRunInputStore) GetAgentRunInput(ctx context.Context, operationID string) (core.DurableAgentRunInput, error) { + operationID = strings.TrimSpace(operationID) + if operationID == "" { + return core.DurableAgentRunInput{}, fmt.Errorf("%w: operation_id is required", ErrAgentRunInputNotFound) + } + + const q = ` + SELECT operation_id, repo_id, branch, workspace_path, instruction, policy_context, expected_revision, created_at + FROM agent_run_inputs + WHERE operation_id=$1 + ` + var out core.DurableAgentRunInput + var policyJSON []byte + row := s.pool.QueryRow(ctx, q, operationID) + if err := row.Scan( + &out.OperationID, &out.RepoID, &out.Branch, &out.WorkspacePath, + &out.Instruction, &policyJSON, &out.ExpectedRevision, &out.CreatedAt, + ); err != nil { + if isNoRows(err) { + return core.DurableAgentRunInput{}, fmt.Errorf("%w: %s", ErrAgentRunInputNotFound, operationID) + } + return core.DurableAgentRunInput{}, fmt.Errorf("get agent run input: %w", err) + } + out.CreatedAt = out.CreatedAt.UTC() + if len(policyJSON) > 0 { + if err := json.Unmarshal(policyJSON, &out.PolicyContext); err != nil { + return core.DurableAgentRunInput{}, fmt.Errorf("unmarshal policy_context: %w", err) + } + } + return out, nil +} diff --git a/services/core/internal/storage/postgres_test.go b/services/core/internal/storage/postgres_test.go index 4313e30..2299a93 100644 --- a/services/core/internal/storage/postgres_test.go +++ b/services/core/internal/storage/postgres_test.go @@ -1033,3 +1033,195 @@ func TestPostgresOperationEventStoreAppendsListsMarksPublishedAndValidates(t *te t.Fatalf("expected ErrOperationNotFound for missing mark ID, got %v", err) } } + +func TestPostgresOperationStorePickQueuedAgentRunOperationFiltersType(t *testing.T) { + dsn := os.Getenv("GITO_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("GITO_TEST_DATABASE_URL not set; skipping Postgres integration test") + } + + ctx := context.Background() + dsn = isolatedPostgresDSN(t, ctx, dsn) + + migrationData, err := os.ReadFile("../../migrations/00001_initial.sql") + if err != nil { + t.Fatalf("read migration: %v", err) + } + + store, err := storage.NewPgStore(ctx, dsn, string(migrationData)) + if err != nil { + t.Fatalf("open store: %v", err) + } + defer store.Close() + + repo := core.Repo{ID: "repo-picker-test", Name: "PickerTest", RemoteURL: "https://example.invalid/r.git", DefaultBranch: "main", WorkspaceRoot: "/ws", CredentialRef: "ref:x"} + if err := store.Repos().CreateRepo(ctx, repo); err != nil { + t.Fatalf("create repo: %v", err) + } + + now := time.Now().UTC() + + // Enqueue a non-agent_run operation (clone). + cloneOp := core.Operation{ID: "op-clone-1", RepoID: repo.ID, Type: core.OperationClone, State: core.OperationQueued, CreatedBy: "test"} + if _, err := store.Operations().CreateOperation(ctx, cloneOp); err != nil { + t.Fatalf("create clone op: %v", err) + } + + // Typed pick should not return the clone operation. + picked, ok, err := store.Operations().PickQueuedAgentRunOperation(ctx, now) + if err != nil { + t.Fatalf("pick agent_run: %v", err) + } + if ok { + t.Fatalf("expected no agent_run to pick, got %+v", picked) + } + + // Generic pick should still return the clone operation. + genPicked, genOk, err := store.Operations().PickQueuedOperation(ctx, now) + if err != nil { + t.Fatalf("generic pick: %v", err) + } + if !genOk { + t.Fatal("expected generic pick to return the clone op") + } + if genPicked.Type != core.OperationClone { + t.Fatalf("generic pick type: got %q want %q", genPicked.Type, core.OperationClone) + } + + // Now enqueue an agent_run operation. + agentOp := core.Operation{ID: "op-agent-1", RepoID: repo.ID, Type: core.OperationAgentRun, State: core.OperationQueued, CreatedBy: "test"} + if _, err := store.Operations().CreateOperation(ctx, agentOp); err != nil { + t.Fatalf("create agent_run op: %v", err) + } + + // Typed pick should return only the agent_run. + agentPicked, agentOk, err := store.Operations().PickQueuedAgentRunOperation(ctx, now) + if err != nil { + t.Fatalf("pick agent_run after enqueue: %v", err) + } + if !agentOk { + t.Fatal("expected agent_run to be picked") + } + if agentPicked.Type != core.OperationAgentRun { + t.Fatalf("picked type: got %q want %q", agentPicked.Type, core.OperationAgentRun) + } + if agentPicked.State != core.OperationRunning { + t.Fatalf("picked state: got %q want running", agentPicked.State) + } + + // Second typed pick should return nothing (already running). + _, ok2, err := store.Operations().PickQueuedAgentRunOperation(ctx, now) + if err != nil { + t.Fatalf("second pick: %v", err) + } + if ok2 { + t.Fatal("expected no second agent_run to pick (already running)") + } +} + +func TestPostgresAgentRunInputStoreCreateAndGet(t *testing.T) { + dsn := os.Getenv("GITO_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("GITO_TEST_DATABASE_URL not set; skipping Postgres integration test") + } + + ctx := context.Background() + dsn = isolatedPostgresDSN(t, ctx, dsn) + + migrationData, err := os.ReadFile("../../migrations/00001_initial.sql") + if err != nil { + t.Fatalf("read migration: %v", err) + } + + store, err := storage.NewPgStore(ctx, dsn, string(migrationData)) + if err != nil { + t.Fatalf("open store: %v", err) + } + defer store.Close() + + repo := core.Repo{ID: "repo-input-test", Name: "InputTest", RemoteURL: "https://example.invalid/r.git", DefaultBranch: "main", WorkspaceRoot: "/ws", CredentialRef: "ref:x"} + if err := store.Repos().CreateRepo(ctx, repo); err != nil { + t.Fatalf("create repo: %v", err) + } + + op := core.Operation{ID: "op-input-1", RepoID: repo.ID, Type: core.OperationAgentRun, State: core.OperationQueued, CreatedBy: "test"} + if _, err := store.Operations().CreateOperation(ctx, op); err != nil { + t.Fatalf("create op: %v", err) + } + + now := time.Now().UTC().Truncate(time.Millisecond) + + policy := map[string]any{"commit_push": true, "branch": "main"} + + input := core.DurableAgentRunInput{ + OperationID: op.ID, + RepoID: repo.ID, + Branch: "main", + WorkspacePath: "/workspaces/repo-input-test/slot-0", + Instruction: "run the linter", + PolicyContext: policy, + ExpectedRevision: "abc123", + CreatedAt: now, + } + + if err := store.AgentRunInputs().CreateAgentRunInput(ctx, input); err != nil { + t.Fatalf("create agent run input: %v", err) + } + + got, err := store.AgentRunInputs().GetAgentRunInput(ctx, op.ID) + if err != nil { + t.Fatalf("get agent run input: %v", err) + } + + if got.OperationID != input.OperationID { + t.Errorf("operation_id: got %q want %q", got.OperationID, input.OperationID) + } + if got.RepoID != input.RepoID { + t.Errorf("repo_id: got %q want %q", got.RepoID, input.RepoID) + } + if got.Branch != input.Branch { + t.Errorf("branch: got %q want %q", got.Branch, input.Branch) + } + if got.WorkspacePath != input.WorkspacePath { + t.Errorf("workspace_path: got %q want %q", got.WorkspacePath, input.WorkspacePath) + } + if got.Instruction != input.Instruction { + t.Errorf("instruction: got %q want %q", got.Instruction, input.Instruction) + } + if got.ExpectedRevision != input.ExpectedRevision { + t.Errorf("expected_revision: got %q want %q", got.ExpectedRevision, input.ExpectedRevision) + } + gotJSON, _ := json.Marshal(got.PolicyContext) + wantJSON, _ := json.Marshal(input.PolicyContext) + if string(gotJSON) != string(wantJSON) { + t.Errorf("policy_context: got %s want %s", gotJSON, wantJSON) + } + + // Validation: missing required fields (empty struct). + if err := store.AgentRunInputs().CreateAgentRunInput(ctx, core.DurableAgentRunInput{}); !errors.Is(err, storage.ErrInvalidAgentRunInput) { + t.Fatalf("expected ErrInvalidAgentRunInput for empty input, got %v", err) + } + + // Validation: blank instruction only (all other fields present). + op2 := core.Operation{ID: "op-input-blank-instr", RepoID: repo.ID, Type: core.OperationAgentRun, State: core.OperationQueued, CreatedBy: "test"} + if _, err := store.Operations().CreateOperation(ctx, op2); err != nil { + t.Fatalf("create op2: %v", err) + } + blankInstr := core.DurableAgentRunInput{ + OperationID: op2.ID, + RepoID: repo.ID, + Branch: "main", + WorkspacePath: "/workspaces/repo-input-test/slot-1", + Instruction: " ", + PolicyContext: nil, + ExpectedRevision: "abc123", + } + if err := store.AgentRunInputs().CreateAgentRunInput(ctx, blankInstr); !errors.Is(err, storage.ErrInvalidAgentRunInput) { + t.Fatalf("expected ErrInvalidAgentRunInput for blank instruction, got %v", err) + } + + // Not found. + if _, err := store.AgentRunInputs().GetAgentRunInput(ctx, "nonexistent-op"); !errors.Is(err, storage.ErrAgentRunInputNotFound) { + t.Fatalf("expected ErrAgentRunInputNotFound, got %v", err) + } +} diff --git a/services/core/internal/storage/storage.go b/services/core/internal/storage/storage.go index 67c6761..25e2009 100644 --- a/services/core/internal/storage/storage.go +++ b/services/core/internal/storage/storage.go @@ -19,6 +19,7 @@ type Store interface { BranchWatches() BranchWatchStore RevisionCursors() RevisionCursorStore ProviderDeliveries() ProviderDeliveryStore + AgentRunInputs() AgentRunInputStore } type RepoStore interface { @@ -29,15 +30,17 @@ type RepoStore interface { } var ( - ErrInvalidRepo = errors.New("invalid repo") - ErrRepoAlreadyExists = errors.New("repo already exists") - ErrRepoNotFound = errors.New("repo not found") - ErrInvalidLease = errors.New("invalid workspace lease") - ErrLeaseAlreadyExists = errors.New("workspace lease already exists") - ErrLeaseNotFound = errors.New("workspace lease not found") - ErrInvalidOperation = errors.New("invalid operation") - ErrOperationAlreadyExists = errors.New("operation already exists") - ErrOperationNotFound = errors.New("operation not found") + ErrInvalidRepo = errors.New("invalid repo") + ErrRepoAlreadyExists = errors.New("repo already exists") + ErrRepoNotFound = errors.New("repo not found") + ErrInvalidLease = errors.New("invalid workspace lease") + ErrLeaseAlreadyExists = errors.New("workspace lease already exists") + ErrLeaseNotFound = errors.New("workspace lease not found") + ErrInvalidOperation = errors.New("invalid operation") + ErrOperationAlreadyExists = errors.New("operation already exists") + ErrOperationNotFound = errors.New("operation not found") + ErrAgentRunInputNotFound = errors.New("agent run input not found") + ErrInvalidAgentRunInput = errors.New("invalid agent run input") ) type WorkspaceLeaseStore interface { @@ -70,6 +73,16 @@ type OperationStore interface { FailOperation(ctx context.Context, id string, now time.Time) (core.Operation, error) CancelOperation(ctx context.Context, id string, now time.Time) (core.Operation, error) PickQueuedOperation(ctx context.Context, now time.Time) (core.Operation, bool, error) + // PickQueuedAgentRunOperation picks only agent_run-typed operations from + // the queue. Non-agent operations are never transitioned to running by this + // method, preventing them from being stranded in a running state. + PickQueuedAgentRunOperation(ctx context.Context, now time.Time) (core.Operation, bool, error) +} + +// AgentRunInputStore persists durable agent run inputs without secret values. +type AgentRunInputStore interface { + CreateAgentRunInput(ctx context.Context, input core.DurableAgentRunInput) error + GetAgentRunInput(ctx context.Context, operationID string) (core.DurableAgentRunInput, error) } type OperationEvent struct { @@ -115,13 +128,14 @@ type ProviderDeliveryStore interface { // Boundary is the concrete implementation of Store backed by injected sub-stores. type Boundary struct { - repos RepoStore - leases WorkspaceLeaseStore - ops OperationStore - opEvents OperationEventStore - watches BranchWatchStore - cursors RevisionCursorStore - deliveries ProviderDeliveryStore + repos RepoStore + leases WorkspaceLeaseStore + ops OperationStore + opEvents OperationEventStore + watches BranchWatchStore + cursors RevisionCursorStore + deliveries ProviderDeliveryStore + agentRunInputs AgentRunInputStore } func New(repos RepoStore, leases WorkspaceLeaseStore, ops OperationStore, opEvents OperationEventStore) *Boundary { @@ -171,3 +185,4 @@ func (b *Boundary) OperationEvents() OperationEventStore { return b.opEvent func (b *Boundary) BranchWatches() BranchWatchStore { return b.watches } func (b *Boundary) RevisionCursors() RevisionCursorStore { return b.cursors } func (b *Boundary) ProviderDeliveries() ProviderDeliveryStore { return b.deliveries } +func (b *Boundary) AgentRunInputs() AgentRunInputStore { return b.agentRunInputs } diff --git a/services/core/internal/storage/storage_test.go b/services/core/internal/storage/storage_test.go index 71f8088..2bc95c0 100644 --- a/services/core/internal/storage/storage_test.go +++ b/services/core/internal/storage/storage_test.go @@ -73,6 +73,9 @@ func (f *fakeOps) CancelOperation(_ context.Context, _ string, _ time.Time) (cor func (f *fakeOps) PickQueuedOperation(_ context.Context, _ time.Time) (core.Operation, bool, error) { return core.Operation{}, false, nil } +func (f *fakeOps) PickQueuedAgentRunOperation(_ context.Context, _ time.Time) (core.Operation, bool, error) { + return core.Operation{}, false, nil +} var _ storage.OperationStore = (*fakeOps)(nil) diff --git a/services/core/internal/worker/http_invoker.go b/services/core/internal/worker/http_invoker.go new file mode 100644 index 0000000..19679a9 --- /dev/null +++ b/services/core/internal/worker/http_invoker.go @@ -0,0 +1,121 @@ +package worker + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// HTTPInvoker dispatches agent runs to an external IOP HTTP service. +// The endpoint must be the full base URL; the invoker POSTs to /invoke. +// Raw upstream errors are never surfaced to callers; only stable sanitized +// messages are returned so secrets and internal details cannot leak through +// operation events. +type HTTPInvoker struct { + endpoint string + client *http.Client +} + +func NewHTTPInvoker(endpoint string) *HTTPInvoker { + return &HTTPInvoker{ + endpoint: strings.TrimRight(strings.TrimSpace(endpoint), "/"), + client: &http.Client{Timeout: 5 * time.Minute}, + } +} + +type iopInvokeRequest struct { + OperationID string `json:"operation_id"` + RepoID string `json:"repo_id"` + Branch string `json:"branch"` + WorkspacePath string `json:"workspace_path"` + Instruction string `json:"instruction"` + PolicyContext map[string]any `json:"policy_context,omitempty"` + ExpectedRevision string `json:"expected_revision,omitempty"` +} + +type iopInvokeResponse struct { + State string `json:"state"` + Message string `json:"message,omitempty"` +} + +func (h *HTTPInvoker) InvokeAgentRun(ctx context.Context, input AgentRunInput) (AgentRunResult, error) { + req := iopInvokeRequest{ + OperationID: input.OperationID, + RepoID: input.RepoID, + Branch: input.Branch, + WorkspacePath: input.WorkspacePath, + Instruction: input.Instruction, + PolicyContext: input.PolicyContext, + ExpectedRevision: input.ExpectedRevision, + } + + body, err := json.Marshal(req) + if err != nil { + return AgentRunResult{}, fmt.Errorf("marshal iop invoke request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, h.endpoint+"/invoke", bytes.NewReader(body)) + if err != nil { + return AgentRunResult{}, fmt.Errorf("build iop invoke request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := h.client.Do(httpReq) + if err != nil { + return AgentRunResult{ + State: AgentRunStateFailed, + Message: "agent run invocation transport error", + }, nil + } + defer resp.Body.Close() + + raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return AgentRunResult{ + State: AgentRunStateFailed, + Message: "agent run invocation read error", + }, nil + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return AgentRunResult{ + State: AgentRunStateFailed, + Message: fmt.Sprintf("agent run invocation failed with status %d", resp.StatusCode), + }, nil + } + + var invokeResp iopInvokeResponse + if err := json.Unmarshal(raw, &invokeResp); err != nil { + return AgentRunResult{ + State: AgentRunStateFailed, + Message: "agent run invocation returned invalid response", + }, nil + } + + state, err := parseAgentRunState(invokeResp.State) + if err != nil { + return AgentRunResult{ + State: AgentRunStateFailed, + Message: "agent run invocation returned unsupported state", + }, nil + } + + return AgentRunResult{ + State: state, + Message: invokeResp.Message, + }, nil +} + +func parseAgentRunState(s string) (AgentRunState, error) { + switch AgentRunState(s) { + case AgentRunStateSucceeded, AgentRunStateFailed, AgentRunStateCancelled: + return AgentRunState(s), nil + default: + return "", fmt.Errorf("unsupported agent run state %q", s) + } +} diff --git a/services/core/internal/worker/http_invoker_test.go b/services/core/internal/worker/http_invoker_test.go new file mode 100644 index 0000000..9a181cc --- /dev/null +++ b/services/core/internal/worker/http_invoker_test.go @@ -0,0 +1,151 @@ +package worker_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.toki-labs.com/toki/gito/services/core/internal/worker" +) + +func TestHTTPInvokerSuccess(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/invoke" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]any{"state": "succeeded", "message": "done"}) + })) + defer srv.Close() + + invoker := worker.NewHTTPInvoker(srv.URL) + result, err := invoker.InvokeAgentRun(context.Background(), worker.AgentRunInput{ + OperationID: "op-1", + RepoID: "repo-1", + Branch: "main", + WorkspacePath: "/ws", + Instruction: "run linter", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.State != worker.AgentRunStateSucceeded { + t.Errorf("state: got %q want succeeded", result.State) + } + if result.Message != "done" { + t.Errorf("message: got %q want done", result.Message) + } +} + +func TestHTTPInvokerFailedState(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]any{"state": "failed", "message": "lint failed"}) + })) + defer srv.Close() + + invoker := worker.NewHTTPInvoker(srv.URL) + result, err := invoker.InvokeAgentRun(context.Background(), worker.AgentRunInput{OperationID: "op-2"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.State != worker.AgentRunStateFailed { + t.Errorf("state: got %q want failed", result.State) + } +} + +func TestHTTPInvokerCancelledState(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]any{"state": "cancelled"}) + })) + defer srv.Close() + + invoker := worker.NewHTTPInvoker(srv.URL) + result, err := invoker.InvokeAgentRun(context.Background(), worker.AgentRunInput{OperationID: "op-3"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.State != worker.AgentRunStateCancelled { + t.Errorf("state: got %q want cancelled", result.State) + } +} + +func TestHTTPInvokerTransportError(t *testing.T) { + // Closed server simulates transport error. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + srv.Close() + + invoker := worker.NewHTTPInvoker(srv.URL) + result, err := invoker.InvokeAgentRun(context.Background(), worker.AgentRunInput{OperationID: "op-4"}) + if err != nil { + t.Fatalf("unexpected error (transport error should return sanitized result, not error): %v", err) + } + if result.State != worker.AgentRunStateFailed { + t.Errorf("transport error state: got %q want failed", result.State) + } + if strings.Contains(result.Message, "connection refused") || strings.Contains(result.Message, "dial tcp") { + t.Errorf("transport error message must not contain raw upstream details: %q", result.Message) + } +} + +func TestHTTPInvokerNonSuccessStatusCode(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = io.WriteString(w, `{"error":"internal server error","trace":"..."}`) + })) + defer srv.Close() + + invoker := worker.NewHTTPInvoker(srv.URL) + result, err := invoker.InvokeAgentRun(context.Background(), worker.AgentRunInput{OperationID: "op-5"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.State != worker.AgentRunStateFailed { + t.Errorf("non-2xx state: got %q want failed", result.State) + } + if strings.Contains(result.Message, "internal server error") || strings.Contains(result.Message, "trace") { + t.Errorf("non-2xx message must not expose upstream body: %q", result.Message) + } +} + +func TestHTTPInvokerInvalidResponseBody(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, `not-json`) + })) + defer srv.Close() + + invoker := worker.NewHTTPInvoker(srv.URL) + result, err := invoker.InvokeAgentRun(context.Background(), worker.AgentRunInput{OperationID: "op-6"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.State != worker.AgentRunStateFailed { + t.Errorf("invalid response state: got %q want failed", result.State) + } +} + +func TestHTTPInvokerUnsupportedState(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]any{"state": "unknown_state"}) + })) + defer srv.Close() + + invoker := worker.NewHTTPInvoker(srv.URL) + result, err := invoker.InvokeAgentRun(context.Background(), worker.AgentRunInput{OperationID: "op-7"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.State != worker.AgentRunStateFailed { + t.Errorf("unsupported state result: got %q want failed", result.State) + } +} diff --git a/services/core/internal/worker/runner.go b/services/core/internal/worker/runner.go index 904194f..57113fd 100644 --- a/services/core/internal/worker/runner.go +++ b/services/core/internal/worker/runner.go @@ -19,6 +19,12 @@ type OperationPicker interface { PickQueuedOperation(ctx context.Context, now time.Time) (core.Operation, bool, error) } +// AgentRunPicker picks only agent_run-typed operations from the queue, +// preventing non-agent operations from being stranded in running state. +type AgentRunPicker interface { + PickQueuedAgentRunOperation(ctx context.Context, now time.Time) (core.Operation, bool, error) +} + type Clock interface { Now() time.Time } @@ -68,6 +74,11 @@ type RevisionScanRequest struct { WorkDir string Runner gitengine.CommandRunner ObservedAt time.Time + // BeforeRevision and AfterRevision, when both non-empty, let the scanner + // emit a branch.updated event without requiring an existing cursor. This + // covers the first push of a watched branch immediately after an agent_run. + BeforeRevision string + AfterRevision string } type RevisionScanResult struct { @@ -95,12 +106,17 @@ type RunnerDependencies struct { AgentRunInvoker AgentRunInvoker GitRunner gitengine.CommandRunner RevisionScanner RevisionScanner + // AgentRunPicker, when set, is preferred over the generic OperationPicker so + // that only agent_run operations are transitioned to running. If nil, the + // caller-supplied generic picker is used instead. + AgentRunPicker AgentRunPicker } type Runner struct { cfg config.Config logger *slog.Logger picker OperationPicker + agentRunPicker AgentRunPicker clock Clock operationStore storage.OperationStore operationEventStore storage.OperationEventStore @@ -131,6 +147,7 @@ func NewRunnerWithDependencies(cfg config.Config, logger *slog.Logger, picker Op cfg: cfg, logger: logger, picker: picker, + agentRunPicker: deps.AgentRunPicker, clock: clock, operationStore: deps.OperationStore, operationEventStore: deps.OperationEventStore, @@ -151,12 +168,19 @@ func (r *Runner) RunOnce(ctx context.Context) error { return nil } - if r.picker == nil { + if r.agentRunPicker == nil && r.picker == nil { r.logger.Warn("worker picker not configured") return nil } - op, ok, err := r.picker.PickQueuedOperation(ctx, r.clock.Now()) + var op core.Operation + var ok bool + var err error + if r.agentRunPicker != nil { + op, ok, err = r.agentRunPicker.PickQueuedAgentRunOperation(ctx, r.clock.Now()) + } else { + 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) @@ -218,9 +242,36 @@ func (r *Runner) runAgentRunOperation(ctx context.Context, op core.Operation) er 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) + input, loadErr := r.agentRunInputLoader.LoadAgentRunInput(ctx, op) + if loadErr != nil { + r.logger.Warn("agent_run input load failed; failing operation", "operation_id", op.ID) + failAt := r.clock.Now().UTC() + failedOp, ferr := r.operationStore.FailOperation(ctx, op.ID, failAt) + if ferr != nil { + return fmt.Errorf("fail agent_run operation after input load failure: %w", ferr) + } + failMsg := "agent run input load failed" + failExtra := map[string]any{"message": failMsg} + for _, evType := range []string{events.OperationFailed, events.AgentRunCompleted} { + payload, perr := agentRunEventPayload(failedOp, core.OperationFailed, failAt, failExtra) + if perr != nil { + return perr + } + ev := storage.OperationEvent{ + OperationID: op.ID, + Event: events.Event{ + ID: eventID(op.ID, evType, failAt), + Type: evType, + Subject: operationEventSubject(op.ID), + Payload: payload, + CreatedAt: failAt, + }, + } + if aerr := r.operationEventStore.AppendEvent(ctx, ev); aerr != nil { + return fmt.Errorf("append %s event after input load failure: %w", evType, aerr) + } + } + return nil } result, err := r.agentRunInvoker.InvokeAgentRun(ctx, input) @@ -258,12 +309,14 @@ func (r *Runner) runAgentRunOperation(ctx context.Context, op core.Operation) er } } scanReq := RevisionScanRequest{ - Provider: provider, - RepoID: op.RepoID, - Branch: policy.Branch, - WorkDir: input.WorkspacePath, - Runner: r.gitRunner, - ObservedAt: r.clock.Now().UTC(), + Provider: provider, + RepoID: op.RepoID, + Branch: policy.Branch, + WorkDir: input.WorkspacePath, + Runner: r.gitRunner, + ObservedAt: r.clock.Now().UTC(), + BeforeRevision: evidence.BeforeRevision, + AfterRevision: evidence.AfterRevision, } scanRes, err := r.revisionScanner.ScanAgentRunRevision(ctx, scanReq) if err != nil { diff --git a/services/core/internal/worker/runner_test.go b/services/core/internal/worker/runner_test.go index aa20aaa..dec89c2 100644 --- a/services/core/internal/worker/runner_test.go +++ b/services/core/internal/worker/runner_test.go @@ -79,6 +79,9 @@ func (f *fakeOperationStore) CancelOperation(_ context.Context, id string, now t func (f *fakeOperationStore) PickQueuedOperation(_ context.Context, _ time.Time) (core.Operation, bool, error) { return core.Operation{}, false, nil } +func (f *fakeOperationStore) PickQueuedAgentRunOperation(_ 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 @@ -1326,3 +1329,167 @@ func TestRunnerRunOnceAgentRunRevisionScan(t *testing.T) { } }) } + +// fakeAgentRunPicker wraps fakeOperationStore to satisfy AgentRunPicker. +type fakeAgentRunPicker struct { + op core.Operation + found bool + err error + calls int +} + +func (f *fakeAgentRunPicker) PickQueuedAgentRunOperation(_ context.Context, _ time.Time) (core.Operation, bool, error) { + f.calls++ + return f.op, f.found, f.err +} + +func TestRunnerRunOnceUsesAgentRunPickerWhenSet(t *testing.T) { + cfg := config.Config{WorkerEnabled: true} + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + clock := fakeClock{now: time.Now()} + + agentOp := core.Operation{ + ID: "typed-pick-op", + RepoID: "repo-1", + Type: core.OperationAgentRun, + State: core.OperationRunning, + } + agentPicker := &fakeAgentRunPicker{op: agentOp, found: true} + genericPicker := &fakePicker{} + opStore := &fakeOperationStore{operation: agentOp} + eventStore := &fakeOperationEventStore{} + loader := &fakeAgentRunInputLoader{ + input: worker.AgentRunInput{ + Branch: "main", + WorkspacePath: "/ws", + ExpectedRevision: "rev-before", + }, + } + invoker := &fakeAgentRunInvoker{result: worker.AgentRunResult{State: worker.AgentRunStateSucceeded}} + gitRunner := &fakeGitRunner{outputs: []string{"", "rev-before\n", ""}} + + runner := worker.NewRunnerWithDependencies(cfg, logger, genericPicker, clock, worker.RunnerDependencies{ + OperationStore: opStore, + OperationEventStore: eventStore, + AgentRunPicker: agentPicker, + AgentRunInputLoader: loader, + AgentRunInvoker: invoker, + GitRunner: gitRunner, + }) + + if err := runner.RunOnce(context.Background()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if agentPicker.calls != 1 { + t.Errorf("expected AgentRunPicker to be called once, got %d", agentPicker.calls) + } + if genericPicker.calls != 0 { + t.Errorf("generic picker must not be called when AgentRunPicker is set, got %d calls", genericPicker.calls) + } +} + +func TestRunnerRunOnceAgentRunRevisionScanIncludesBeforeAfter(t *testing.T) { + cfg := config.Config{WorkerEnabled: true} + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + now := time.Now().UTC() + agentOp := runningAgentRunOperation("rev-scan-before-after") + clock := fakeClock{now: now} + opStore := &fakeOperationStore{operation: agentOp} + eventStore := &fakeOperationEventStore{} + loader := &fakeAgentRunInputLoader{ + input: worker.AgentRunInput{ + Branch: "main", + WorkspacePath: "/ws/slot", + ExpectedRevision: "rev-aaa", + PolicyContext: map[string]any{ + "commit_push": true, + "provider": "forgejo", + }, + }, + } + invoker := &fakeAgentRunInvoker{result: worker.AgentRunResult{State: worker.AgentRunStateSucceeded}} + // git status (clean), HEAD rev (rev-bbb), changed files + gitRunner := &fakeGitRunner{ + outputs: []string{ + "", + "rev-bbb\n", + "M\tREADME.md\n", + }, + } + scanner := &fakeRevisionScanner{result: worker.RevisionScanResult{Attempted: true, Matched: true, EventID: "evt-x"}} + picker := &fakePicker{ + pickFn: func(_ context.Context, _ time.Time) (core.Operation, bool, error) { + return agentOp, true, nil + }, + } + + runner := worker.NewRunnerWithDependencies(cfg, logger, picker, clock, worker.RunnerDependencies{ + OperationStore: opStore, + OperationEventStore: eventStore, + AgentRunInputLoader: loader, + AgentRunInvoker: invoker, + GitRunner: gitRunner, + RevisionScanner: scanner, + }) + + if err := runner.RunOnce(context.Background()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(scanner.calls) != 1 { + t.Fatalf("expected 1 scanner call, got %d", len(scanner.calls)) + } + req := scanner.calls[0] + if req.BeforeRevision != "rev-aaa" { + t.Errorf("BeforeRevision: got %q want %q", req.BeforeRevision, "rev-aaa") + } + if req.AfterRevision != "rev-bbb" { + t.Errorf("AfterRevision: got %q want %q", req.AfterRevision, "rev-bbb") + } +} + +func TestRunnerRunOnceAgentRunInputLoaderFailureFails(t *testing.T) { + cfg := config.Config{WorkerEnabled: true} + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + agentOp := runningAgentRunOperation("missing-input-op") + clock := fakeClock{now: time.Now()} + opStore := &fakeOperationStore{operation: agentOp} + eventStore := &fakeOperationEventStore{} + picker := &fakePicker{ + pickFn: func(_ context.Context, _ time.Time) (core.Operation, bool, error) { + return agentOp, true, nil + }, + } + loader := &fakeAgentRunInputLoader{err: storage.ErrAgentRunInputNotFound} + invoker := &fakeAgentRunInvoker{} + + runner := worker.NewRunnerWithDependencies(cfg, logger, picker, clock, worker.RunnerDependencies{ + OperationStore: opStore, + OperationEventStore: eventStore, + AgentRunInputLoader: loader, + AgentRunInvoker: invoker, + }) + + if err := runner.RunOnce(context.Background()); err != nil { + t.Fatalf("expected nil error after input load failure (operation must be failed, not propagated): %v", err) + } + if opStore.failedCalls != 1 { + t.Errorf("expected FailOperation called once, got %d", opStore.failedCalls) + } + if invoker.calls != 0 { + t.Errorf("invoker must not be called when input loader fails, got %d calls", invoker.calls) + } + // started events (2) + completion events (2) = 4 total + if len(eventStore.events) < 2 { + t.Errorf("expected completion events appended, got %d events", len(eventStore.events)) + } + // Last two events must be OperationFailed and AgentRunCompleted. + n := len(eventStore.events) + if n >= 2 { + if eventStore.events[n-2].Event.Type != events.OperationFailed { + t.Errorf("second-to-last event type: got %q want %q", eventStore.events[n-2].Event.Type, events.OperationFailed) + } + if eventStore.events[n-1].Event.Type != events.AgentRunCompleted { + t.Errorf("last event type: got %q want %q", eventStore.events[n-1].Event.Type, events.AgentRunCompleted) + } + } +} diff --git a/services/core/migrations/00001_initial.sql b/services/core/migrations/00001_initial.sql index 958e8d9..54ce18e 100644 --- a/services/core/migrations/00001_initial.sql +++ b/services/core/migrations/00001_initial.sql @@ -86,10 +86,22 @@ CREATE TABLE IF NOT EXISTS provider_deliveries ( created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (provider, dedupe_key) ); + +CREATE TABLE IF NOT EXISTS agent_run_inputs ( + operation_id TEXT PRIMARY KEY REFERENCES operations(id), + repo_id TEXT NOT NULL CHECK (btrim(repo_id) <> ''), + branch TEXT NOT NULL CHECK (btrim(branch) <> ''), + workspace_path TEXT NOT NULL CHECK (btrim(workspace_path) <> ''), + instruction TEXT NOT NULL CHECK (btrim(instruction) <> ''), + policy_context JSONB NOT NULL DEFAULT '{}', + expected_revision TEXT NOT NULL CHECK (btrim(expected_revision) <> ''), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); -- +goose StatementEnd -- +goose Down -- +goose StatementBegin +DROP TABLE IF EXISTS agent_run_inputs; DROP TABLE IF EXISTS provider_deliveries; DROP TABLE IF EXISTS branch_watches; DROP TABLE IF EXISTS revision_cursors;