diff --git a/agent-task/m-iop-agent-run-bridge/02+01_git_result_convergence/CODE_REVIEW-local-G06.md b/agent-task/archive/2026/06/m-iop-agent-run-bridge/02+01_git_result_convergence/code_review_local_G06_0.log similarity index 66% rename from agent-task/m-iop-agent-run-bridge/02+01_git_result_convergence/CODE_REVIEW-local-G06.md rename to agent-task/archive/2026/06/m-iop-agent-run-bridge/02+01_git_result_convergence/code_review_local_G06_0.log index d9f288a..70a0c5d 100644 --- a/agent-task/m-iop-agent-run-bridge/02+01_git_result_convergence/CODE_REVIEW-local-G06.md +++ b/agent-task/archive/2026/06/m-iop-agent-run-bridge/02+01_git_result_convergence/code_review_local_G06_0.log @@ -42,41 +42,45 @@ task=m-iop-agent-run-bridge/02+01_git_result_convergence, plan=0, tag=API | 항목 | 완료 여부 | |------|---------| -| [API-1] Git Evidence For Agent Run Result | [ ] | +| [API-1] Git Evidence For Agent Run Result | [x] | ## 구현 체크리스트 -- [ ] `01_iop_invocation_contract`의 `complete.log`가 있는지 확인하고, 없으면 구현을 시작하지 않는다. -- [ ] IOP succeeded result path에서 Git status, before/after revision, changed files evidence를 수집해 operation completion payload에 남긴다. -- [ ] dirty workspace policy, commit/push, push 이후 revision event 발행은 구현하지 않고 후속 Epic 범위로 남긴다. -- [ ] result evidence unit test를 추가한다. 검증: `cd services/core && go test -count=1 ./internal/worker ./internal/controlplane ./internal/gitengine ./internal/storage`. -- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. +- [x] `01_iop_invocation_contract`의 `complete.log`가 있는지 확인하고, 없으면 구현을 시작하지 않는다. +- [x] IOP succeeded result path에서 Git status, before/after revision, changed files evidence를 수집해 operation completion payload에 남긴다. +- [x] dirty workspace policy, commit/push, push 이후 revision event 발행은 구현하지 않고 후속 Epic 범위로 남긴다. +- [x] result evidence unit test를 추가한다. 검증: `cd services/core && go test -count=1 ./internal/worker ./internal/controlplane ./internal/gitengine ./internal/storage`. +- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. ## 코드리뷰 전용 체크리스트 > **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. > 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. -- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. -- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. -- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G06_N.log`로 아카이브한다. -- [ ] active `PLAN-*-G??.md`를 `plan_local_G06_M.log`로 아카이브한다. -- [ ] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다. -- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. -- [ ] PASS이면 active task 디렉터리 `agent-task/m-iop-agent-run-bridge/02+01_git_result_convergence/`를 `agent-task/archive/YYYY/MM/m-iop-agent-run-bridge/02+01_git_result_convergence/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. -- [ ] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. -- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-iop-agent-run-bridge/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G06_N.log`로 아카이브한다. +- [x] active `PLAN-*-G??.md`를 `plan_local_G06_M.log`로 아카이브한다. +- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다. +- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [x] PASS이면 active task 디렉터리 `agent-task/m-iop-agent-run-bridge/02+01_git_result_convergence/`를 `agent-task/archive/YYYY/MM/m-iop-agent-run-bridge/02+01_git_result_convergence/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [x] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-iop-agent-run-bridge/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. - [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G06.md`와 `CODE_REVIEW-local-G06.md`를 작성하고 `complete.log`를 작성하지 않는다. - [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다. - [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다. ## 계획 대비 변경 사항 -_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._ +- 계획 범위 유지. `services/core/internal/gitengine/command.go`와 `services/core/internal/controlplane/runtime.go`는 기존 primitive 재사용만으로 충분해 수정하지 않았다. +- local core 테스트 규칙 충족을 위해 PLAN 고정 검증 외에 `cd services/core && go test ./...`를 추가 실행했다. ## 주요 설계 결정 -_구현 에이전트가 주요 설계 결정 사항을 기록한다._ +- `RunnerDependencies.GitRunner`를 추가하고 기본값은 `gitengine.CLI{}`로 둬 production 경로는 기존 Git engine을 쓰고 테스트는 fake runner를 주입한다. +- IOP가 `succeeded`를 반환한 경우에만 `workspace_path`와 `expected_revision`으로 `git status --porcelain`, `rev-parse HEAD`, `diff --name-status before..after`를 수집한다. +- Git evidence 수집 실패 또는 필수 evidence 입력 누락은 IOP success를 신뢰하지 않고 stable message `git evidence collection failed`를 가진 failed operation event로 수렴한다. +- dirty workspace policy는 적용하지 않고 `status_clean`/`status_output` evidence만 payload에 남긴다. ## 사용자 리뷰 요청 @@ -110,13 +114,40 @@ _구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 ### API-1 중간 검증 ``` $ cd services/core && go test -count=1 ./internal/worker ./internal/controlplane ./internal/gitengine ./internal/storage -(output) +ok git.toki-labs.com/toki/gito/services/core/internal/worker 0.003s +ok git.toki-labs.com/toki/gito/services/core/internal/controlplane 0.542s +ok git.toki-labs.com/toki/gito/services/core/internal/gitengine 1.246s +ok git.toki-labs.com/toki/gito/services/core/internal/storage 0.010s ``` ### 최종 검증 ``` $ cd services/core && go test -count=1 ./internal/worker ./internal/controlplane ./internal/gitengine ./internal/storage -(output) +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.424s +ok git.toki-labs.com/toki/gito/services/core/internal/gitengine 1.785s +ok git.toki-labs.com/toki/gito/services/core/internal/storage 0.006s +``` + +### 추가 local core-smoke 검증 +``` +$ git diff --check +``` + +``` +$ 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 (cached) +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 (cached) +ok git.toki-labs.com/toki/gito/services/core/internal/worker 0.004s ``` --- @@ -124,3 +155,17 @@ $ cd services/core && go test -count=1 ./internal/worker ./internal/controlplane > **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?** > If anything is blank, go back and fill it in before saving this file. > Leave review-agent-only sections unchanged. + +## 코드리뷰 결과 + +- 종합 판정: PASS +- 차원별 평가: + - correctness: Pass + - completeness: Pass + - test coverage: Pass + - API contract: Pass + - code quality: Pass + - plan deviation: Pass + - verification trust: Pass +- 발견된 문제: 없음 +- 다음 단계: PASS이므로 `complete.log`를 작성하고 task directory를 archive로 이동한다. diff --git a/agent-task/archive/2026/06/m-iop-agent-run-bridge/02+01_git_result_convergence/complete.log b/agent-task/archive/2026/06/m-iop-agent-run-bridge/02+01_git_result_convergence/complete.log new file mode 100644 index 0000000..f1fff36 --- /dev/null +++ b/agent-task/archive/2026/06/m-iop-agent-run-bridge/02+01_git_result_convergence/complete.log @@ -0,0 +1,43 @@ +# Complete - m-iop-agent-run-bridge/02+01_git_result_convergence + +## 완료 일시 + +2026-06-16 + +## 요약 + +IOP success result path에 Git status/revision evidence를 operation completion payload로 수렴시키는 작업을 1회 리뷰 루프로 완료했다. 최종 판정은 PASS다. + +## 루프 이력 + +| Plan | Review | Verdict | 메모 | +|------|--------|---------|------| +| `plan_local_G06_0.log` | `code_review_local_G06_0.log` | PASS | Git evidence 수집, failure convergence, worker unit test, local core smoke 재검증 통과 | + +## 구현/정리 내용 + +- `RunnerDependencies.GitRunner`와 기본 `gitengine.CLI{}` 주입 경로를 추가해 worker가 IOP success 후 Git evidence를 수집할 수 있게 했다. +- IOP `succeeded` 결과에서 `status_clean`, `status_output`, `before_revision`, `after_revision`, `changed_files`를 operation/agent completion payload에 기록한다. +- Git evidence 수집 실패 또는 필수 입력 누락은 IOP success를 신뢰하지 않고 `failed` operation event로 수렴한다. +- worker unit test에 evidence payload 기록과 evidence failure path 검증을 추가했다. + +## 최종 검증 + +- `git diff --check` - PASS; 출력 없음. +- `cd services/core && go test -count=1 ./internal/worker ./internal/controlplane ./internal/gitengine ./internal/storage` - PASS; 네 package 모두 `ok`. +- `cd services/core && go test ./...` - PASS; core module 전체 Go package 통과. + +## Roadmap Completion + +- Milestone: `agent-roadmap/phase/agent-shell-iop-execution/milestones/iop-agent-run-bridge.md` +- Completed task ids: + - `result`: PASS; evidence=`agent-task/archive/2026/06/m-iop-agent-run-bridge/02+01_git_result_convergence/plan_local_G06_0.log`, `agent-task/archive/2026/06/m-iop-agent-run-bridge/02+01_git_result_convergence/code_review_local_G06_0.log`; verification=`git diff --check`, `cd services/core && go test -count=1 ./internal/worker ./internal/controlplane ./internal/gitengine ./internal/storage`, `cd services/core && go test ./...` +- Not completed task ids: 없음 + +## 잔여 Nit + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/m-iop-agent-run-bridge/02+01_git_result_convergence/PLAN-local-G06.md b/agent-task/archive/2026/06/m-iop-agent-run-bridge/02+01_git_result_convergence/plan_local_G06_0.log similarity index 100% rename from agent-task/m-iop-agent-run-bridge/02+01_git_result_convergence/PLAN-local-G06.md rename to agent-task/archive/2026/06/m-iop-agent-run-bridge/02+01_git_result_convergence/plan_local_G06_0.log diff --git a/services/core/internal/worker/runner.go b/services/core/internal/worker/runner.go index c50d29b..b75cea6 100644 --- a/services/core/internal/worker/runner.go +++ b/services/core/internal/worker/runner.go @@ -11,6 +11,7 @@ import ( "git.toki-labs.com/toki/gito/services/core/internal/config" "git.toki-labs.com/toki/gito/services/core/internal/core" "git.toki-labs.com/toki/gito/services/core/internal/events" + "git.toki-labs.com/toki/gito/services/core/internal/gitengine" "git.toki-labs.com/toki/gito/services/core/internal/storage" ) @@ -52,6 +53,14 @@ type AgentRunResult struct { Message string } +type AgentRunGitEvidence struct { + StatusClean bool + StatusOutput string + BeforeRevision string + AfterRevision string + ChangedFiles []gitengine.ChangedFile +} + type AgentRunInputLoader interface { LoadAgentRunInput(ctx context.Context, op core.Operation) (AgentRunInput, error) } @@ -65,6 +74,7 @@ type RunnerDependencies struct { OperationEventStore storage.OperationEventStore AgentRunInputLoader AgentRunInputLoader AgentRunInvoker AgentRunInvoker + GitRunner gitengine.CommandRunner } type Runner struct { @@ -76,6 +86,7 @@ type Runner struct { operationEventStore storage.OperationEventStore agentRunInputLoader AgentRunInputLoader agentRunInvoker AgentRunInvoker + gitRunner gitengine.CommandRunner } func NewRunner(cfg config.Config, logger *slog.Logger, picker OperationPicker, clock Clock) *Runner { @@ -89,6 +100,10 @@ func NewRunnerWithDependencies(cfg config.Config, logger *slog.Logger, picker Op if logger == nil { logger = slog.Default() } + gitRunner := deps.GitRunner + if gitRunner == nil { + gitRunner = gitengine.CLI{} + } return &Runner{ cfg: cfg, logger: logger, @@ -98,6 +113,7 @@ func NewRunnerWithDependencies(cfg config.Config, logger *slog.Logger, picker Op operationEventStore: deps.OperationEventStore, agentRunInputLoader: deps.AgentRunInputLoader, agentRunInvoker: deps.AgentRunInvoker, + gitRunner: gitRunner, } } @@ -199,6 +215,20 @@ func (r *Runner) runAgentRunOperation(ctx context.Context, op core.Operation) er completionMessage = string(terminalState) } + completionExtra := map[string]any{"message": completionMessage} + if terminalState == core.OperationSucceeded { + evidence, evidenceErr := r.collectAgentRunGitEvidence(input) + if evidenceErr != nil { + r.logger.Warn("agent_run git evidence collection failed", "operation_id", op.ID, "error", evidenceErr) + terminalState = core.OperationFailed + completionMessage = "git evidence collection failed" + completionExtra = map[string]any{"message": completionMessage} + } else { + completionExtra = agentRunGitEvidencePayload(evidence) + completionExtra["message"] = completionMessage + } + } + terminalAt := r.clock.Now().UTC() var updatedOperation core.Operation switch terminalState { @@ -227,7 +257,7 @@ func (r *Runner) runAgentRunOperation(ctx context.Context, op core.Operation) er Subject: operationEventSubject(op.ID), }, } - completedPayload, err := agentRunEventPayload(updatedOperation, terminalState, terminalAt, map[string]any{"message": completionMessage}) + completedPayload, err := agentRunEventPayload(updatedOperation, terminalState, terminalAt, completionExtra) if err != nil { return err } @@ -245,7 +275,7 @@ func (r *Runner) runAgentRunOperation(ctx context.Context, op core.Operation) er Subject: operationEventSubject(op.ID), }, } - agentCompletedPayload, err := agentRunEventPayload(updatedOperation, terminalState, terminalAt, map[string]any{"message": completionMessage}) + agentCompletedPayload, err := agentRunEventPayload(updatedOperation, terminalState, terminalAt, completionExtra) if err != nil { return err } @@ -258,6 +288,52 @@ func (r *Runner) runAgentRunOperation(ctx context.Context, op core.Operation) er return nil } +func (r *Runner) collectAgentRunGitEvidence(input AgentRunInput) (AgentRunGitEvidence, error) { + workdir := strings.TrimSpace(input.WorkspacePath) + before := strings.TrimSpace(input.ExpectedRevision) + if workdir == "" || before == "" { + return AgentRunGitEvidence{}, fmt.Errorf("workspace_path and expected_revision are required") + } + + status, err := gitengine.Status(r.gitRunner, workdir) + if err != nil { + return AgentRunGitEvidence{}, fmt.Errorf("git status: %w", err) + } + after, err := gitengine.HeadRevision(r.gitRunner, workdir) + if err != nil { + return AgentRunGitEvidence{}, fmt.Errorf("git head revision: %w", err) + } + changedFiles, err := gitengine.ChangedFilesWithStatus(r.gitRunner, workdir, before, after) + if err != nil { + return AgentRunGitEvidence{}, fmt.Errorf("git changed files: %w", err) + } + + return AgentRunGitEvidence{ + StatusClean: status.Clean, + StatusOutput: status.Output, + BeforeRevision: before, + AfterRevision: after, + ChangedFiles: changedFiles, + }, nil +} + +func agentRunGitEvidencePayload(evidence AgentRunGitEvidence) map[string]any { + changedFiles := make([]any, 0, len(evidence.ChangedFiles)) + for _, file := range evidence.ChangedFiles { + changedFiles = append(changedFiles, map[string]any{ + "path": file.Path, + "change_type": file.ChangeType, + }) + } + return map[string]any{ + "status_clean": evidence.StatusClean, + "status_output": evidence.StatusOutput, + "before_revision": evidence.BeforeRevision, + "after_revision": evidence.AfterRevision, + "changed_files": changedFiles, + } +} + func operationEventSubject(operationID string) string { return "operation:" + operationID } diff --git a/services/core/internal/worker/runner_test.go b/services/core/internal/worker/runner_test.go index ad03768..8cd154c 100644 --- a/services/core/internal/worker/runner_test.go +++ b/services/core/internal/worker/runner_test.go @@ -6,6 +6,7 @@ import ( "errors" "io" "log/slog" + "reflect" "testing" "time" @@ -138,6 +139,34 @@ func (f *fakeAgentRunInvoker) InvokeAgentRun(_ context.Context, _ worker.AgentRu return f.result, f.err } +type fakeGitRunner struct { + outputs []string + errs []error + calls []fakeGitCall +} + +type fakeGitCall struct { + workdir string + args []string +} + +func (f *fakeGitRunner) Run(workdir string, args ...string) (string, error) { + f.calls = append(f.calls, fakeGitCall{ + workdir: workdir, + args: append([]string(nil), args...), + }) + callIndex := len(f.calls) - 1 + var output string + if callIndex < len(f.outputs) { + output = f.outputs[callIndex] + } + var err error + if callIndex < len(f.errs) { + err = f.errs[callIndex] + } + return output, err +} + func decodePayload(t *testing.T, raw []byte) map[string]any { t.Helper() payload := map[string]any{} @@ -271,8 +300,9 @@ func TestRunnerRunOnceAgentRunInvokesBackendAndLogsEvents(t *testing.T) { eventStore := &fakeOperationEventStore{} loader := &fakeAgentRunInputLoader{ input: worker.AgentRunInput{ - Branch: "main", - WorkspacePath: "/tmp/workspace", + Branch: "main", + WorkspacePath: "/tmp/workspace", + ExpectedRevision: "before-revision", }, } invoker := &fakeAgentRunInvoker{ @@ -281,11 +311,19 @@ func TestRunnerRunOnceAgentRunInvokesBackendAndLogsEvents(t *testing.T) { Message: "done", }, } + gitRunner := &fakeGitRunner{ + outputs: []string{ + "", + "before-revision\n", + "", + }, + } runner := worker.NewRunnerWithDependencies(cfg, logger, picker, clock, worker.RunnerDependencies{ OperationStore: opStore, OperationEventStore: eventStore, AgentRunInputLoader: loader, AgentRunInvoker: invoker, + GitRunner: gitRunner, }) err := runner.RunOnce(context.Background()) @@ -337,6 +375,174 @@ func TestRunnerRunOnceAgentRunInvokesBackendAndLogsEvents(t *testing.T) { } } +func TestRunnerRunOnceAgentRunRecordsGitEvidence(t *testing.T) { + cfg := config.Config{WorkerEnabled: true} + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + now := time.Now().UTC().Truncate(time.Microsecond) + op := runningAgentRunOperation("agent-run-evidence") + picker := &fakePicker{ + pickFn: func(ctx context.Context, now time.Time) (core.Operation, bool, error) { + return op, true, nil + }, + } + clock := fakeClock{now: now} + opStore := &fakeOperationStore{operation: op} + eventStore := &fakeOperationEventStore{} + loader := &fakeAgentRunInputLoader{ + input: worker.AgentRunInput{ + Branch: "main", + WorkspacePath: "/repo/worktree", + ExpectedRevision: "before-revision", + }, + } + invoker := &fakeAgentRunInvoker{ + result: worker.AgentRunResult{ + State: worker.AgentRunStateSucceeded, + Message: "done", + }, + } + gitRunner := &fakeGitRunner{ + outputs: []string{ + "", + "after-revision\n", + "A\tREADME.md\nM\tservices/core/internal/worker/runner.go\n", + }, + } + runner := worker.NewRunnerWithDependencies(cfg, logger, picker, clock, worker.RunnerDependencies{ + OperationStore: opStore, + OperationEventStore: eventStore, + AgentRunInputLoader: loader, + AgentRunInvoker: invoker, + GitRunner: gitRunner, + }) + + err := runner.RunOnce(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if opStore.succeededCalls != 1 || opStore.failedCalls != 0 { + t.Fatalf("expected one succeed and no fail call, got succeed=%d fail=%d", opStore.succeededCalls, opStore.failedCalls) + } + if len(eventStore.events) != 4 { + t.Fatalf("expected 4 events, got %d", len(eventStore.events)) + } + + completedPayload := decodePayload(t, eventStore.events[2].Event.Payload) + if completedPayload["status_clean"] != true { + t.Fatalf("status_clean: got %v want true", completedPayload["status_clean"]) + } + if completedPayload["status_output"] != "" { + t.Fatalf("status_output: got %v want empty string", completedPayload["status_output"]) + } + if completedPayload["before_revision"] != "before-revision" { + t.Fatalf("before_revision: got %v want %q", completedPayload["before_revision"], "before-revision") + } + if completedPayload["after_revision"] != "after-revision" { + t.Fatalf("after_revision: got %v want %q", completedPayload["after_revision"], "after-revision") + } + changedFiles, ok := completedPayload["changed_files"].([]any) + if !ok { + t.Fatalf("changed_files has type %T, want []any", completedPayload["changed_files"]) + } + if len(changedFiles) != 2 { + t.Fatalf("changed_files len: got %d want 2", len(changedFiles)) + } + firstFile, ok := changedFiles[0].(map[string]any) + if !ok { + t.Fatalf("changed_files[0] has type %T, want map[string]any", changedFiles[0]) + } + if firstFile["path"] != "README.md" || firstFile["change_type"] != "added" { + t.Fatalf("changed_files[0]: got %#v", firstFile) + } + secondFile, ok := changedFiles[1].(map[string]any) + if !ok { + t.Fatalf("changed_files[1] has type %T, want map[string]any", changedFiles[1]) + } + if secondFile["path"] != "services/core/internal/worker/runner.go" || secondFile["change_type"] != "modified" { + t.Fatalf("changed_files[1]: got %#v", secondFile) + } + + wantCalls := []fakeGitCall{ + {workdir: "/repo/worktree", args: []string{"status", "--porcelain"}}, + {workdir: "/repo/worktree", args: []string{"rev-parse", "HEAD"}}, + {workdir: "/repo/worktree", args: []string{"diff", "--name-status", "before-revision..after-revision"}}, + } + if !reflect.DeepEqual(gitRunner.calls, wantCalls) { + t.Fatalf("git calls:\n got %#v\nwant %#v", gitRunner.calls, wantCalls) + } +} + +func TestRunnerRunOnceAgentRunFailsWhenGitEvidenceFails(t *testing.T) { + cfg := config.Config{WorkerEnabled: true} + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + now := time.Now().UTC().Truncate(time.Microsecond) + op := runningAgentRunOperation("agent-run-evidence-fail") + picker := &fakePicker{ + pickFn: func(ctx context.Context, now time.Time) (core.Operation, bool, error) { + return op, true, nil + }, + } + clock := fakeClock{now: now} + opStore := &fakeOperationStore{operation: op} + eventStore := &fakeOperationEventStore{} + loader := &fakeAgentRunInputLoader{ + input: worker.AgentRunInput{ + Branch: "main", + WorkspacePath: "/repo/worktree", + ExpectedRevision: "before-revision", + }, + } + invoker := &fakeAgentRunInvoker{ + result: worker.AgentRunResult{ + State: worker.AgentRunStateSucceeded, + Message: "done", + }, + } + gitRunner := &fakeGitRunner{ + errs: []error{errors.New("git status failed")}, + } + runner := worker.NewRunnerWithDependencies(cfg, logger, picker, clock, worker.RunnerDependencies{ + OperationStore: opStore, + OperationEventStore: eventStore, + AgentRunInputLoader: loader, + AgentRunInvoker: invoker, + GitRunner: gitRunner, + }) + + err := runner.RunOnce(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if opStore.succeededCalls != 0 || opStore.failedCalls != 1 { + t.Fatalf("expected no succeed and one fail call, got succeed=%d fail=%d", opStore.succeededCalls, opStore.failedCalls) + } + if opStore.operation.State != core.OperationFailed { + t.Fatalf("operation state mismatch: got %q want %q", opStore.operation.State, core.OperationFailed) + } + if len(eventStore.events) != 4 { + t.Fatalf("expected 4 events, got %d", len(eventStore.events)) + } + if eventStore.events[2].Event.Type != events.OperationFailed { + t.Fatalf("completion event type: got %q want %q", eventStore.events[2].Event.Type, events.OperationFailed) + } + completedPayload := decodePayload(t, eventStore.events[2].Event.Payload) + if completedPayload["state"] != string(core.OperationFailed) { + t.Fatalf("completion state mismatch: got %v want %q", completedPayload["state"], core.OperationFailed) + } + if completedPayload["message"] != "git evidence collection failed" { + t.Fatalf("message: got %v want %q", completedPayload["message"], "git evidence collection failed") + } + if _, ok := completedPayload["status_clean"]; ok { + t.Fatalf("did not expect git evidence fields on evidence failure payload: %#v", completedPayload) + } + wantCalls := []fakeGitCall{ + {workdir: "/repo/worktree", args: []string{"status", "--porcelain"}}, + } + if !reflect.DeepEqual(gitRunner.calls, wantCalls) { + t.Fatalf("git calls:\n got %#v\nwant %#v", gitRunner.calls, wantCalls) + } +} + func TestRunnerRunOnceAgentRunFailsWhenBackendFails(t *testing.T) { cfg := config.Config{WorkerEnabled: true} logger := slog.New(slog.NewTextHandler(io.Discard, nil))