feat: runner proto socket transport hardening and related updates

- Add runner-proto-socket-transport-hardening milestone and SDD docs
- Add runnersocket package for Go service
- Update agent config, runner, and job client (Dart)
- Update Bootstrap scripts (PowerShell, shell)
- Update Go service HTTP server handlers and routes
- Add CICD state store updates
- Update agent-ops domain rules and phase roadmap
This commit is contained in:
toki 2026-06-20 18:23:30 +09:00
parent 261aa93407
commit 88c8ff3b07
41 changed files with 3912 additions and 191 deletions

View file

@ -8,7 +8,7 @@ last_rule_updated_at: 2026-06-13
## 목적 / 책임 ## 목적 / 책임
OTO Server에 CLI 기반 runner agent를 등록하고, agent config 파싱, 등록/heartbeat/disconnect, remote job claim, remote pipeline 실행, 실행 결과/로그/artifact 보고, bootstrap 스크립트를 담당한다. OTO Server에 CLI 기반 runner agent를 등록하고, agent config 파싱, proto-socket runner session, remote pipeline 실행, 실행 결과/로그/artifact 보고, bootstrap 스크립트를 담당한다.
파이프라인 실행 엔진이나 scheduler 등록 정책이 아니라 `oto agent run --config <path>` 실행 흐름과 OTO Server runner 세션 초기화가 이 도메인의 책임이다. 파이프라인 실행 엔진이나 scheduler 등록 정책이 아니라 `oto agent run --config <path>` 실행 흐름과 OTO Server runner 세션 초기화가 이 도메인의 책임이다.
## 포함 경로 ## 포함 경로
@ -39,13 +39,14 @@ OTO Server에 CLI 기반 runner agent를 등록하고, agent config 파싱, 등
- `AgentConfigException` — config 파일/필드/타입 오류 표현 - `AgentConfigException` — config 파일/필드/타입 오류 표현
- `AgentRunner` / `DefaultAgentRunner` — OTO Server 등록 workflow, remote job loop, 결과 로그 출력 - `AgentRunner` / `DefaultAgentRunner` — OTO Server 등록 workflow, remote job loop, 결과 로그 출력
- `RegistrationException` — OTO Server 등록 거절 또는 연결 실패 표현 - `RegistrationException` — OTO Server 등록 거절 또는 연결 실패 표현
- `RegistrationClient` / `AgentSession` / `OtoServerJobSession` — 등록 transport와 세션 수명주기 추상화 - `RegistrationClient` / `AgentSession` / `OtoServerPushJobSession` / `OtoServerJobSession` — 등록 transport와 세션 수명주기 추상화. 기본 OTO Server runner 경로는 proto-socket push session이다.
- `OtoServerRegistrationClient` — HTTP 기반 runner register, heartbeat, disconnect 요청 - `OtoServerSocketRegistrationClient` / `OtoServerSocketJobClient` — OTO Server proto-socket 등록, heartbeat, pushed `RunRequest` 수신, execution/log/artifact 보고
- `OtoServerJobClient` — job claim, execution report, log append, artifact report, cancel, status, self-update 요청 - `OtoServerRegistrationClient` / `OtoServerJobClient` — HTTP 기반 legacy/compatibility 경로. bootstrap, UI API smoke, 기존 HTTP 계약 확인에 남기되 기본 production runner loop로 확장하지 않는다.
- `RemoteJobReporter` — remote run 실행 결과를 HTTP/socket transport와 분리해 보고하는 interface
- `RemoteRunExecutor` / `RunRequestParser` / `RemoteRunInput` — OTO Server `RunRequest`를 workspace-confined YAML 실행 입력으로 변환하고 `Application.build()` 결과를 보고 - `RemoteRunExecutor` / `RunRequestParser` / `RemoteRunInput` — OTO Server `RunRequest`를 workspace-confined YAML 실행 입력으로 변환하고 `Application.build()` 결과를 보고
- `RunnerCapabilityProvider` / `RunnerCapabilitySnapshot` — 등록 시 runner capability와 command catalog 요약 제공 계약 - `RunnerCapabilityProvider` / `RunnerCapabilitySnapshot` — 등록 시 runner capability와 command catalog 요약 제공 계약
- `RegistrationResult` — OTO Server/iop response의 accepted/rejected 결과 변환 - `RegistrationResult` — OTO Server/iop response의 accepted/rejected 결과 변환
- `EdgeEndpoint` / `EdgeRegistrationClient` / `_OtoIopClient` — legacy iop Edge compatibility 경로. 기본 runner 경로에서는 `OtoServerRegistrationClient`를 사용 - `EdgeEndpoint` / `EdgeRegistrationClient` / `_OtoIopClient` — legacy iop Edge compatibility 경로. 기본 runner 경로에서는 `OtoServerSocketRegistrationClient`를 사용
- `apps/runner/lib/oto/agent/oto/*.pb*.dart``proto/oto/runner.proto` generated Dart protobuf 코드 - `apps/runner/lib/oto/agent/oto/*.pb*.dart``proto/oto/runner.proto` generated Dart protobuf 코드
- `apps/runner/lib/oto/agent/iop/*.pb*.dart` — legacy `iop/runtime.proto` generated protobuf 코드 - `apps/runner/lib/oto/agent/iop/*.pb*.dart` — legacy `iop/runtime.proto` generated protobuf 코드
- `oto_agent_bootstrap.sh` — agent config 파일 생성, 권한 설정, background 실행 bootstrap - `oto_agent_bootstrap.sh` — agent config 파일 생성, 권한 설정, background 실행 bootstrap
@ -56,15 +57,15 @@ OTO Server에 CLI 기반 runner agent를 등록하고, agent config 파싱, 등
- `--config <path>``--config=<path>` 형식을 모두 유지하고, 누락/알 수 없는 인자는 실행 전에 오류 처리한다. - `--config <path>``--config=<path>` 형식을 모두 유지하고, 누락/알 수 없는 인자는 실행 전에 오류 처리한다.
- `AgentConfig`는 YAML root와 `agent`, `server`(또는 legacy `edge`), `runtime` section이 map인지 확인한 뒤 required string을 검증한다. - `AgentConfig`는 YAML root와 `agent`, `server`(또는 legacy `edge`), `runtime` section이 map인지 확인한 뒤 required string을 검증한다.
- agent config의 required field는 빈 문자열을 거부하고, optional `agent.alias`는 누락 시 `null`, 빈 문자열이면 빈 문자열로 보존한다. - agent config의 required field는 빈 문자열을 거부하고, optional `agent.alias`는 누락 시 `null`, 빈 문자열이면 빈 문자열로 보존한다.
- OTO Server URL은 scheme 없는 host도 허용하고, HTTP endpoint 생성 시 기본 `http://`로 해석한다. - OTO Server URL은 scheme 없는 host도 허용하고, HTTP endpoint 생성 시 기본 `http://`로 해석한다. runner socket URL은 `server.socket_url`을 우선하고, 없으면 server host의 `tcp://<host>:18080`으로 해석한다.
- OTO Server 등록 세션은 `openSession()`에서 열고 세션 소유자가 `finally`에서 `AgentSession.close()`를 호출해 heartbeat timer와 HTTP client를 정리한다. - OTO Server 등록 세션은 `openSession()`에서 열고 세션 소유자가 `finally`에서 `AgentSession.close()`를 호출해 heartbeat timer와 transport를 정리한다.
- 기본 agent loop는 job claim 실패를 "대기할 작업 없음"으로 보고 polling을 계속하고, claim된 `RunRequest` `RemoteRunExecutor.runOnce()`로 실행한다. - 기본 agent loop는 proto-socket으로 pushed `RunRequest`를 받아 `RemoteRunExecutor.runOnce()`로 실행한다. HTTP `jobs/claim` polling loop는 compatibility fallback으로만 유지한다.
- remote YAML path는 `workspaceRoot` 아래로 confinement를 강제하고, inline YAML이 있으면 path보다 우선한다. - remote YAML path는 `workspaceRoot` 아래로 confinement를 강제하고, inline YAML이 있으면 path보다 우선한다.
- remote variables는 YAML `property` map 위에 overlay하되, 기존 `property`가 map이 아니면 원본 YAML을 유지해 `Application.build()` 검증이 실패하게 한다. - remote variables는 YAML `property` map 위에 overlay하되, 기존 `property`가 map이 아니면 원본 YAML을 유지해 `Application.build()` 검증이 실패하게 한다.
- `property.artifacts` 선언은 `name`/`path` map list만 허용하고, artifact path도 workspace confinement를 통과해야 한다. - `property.artifacts` 선언은 `name`/`path` map list만 허용하고, artifact path도 workspace confinement를 통과해야 한다.
- RegisterResponse/JSON 변환은 `RegistrationResult`에 모아 accepted/rejected와 runner id/alias 추출을 한 곳에서 처리한다. - RegisterResponse/JSON 변환은 `RegistrationResult`에 모아 accepted/rejected와 runner id/alias 추출을 한 곳에서 처리한다.
- generated protobuf 파일은 직접 수정하지 않고 원본 proto와 생성 절차를 확인해 재생성한다. - generated protobuf 파일은 직접 수정하지 않고 원본 proto와 생성 절차를 확인해 재생성한다.
- bootstrap 스크립트는 `--server-url`을 기본 인자로 사용하고 legacy `--edge-url`도 server URL로 받는다. - bootstrap 스크립트는 `--server-url`과 optional `--socket-url`을 기본 인자로 사용하고 legacy `--edge-url`도 server URL로 받는다.
- bootstrap 스크립트는 `--release-base-url`에 https만 허용하고, 생성한 config 파일은 `chmod 600`을 유지한다. - bootstrap 스크립트는 `--release-base-url`에 https만 허용하고, 생성한 config 파일은 `chmod 600`을 유지한다.
- legacy iop Edge 경로는 `edge_registration_client.dart` 안에 격리하고, 기본 production runner 경로에서 직접 import하지 않는다. - legacy iop Edge 경로는 `edge_registration_client.dart` 안에 격리하고, 기본 production runner 경로에서 직접 import하지 않는다.
- agent 변경 후 `dart analyze`와 agent 관련 테스트(`apps/runner/test/oto_agent_*`, `apps/runner/test/oto_server_connection_smoke_test.dart`, 필요 시 `apps/runner/test/oto_iop_connection_smoke_test.dart`)를 확인한다. - agent 변경 후 `dart analyze`와 agent 관련 테스트(`apps/runner/test/oto_agent_*`, `apps/runner/test/oto_server_connection_smoke_test.dart`, 필요 시 `apps/runner/test/oto_iop_connection_smoke_test.dart`)를 확인한다.

View file

@ -41,7 +41,7 @@ last_rule_updated_at: 2026-06-13
- `dart_framework/platform/isolate_manager.dart` — CLI 실행/스케줄러 isolate 처리에서 사용 - `dart_framework/platform/isolate_manager.dart` — CLI 실행/스케줄러 isolate 처리에서 사용
- `dart_framework/utils/*``simpleFuture`, path/system/string/os startup 유틸 사용 - `dart_framework/utils/*``simpleFuture`, path/system/string/os startup 유틸 사용
- `dart_framework/log/log.dart``Application.logWithType()`의 로그 타입에 사용 - `dart_framework/log/log.dart``Application.logWithType()`의 로그 타입에 사용
- `proto_socket` — legacy iop Edge compatibility 경로에서 사용하는 workspace 상위 path dependency - `proto_socket`기본 OTO Server runner session과 legacy iop Edge compatibility 경로에서 사용하는 workspace 상위 path dependency
- `protobuf` / `fixnum` — generated protobuf 코드 런타임 의존성 - `protobuf` / `fixnum` — generated protobuf 코드 런타임 의존성
- `resource_importer``apps/runner/lib/resources.resource_importer.dart` 생성 파일과 asset 임베딩 설정 - `resource_importer``apps/runner/lib/resources.resource_importer.dart` 생성 파일과 asset 임베딩 설정
- `proto/oto/runner.proto` — OTO Server runner 등록, heartbeat, bootstrap, job claim, execution/log/artifact report, cancel/status/self-update 계약 - `proto/oto/runner.proto` — OTO Server runner 등록, heartbeat, bootstrap, job claim, execution/log/artifact report, cancel/status/self-update 계약
@ -64,7 +64,7 @@ last_rule_updated_at: 2026-06-13
- **core**: `apps/runner/lib/oto/application.dart`는 OTO 오케스트레이터이며 외부 framework의 Application과 별개다 - **core**: `apps/runner/lib/oto/application.dart`는 OTO 오케스트레이터이며 외부 framework의 Application과 별개다
- **cli/scheduler**: isolate, process, OS startup 유틸을 소비하지만 스케줄러 정책은 scheduler 도메인에 둔다 - **cli/scheduler**: isolate, process, OS startup 유틸을 소비하지만 스케줄러 정책은 scheduler 도메인에 둔다
- **command**: 커맨드들은 `ProcessExecutor`를 소비하지만 커맨드별 비즈니스 로직은 command 도메인에 둔다 - **command**: 커맨드들은 `ProcessExecutor`를 소비하지만 커맨드별 비즈니스 로직은 command 도메인에 둔다
- **agent**: `http`, `proto_socket`, `protobuf`, `fixnum` 의존성은 framework 도메인에서 관리하지만 OTO Server/legacy iop protocol 사용 방식과 agent 정책은 agent 도메인에 둔다 - **agent**: `http`, `proto_socket`, `protobuf`, `fixnum` 의존성은 framework 도메인에서 관리하지만 OTO Server runner socket/HTTP compatibility/legacy iop protocol 사용 방식과 agent 정책은 agent 도메인에 둔다
- **services/core/internal**: Go core service business logic은 아직 dedicated domain rule이 없다. framework 도메인은 `go.mod`, `go.sum`, generated proto, build/test command 경계만 다룬다 - **services/core/internal**: Go core service business logic은 아직 dedicated domain rule이 없다. framework 도메인은 `go.mod`, `go.sum`, generated proto, build/test command 경계만 다룬다
## 금지 사항 ## 금지 사항

View file

@ -27,6 +27,9 @@
- [완료] Control Plane 운영 액션 표면 - [완료] Control Plane 운영 액션 표면
- 경로: `agent-roadmap/archive/phase/control-plane-product-surface/milestones/control-plane-operator-actions.md` - 경로: `agent-roadmap/archive/phase/control-plane-product-surface/milestones/control-plane-operator-actions.md`
- 요약: 기존 OTO Core write endpoint를 UI/adapter action 표면으로 연결하되, 상태 전이와 실패/확인 UX는 SDD로 먼저 잠근다. - 요약: 기존 OTO Core write endpoint를 UI/adapter action 표면으로 연결하되, 상태 전이와 실패/확인 UX는 SDD로 먼저 잠근다.
- [진행중] Runner proto-socket transport hardening
- 경로: `agent-roadmap/phase/control-plane-product-surface/milestones/runner-proto-socket-transport-hardening.md`
- 요약: OTO Server와 runner의 기본 내부 연결을 proto-socket push/session 기준으로 다듬고, HTTP job claim/polling 잔존 경로를 compatibility 경계로 축소한다.
- [스케치] Control Plane 운영 보안·감사 표면 - [스케치] Control Plane 운영 보안·감사 표면
- 경로: `agent-roadmap/phase/control-plane-product-surface/milestones/control-plane-security-audit-surface.md` - 경로: `agent-roadmap/phase/control-plane-product-surface/milestones/control-plane-security-audit-surface.md`
- 요약: write action 표면 이후 필요한 인증/권한, 감사 로그, 민감 정보 마스킹, UI 노출 경계를 SDD와 구현 가능한 Task로 구체화한다. - 요약: write action 표면 이후 필요한 인증/권한, 감사 로그, 민감 정보 마스킹, UI 노출 경계를 SDD와 구현 가능한 Task로 구체화한다.

View file

@ -0,0 +1,73 @@
# Milestone: Runner proto-socket transport hardening
## 위치
- Roadmap: `agent-roadmap/ROADMAP.md`
- Phase: `agent-roadmap/phase/control-plane-product-surface/PHASE.md`
## 목표
OTO Server와 runner 사이의 기본 내부 연결을 proto-socket 장기 세션으로 고정하고, 급하게 붙인 HTTP job claim/polling 잔존 경로를 compatibility 경계로 격리한다.
완료 후 bootstrap으로 연결된 runner는 register, heartbeat, pushed RunRequest, execution/log/artifact report를 socket 기준으로 검증하며, cancel/status/self-update의 방향성과 실패 처리가 문서와 테스트에서 같은 계약을 바라봐야 한다.
## 상태
[진행중]
## 구현 잠금
- 상태: 해제
- SDD: 필요
- SDD 문서: `agent-roadmap/sdd/control-plane-product-surface/runner-proto-socket-transport-hardening/SDD.md`
- SDD 사유: server-runner 장기 연결, lifecycle/state machine, cancel/self-update 방향성, HTTP compatibility 축소는 API/proto/config/env와 field smoke에 영향을 주므로 구현 전에 계약을 고정해야 한다.
- 잠금 해제 조건:
- [x] SDD 상태가 `[승인됨]`이고 SDD 잠금이 해제되어 있다.
- [x] SDD `USER_REVIEW.md`가 없거나 승인/해결되었다.
- [x] Acceptance Scenario가 아래 기능 Task와 연결되어 있다.
- [x] Evidence Map이 plan의 `Spec Targets`와 완료 시 `Spec Completion`으로 검증 가능하게 연결되어 있다.
- 결정 필요:
- 없음. 기본 방향은 `proto-socket` push/session을 production runner transport로 두고, HTTP runner job claim/polling은 compatibility 경계로 축소한다.
## 범위
- OTO Server runner socket listener와 Dart runner socket client/session의 register, heartbeat, run dispatch, report, disconnect lifecycle을 정리한다.
- HTTP job claim/report/log/artifact 경로는 public operator API와 runner transport compatibility를 분리해 문서, 명명, 테스트에서 기본 경로로 보이지 않게 한다.
- cancel/status/self-update가 socket push/session과 HTTP operator action 사이에서 어떤 방향으로 흐르는지 고정한다.
- bootstrap/config/env에서 `server.url``server.socket_url`의 책임을 분리하고 기본 포트/공개 URL 계산을 검증한다.
## 기능
### Epic: [socket-transport] Runner socket transport 정리
기본 runner transport를 proto-socket으로 고정하고, HTTP polling 잔재를 compatibility 경계로 격리한다.
- [x] [socket-contract] register, heartbeat, RunRequest push, execution/log/artifact report의 server-runner 방향성과 메시지 계약을 SDD와 domain rule에 고정한다. 검증: SDD Acceptance Scenario와 domain rule이 HTTP polling을 기본 경로로 설명하지 않는다.
- [ ] [session-lifecycle] runner id reconciliation, duplicate connection close, disconnect/timeout, reconnect 후보, heartbeat 실패 처리의 socket session lifecycle을 정리한다. 검증: Go/Dart unit 또는 smoke가 server-assigned runner id와 disconnect 상태 전이를 확인한다.
- [ ] [dispatch-state] queued job dispatch, invalid RunRequest 실패 처리, active execution gate, terminal state 전이를 socket dispatch 기준으로 정리한다. 검증: socket dispatch 테스트가 queued -> running -> terminal 및 invalid input -> failed를 확인한다.
- [ ] [runner-actions] cancel/status/self-update의 operator HTTP action과 runner socket message 방향을 정리하고 구현 또는 명시적 범위 제외로 고정한다. 검증: cancel이 runner 실행과 store state 사이에서 모순된 terminal report를 만들지 않는다.
- [ ] [compat-boundary] HTTP runner job claim/report/log/artifact 경로를 compatibility로 격리하고 기본 agent loop와 기본 smoke에서 제거한다. 검증: `OtoServerSocketRegistrationClient`가 기본이고 HTTP polling 테스트는 compatibility 이름/그룹으로만 남는다.
- [ ] [socket-smoke] local/remote smoke 기준을 proto-socket 연결 중심으로 갱신한다. 검증: `cd services/core && go test ./...`, `cd apps/runner && dart analyze`, agent socket smoke test가 통과한다.
## 완료 리뷰
- 상태: 없음
- 요청일: 없음
- 완료 근거: 없음
- 검토 항목: 없음
- 리뷰 코멘트: 없음
## 범위 제외
- iop Edge 직접 연결 경로를 복원하지 않는다.
- production-grade 인증/권한/감사 로그는 `Control Plane 운영 보안·감사 표면` Milestone에서 다룬다.
- durable artifact storage와 장기 job scheduler policy는 이 Milestone에서 완성하지 않는다.
- HTTP public operator API 전체를 제거하지 않는다. 제거/축소 대상은 runner 내부 transport로 쓰이던 HTTP job claim/polling 경로다.
## 작업 컨텍스트
- 관련 경로: `services/core/internal/runnersocket/**`, `services/core/internal/httpserver/**`, `services/core/cmd/oto-core/**`, `apps/runner/lib/oto/agent/**`, `apps/runner/assets/script/**/oto_agent_bootstrap.*`, `proto/oto/runner.proto`, `agent-test/local/agent-smoke.md`
- 표준선(선택): server/operator-facing HTTP API는 유지하고, runner-facing 내부 실행 transport는 proto-socket으로 둔다.
- 표준선(선택): HTTP runner claim/report 코드는 삭제 전 compatibility fallback으로 남기더라도 기본 agent loop, bootstrap, smoke 기준에서는 제외한다.
- 선행 작업: `Runner bootstrap, OTO binary, 노드 연결`, `Control Plane API 데이터 바인딩`, `Control Plane 운영 액션 표면`
- 후속 작업: 운영 보안·감사 표면, durable persistence, 운영 대시보드 고도화
- 확인 완료: SDD에서 cancel/self-update의 즉시 구현 범위와 compatibility endpoint 제거/유지 기준을 검증 가능한 acceptance로 고정했다.

View file

@ -0,0 +1,116 @@
# SDD: Runner proto-socket transport hardening
## 위치
- Milestone: `agent-roadmap/phase/control-plane-product-surface/milestones/runner-proto-socket-transport-hardening.md`
- Phase: `agent-roadmap/phase/control-plane-product-surface/PHASE.md`
## 상태
[승인됨]
## SDD 잠금
- 상태: 해제
- 사용자 리뷰: 없음
- 잠금 항목:
- 없음
## 문제 / 비목표
- 문제: OTO runner transport가 proto-socket push/session과 HTTP claim/polling compatibility 경로를 함께 갖고 있어, 기본 production runner loop, 상태 전이, cancel/status/self-update 방향, smoke evidence의 source of truth가 흐려질 수 있다.
- 비목표:
- HTTP public operator API 전체 제거
- production-grade 인증/권한/감사 로그
- durable artifact storage와 장기 scheduler policy
- iop Edge 직접 연결 경로 복원
## Source of Truth
| 영역 | 기준 | 메모 |
|------|------|------|
| Roadmap | `agent-roadmap/phase/control-plane-product-surface/milestones/runner-proto-socket-transport-hardening.md` | Milestone 기능 Task와 완료 반영 기준 |
| Code | `services/core/internal/runnersocket/server.go` | server-runner proto-socket register, heartbeat, RunRequest dispatch, report, cancel push 기준 |
| Code | `apps/runner/lib/oto/agent/registration_client.dart` | Dart runner socket session, heartbeat, pushed RunRequest 수신 기준 |
| Code | `apps/runner/lib/oto/agent/agent_runner.dart` | 기본 agent loop가 socket push session을 우선 소비하는 기준 |
| Code | `services/core/internal/httpserver/routes.go` | operator-facing HTTP API와 compatibility runner HTTP endpoint 경계 |
| Code | `proto/oto/runner.proto` | wire message 이름과 필드 계약 |
| Domain Rule | `agent-ops/rules/project/domain/agent/rules.md` | 기본 runner 경로는 proto-socket이고 HTTP polling은 compatibility fallback이라는 정책 기준 |
| External Provider | 없음 | 이 Milestone은 외부 provider write를 추가하지 않는다. |
| User Decision | 없음 | 기본 방향과 범위는 Milestone 및 domain rule로 결정되어 있다. |
## State Machine
| 상태 | 진입 조건 | 다음 상태 | 근거 |
|------|-----------|-----------|------|
| runner.accepted | `RegisterRunnerRequest`가 enrollment token, runner id, protocol version, capability 검증을 통과한다. | runner.online, runner.disconnected, runner.heartbeat_timeout | `runnerregistry.Register`, `runnersocket.RegisterRunnerRequest` handler |
| runner.online | socket session에서 register 직후 또는 heartbeat가 성공한다. | runner.disconnected, runner.heartbeat_timeout | `runnerregistry.Heartbeat`, socket disconnect listener, timeout loop |
| runner.disconnected | 현재 socket client가 닫히고 server가 해당 client를 현재 runner client로 보고 있다. | runner.accepted | duplicate/reconnect는 re-register를 통해 새 accepted record를 만든다. |
| job.queued | operator HTTP API가 `run_request`를 포함한 job을 생성한다. | job.running, job.failed, job.canceled | `cicdstate.Store`, socket dispatcher |
| job.running | socket dispatcher가 queued job을 claim하고 execution을 생성한 뒤 `RunRequest`를 push한다. | job.succeeded, job.failed, job.canceled | `runnersocket.DispatchQueuedJobs`, `handleExecutionReport`, `CancelJobExecution` |
| execution.running | job dispatch가 runner id를 execution owner로 고정하고 `RunRequest`를 runner에 보낸다. | execution.succeeded, execution.failed, execution.canceled | `cicdstate.Store`, runner report/cancel handlers |
| execution.canceled | operator HTTP cancel action이 store를 canceled로 전환하고 연결된 runner socket에 best-effort cancel message를 보낸다. | terminal | terminal report는 store transition guard와 report handler에서 모순 없이 거부/기록되어야 한다. |
## Interface Contract
- 계약 원문: 없음. Wire source는 `proto/oto/runner.proto`이며 SDD에는 원문을 복제하지 않는다.
- 입력:
- `RegisterRunnerRequest`: runner identity, enrollment token, protocol version, capability, command catalog.
- `HeartbeatRequest`: runner liveness와 health status.
- `RunRequest`: server가 socket으로 runner에 push하는 job/execution 실행 입력. `pipeline_yaml` 또는 `pipeline_yaml_path` 중 정확히 하나만 유효하다.
- `ExecutionReportRequest`: runner가 socket으로 보고하는 terminal execution 결과.
- `LogAppendRequest` / `ArtifactReportRequest`: runner가 socket으로 보고하는 execution log/artifact metadata.
- `CancelRunRequest`: operator HTTP cancel action 이후 server가 연결된 runner socket에 best-effort로 전달하는 cancel signal.
- `RunnerStatusRequest` / `SelfUpdateRequest`: proto에는 남아 있지만 이 Milestone에서는 status source와 self-update 실행 범위를 명시적으로 고정한 뒤 구현 또는 범위 제외한다.
- 출력:
- register/heartbeat/report/log/artifact response는 accepted/success 여부와 structured error를 반환한다.
- operator-facing HTTP status/self-update response는 UI/operator가 읽는 상태와 deferral 결과를 반환한다.
- 금지:
- 기본 agent loop를 HTTP `jobs/claim` polling 확장 대상으로 삼지 않는다.
- HTTP runner job claim/report/log/artifact 경로를 기본 smoke나 production runner transport로 설명하지 않는다.
- terminal execution/job 상태를 cancel 이후 runner report로 되돌리지 않는다.
- generated protobuf 파일을 수동 수정하지 않는다.
## Acceptance Scenarios
| ID | Milestone Task | Given | When | Then |
|----|----------------|-------|------|------|
| S01 | `socket-contract` | Milestone과 agent domain rule이 proto-socket 기본 경로를 선언한다. | SDD와 domain rule을 확인한다. | SDD Acceptance Scenario와 domain rule 모두 HTTP polling을 기본 runner path로 설명하지 않는다. |
| S02 | `session-lifecycle` | 같은 runner id가 재연결하거나 heartbeat/disconnect가 발생한다. | socket server가 register, heartbeat, duplicate client close, disconnect/timeout을 처리한다. | server-assigned runner id reconciliation과 disconnect 상태 전이가 Go/Dart unit 또는 smoke에서 검증된다. |
| S03 | `dispatch-state` | queued job과 invalid `RunRequest` 후보가 있다. | socket dispatcher가 idle runner에 `RunRequest`를 push한다. | queued -> running -> terminal, invalid input -> failed, active execution gate가 socket dispatch 테스트로 검증된다. |
| S04 | `runner-actions` | operator가 cancel/status/self-update HTTP action을 호출한다. | server가 store, registry, runner socket message 방향을 적용한다. | cancel은 runner 실행과 store state 사이에 모순된 terminal report를 만들지 않고, status/self-update 범위가 구현 또는 명시적 범위 제외로 고정된다. |
| S05 | `compat-boundary` | HTTP runner claim/report/log/artifact compatibility 코드가 남아 있다. | 기본 agent loop, smoke, 테스트 이름/그룹을 정리한다. | `OtoServerSocketRegistrationClient`가 기본이고 HTTP polling 테스트는 compatibility 이름/그룹으로만 남는다. |
| S06 | `socket-smoke` | local/remote smoke 기준이 runner transport 완료 근거로 쓰인다. | agent smoke 문서와 테스트가 socket 연결 중심 evidence를 요구한다. | `cd services/core && go test ./...`, `cd apps/runner && dart analyze`, agent socket smoke test가 통과한다. |
## Evidence Map
| Scenario | Required Evidence | `agent-task` 연결 | `Spec Completion` 기대 |
|----------|-------------------|------------------|---------------------------|
| S01 | `agent-roadmap/sdd/.../SDD.md``agent-ops/rules/project/domain/agent/rules.md`가 proto-socket 기본 경로와 HTTP compatibility 경계를 같은 방향으로 설명한다. | roadmap-only update | `socket-contract`: SDD와 domain rule에서 HTTP polling이 기본 경로가 아님을 확인 |
| S02 | Go/Dart unit 또는 smoke가 server-assigned runner id, duplicate connection close, disconnect/timeout, heartbeat failure를 검증한다. | `agent-task/m-runner-proto-socket-transport-hardening/01_socket_lifecycle` | `session-lifecycle`: lifecycle 상태 전이와 재연결 기준 검증 |
| S03 | socket dispatch 테스트가 queued -> running -> terminal, invalid input -> failed, active execution gate를 검증한다. | `agent-task/m-runner-proto-socket-transport-hardening/02+01_dispatch_state` | `dispatch-state`: socket dispatch 상태 전이 검증 |
| S04 | cancel/status/self-update 테스트가 operator HTTP action과 socket message/store state 방향을 검증하거나 self-update 실행 범위 제외를 명시한다. | `agent-task/m-runner-proto-socket-transport-hardening/03+01,02_runner_actions` | `runner-actions`: cancel terminal report 모순 방지와 action 범위 고정 |
| S05 | search/test evidence가 기본 agent loop와 기본 smoke에서 HTTP polling claim/report를 제거하고 compatibility 그룹으로만 남겼음을 보여준다. | `agent-task/m-runner-proto-socket-transport-hardening/04+01_compat_boundary` | `compat-boundary`: 기본 runner transport와 compatibility test naming 분리 |
| S06 | agent socket smoke와 local test rule/profile evidence가 socket-centered smoke 기준을 요구하고 통과한다. | `agent-task/m-runner-proto-socket-transport-hardening/05+02,03,04_socket_smoke` | `socket-smoke`: Go test, Dart analyze, agent socket smoke 통과 |
## Cross-repo Dependencies
- 없음
## Drift Check
- [x] Milestone 기능 Task와 Acceptance Scenario가 일치한다.
- [x] Evidence Map이 plan/code-review/complete.log에서 검증 가능하다.
- [x] agent-contract를 쓰는 경우 SDD에 계약 원문을 복제하지 않았다.
- [x] 사용자 리뷰가 필요한 항목은 `USER_REVIEW.md`에만 남겼다.
## 사용자 리뷰 이력
- 없음
## 작업 컨텍스트
- 표준선: server/operator-facing HTTP API는 유지하고, runner-facing 내부 실행 transport는 proto-socket push/session으로 둔다.
- 표준선: HTTP runner claim/report/log/artifact 경로는 삭제 전 compatibility fallback으로만 남기며, 기본 agent loop와 기본 smoke evidence에서는 제외한다.
- 표준선: cancel은 operator HTTP action에서 store를 terminal canceled로 바꾸고 연결된 socket runner에 best-effort signal을 보낸다. runner-side cooperative stop이 부족하면 그 구현을 `runner-actions` plan에서 다루고, self-update의 실제 binary replacement는 범위 제외 또는 후속 Milestone으로 고정한다.
- 후속 SDD: 없음

View file

@ -0,0 +1,172 @@
<!-- task=m-runner-proto-socket-transport-hardening/01_socket_lifecycle plan=0 tag=API -->
# Code Review Reference - API
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a selected SDD decision or selected Milestone `구현 잠금 > 결정 필요` item, fill `사용자 리뷰 요청` with linked evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Environment/secret/service blockers, generic scope changes, repeated failures, and evidence gaps that a follow-up agent can close 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 only SDD/Milestone lock decisions 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-20
task=m-runner-proto-socket-transport-hardening/01_socket_lifecycle, plan=0, tag=API
## Roadmap Targets
- Milestone: `agent-roadmap/phase/control-plane-product-surface/milestones/runner-proto-socket-transport-hardening.md`
- Task ids:
- `session-lifecycle`: runner id reconciliation, duplicate connection close, disconnect/timeout, reconnect 후보, heartbeat 실패 처리의 socket session lifecycle을 정리한다.
- Completion mode: check-on-pass
## Spec Targets
- SDD: `agent-roadmap/sdd/control-plane-product-surface/runner-proto-socket-transport-hardening/SDD.md`
- Acceptance scenarios:
- `S02`: task=`session-lifecycle`; evidence=`Go/Dart unit 또는 smoke가 server-assigned runner id, duplicate connection close, disconnect/timeout, heartbeat failure를 검증한다.`
- Completion mode: spec-check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[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-runner-proto-socket-transport-hardening/01_socket_lifecycle/`로 이동한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [API-1] 서버 socket lifecycle 테스트와 보강 | [x] |
| [API-2] Dart socket session heartbeat/close 테스트와 보강 | [x] |
## 구현 체크리스트
- [x] [API-1] 서버 socket lifecycle 테스트와 필요한 보강을 구현한다. 검증: `cd services/core && go test -count=1 ./...`
- [x] [API-2] Dart socket session heartbeat/close behavior 테스트와 필요한 보강을 구현한다. 검증: `cd apps/runner && dart test test/oto_agent_registration_test.dart test/oto_server_connection_smoke_test.dart`
- [x] 전체 agent 검증을 실행한다. 검증: `cd apps/runner && dart analyze`
- [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-runner-proto-socket-transport-hardening/01_socket_lifecycle/`를 `agent-task/archive/YYYY/MM/m-runner-proto-socket-transport-hardening/01_socket_lifecycle/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-runner-proto-socket-transport-hardening/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active plan/review 파일 또는 follow-up plan을 작성하고 `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`를 남기지 않는다.
## 계획 대비 변경 사항
1. **`removeClient` bool 반환값 추가**: 계획은 stale disconnect를 guard하도록 요구했다. 기존 `removeClient`는 void였으나 `bool` 반환으로 변경해 disconnect listener에서 `registry.Disconnect`를 조건부 호출하도록 보강했다.
2. **`_runRequests.close()` await 제거**: `_OtoServerSocketAgentSession.close()`에서 `await _runRequests.close()`가 구독자 없을 때 무기한 대기하는 Dart single-subscription StreamController 특성 때문에 hang이 발생했다. `unawaited(_runRequests.close())`로 변경해 구독자 유무와 무관하게 close()가 완료되도록 했다.
3. **두 번째 Dart 소켓 테스트 추가**: PLAN은 한 개의 테스트를 제안했으나, server disconnect 후 idempotent close 시나리오를 추가 테스트로 분리해 각각의 검증 범위를 명확히 했다.
## 주요 설계 결정
1. **`removeClient(runnerID, client) → bool` 패턴**: stale disconnect 보호를 pointer 비교로 구현했다. 같은 runnerID라도 다른 `*protoSocket.TcpClient` 인스턴스이면 false 반환 → `registry.Disconnect` 생략. 이렇게 해야 reconnect 중 old client의 disconnect event가 new session을 덮지 않는다.
2. **`net.Pipe()` 기반 Go unit test**: real TCP server 없이 `net.Pipe()`로 TcpClient를 생성해 `setClient/removeClient/connectedRunnerIDs` 등 server의 내부 메서드를 같은 package(runnersocket)에서 직접 테스트했다. heartbeat를 비활성(intervalSec=0)으로 설정해 배경 타이머 간섭을 차단했다.
3. **`_startCoreWithSocket` helper**: Dart 소켓 테스트는 real Go server가 필요하므로 OTO_RUNNER_SOCKET_ADDR 환경변수로 proto-socket 포트를 별도 할당하는 helper를 추가했다. HTTP smoke 테스트의 기존 `_waitForPort` 패턴을 재사용해 server ready 확인을 일관되게 처리했다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 SDD 결정 또는 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- stale socket disconnect가 current runner session을 disconnected로 덮지 않는지 확인한다.
- heartbeat failure/close 테스트가 timer leak 없이 끝나는지 확인한다.
- Roadmap Targets와 Spec Targets가 실제 구현 evidence와 맞는지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
### API-1 중간 검증
```bash
$ cd services/core && go test -count=1 ./...
? github.com/toki/oto/services/core/cmd/oto-core [no test files]
ok github.com/toki/oto/services/core/internal/cicdstate 0.002s
ok github.com/toki/oto/services/core/internal/httpserver 0.100s
ok github.com/toki/oto/services/core/internal/runnerregistry 0.005s
ok github.com/toki/oto/services/core/internal/runnersocket 0.029s
? github.com/toki/oto/services/core/oto [no test files]
```
### API-2 중간 검증
```bash
$ cd apps/runner && dart test test/oto_agent_registration_test.dart test/oto_server_connection_smoke_test.dart
00:07 +50: All tests passed!
```
### 최종 검증
```bash
$ cd services/core && go test -count=1 ./...
? github.com/toki/oto/services/core/cmd/oto-core [no test files]
ok github.com/toki/oto/services/core/internal/cicdstate 0.002s
ok github.com/toki/oto/services/core/internal/httpserver 0.100s
ok github.com/toki/oto/services/core/internal/runnerregistry 0.005s
ok github.com/toki/oto/services/core/internal/runnersocket 0.029s
? github.com/toki/oto/services/core/oto [no test files]
$ cd apps/runner && dart test test/oto_agent_registration_test.dart test/oto_server_connection_smoke_test.dart
00:07 +50: All tests passed!
$ cd apps/runner && dart analyze
Analyzing runner...
No issues found!
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.
## 코드리뷰 결과
- 종합 판정: FAIL
- 차원별 평가:
- correctness: Fail
- completeness: Fail
- test coverage: Fail
- API contract: Fail
- code quality: Pass
- plan deviation: Pass
- verification trust: Fail
- spec conformance: Fail
- 발견된 문제:
- Required: `services/core/internal/runnersocket/server.go:350`의 `setClient`가 `s.mu`를 잡은 상태에서 old `TcpClient.Close()`를 호출합니다. production client에는 `attachClientHandlers`가 등록한 disconnect listener가 있고, proto-socket `Close()`는 `notifyDisconnected()`에서 listener를 동기 호출합니다. 따라서 duplicate registration에서 old client close -> listener -> `removeClient()` -> 같은 `s.mu` 재획득 경로로 교착될 수 있습니다. 수정은 새 client를 map에 저장하고 lock을 푼 뒤 old client를 close하도록 바꾸며, `attachClientHandlers`가 붙은 client 2개로 duplicate replacement가 완료되는 단위 테스트를 추가해야 합니다.
- 다음 단계:
- FAIL: user-review gate는 트리거하지 않고, 위 Required를 해결하는 후속 `PLAN-local-G06.md` / `CODE_REVIEW-local-G06.md`를 작성한다.

View file

@ -0,0 +1,182 @@
<!-- task=m-runner-proto-socket-transport-hardening/01_socket_lifecycle plan=1 tag=REVIEW_API -->
# Code Review Reference - REVIEW_API
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a selected SDD decision or selected Milestone `구현 잠금 > 결정 필요` item, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Environment/secret/service setup, generic scope conflicts, loop exhaustion, and evidence gaps that a follow-up agent can close 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 only the linked SDD/Milestone lock 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-20
task=m-runner-proto-socket-transport-hardening/01_socket_lifecycle, plan=1, tag=REVIEW_API
## Roadmap Targets
- Milestone: `agent-roadmap/phase/control-plane-product-surface/milestones/runner-proto-socket-transport-hardening.md`
- Task ids:
- `session-lifecycle`: runner id reconciliation, duplicate connection close, disconnect/timeout, reconnect 후보, heartbeat 실패 처리의 socket session lifecycle을 정리한다.
- Completion mode: check-on-pass
## Spec Targets
- SDD: `agent-roadmap/sdd/control-plane-product-surface/runner-proto-socket-transport-hardening/SDD.md`
- Acceptance scenarios:
- `S02`: task=`session-lifecycle`; evidence=`Go/Dart unit 또는 smoke가 server-assigned runner id, duplicate connection close, disconnect/timeout, heartbeat failure를 검증한다.`
- Completion mode: spec-check-on-pass
## Archive Evidence Snapshot
- Current archived plan: `agent-task/m-runner-proto-socket-transport-hardening/01_socket_lifecycle/plan_cloud_G07_0.log`
- Current archived review: `agent-task/m-runner-proto-socket-transport-hardening/01_socket_lifecycle/code_review_cloud_G07_0.log`
- Verdict: FAIL
- Required summary:
- `services/core/internal/runnersocket/server.go:350`의 `setClient`가 `s.mu`를 잡은 상태에서 old `TcpClient.Close()`를 호출한다. production client에는 `attachClientHandlers`의 disconnect listener가 붙어 있고, proto-socket `Close()`는 listener를 동기 호출하므로 duplicate registration에서 `setClient` -> `old.Close()` -> listener -> `removeClient()` -> 같은 `s.mu` 재획득 경로로 교착될 수 있다.
- Affected files:
- `services/core/internal/runnersocket/server.go`
- `services/core/internal/runnersocket/server_test.go`
- Prior verification evidence:
- `cd services/core && go test -count=1 ./...` 통과
- `cd apps/runner && dart test test/oto_agent_registration_test.dart test/oto_server_connection_smoke_test.dart` 통과
- `cd apps/runner && dart analyze` 통과
- Verification trust note:
- 기존 Go test는 listener가 없는 `pipeClient`만 `setClient`에 넣어 production disconnect listener 재진입을 검증하지 못했다.
- Roadmap/spec carryover:
- Roadmap Task `session-lifecycle` 및 SDD `S02`를 계속 완료 대상으로 유지한다.
- Allowed narrow reread:
- 필요하면 위 current archived plan/review log만 좁게 다시 읽는다. `agent-task/archive/**`는 읽지 않는다.
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-local-G06.md` -> `code_review_local_G06_N.log`, `PLAN-local-G06.md` -> `plan_local_G06_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-runner-proto-socket-transport-hardening/01_socket_lifecycle/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_API-1] duplicate socket replacement deadlock 제거 | [x] |
## 구현 체크리스트
- [x] [REVIEW_API-1] duplicate socket replacement deadlock을 제거하고 production listener 경로 테스트를 추가한다. 검증: `cd services/core && go test -count=1 ./internal/runnersocket`
- [x] 전체 Go 회귀를 실행한다. 검증: `cd services/core && go test -count=1 ./...`
- [x] 전체 agent 검증을 재실행한다. 검증: `cd apps/runner && dart test test/oto_agent_registration_test.dart test/oto_server_connection_smoke_test.dart` 및 `cd apps/runner && dart analyze`
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G06_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_local_G06_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [x] PASS이면 active task 디렉터리 `agent-task/m-runner-proto-socket-transport-hardening/01_socket_lifecycle/`를 `agent-task/archive/YYYY/MM/m-runner-proto-socket-transport-hardening/01_socket_lifecycle/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-runner-proto-socket-transport-hardening/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-{build_lane}-GNN.md`와 `CODE_REVIEW-{review_lane}-GNN.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가 연결된 SDD/Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
없음. PLAN의 방향 예시 그대로 `setClient`의 lock 해제 후 Close 구조를 채택하고, production listener를 가진 client로 재진입 테스트를 추가했다.
## 주요 설계 결정
1. **lock 해제 후 Close 순서**: `s.mu.Unlock()` 직후 `old.Close()` 호출. new client가 이미 map에 있으므로 old listener가 실행될 때 `removeClient("runner-E", old)`는 map 포인터 불일치로 false를 반환해 `registry.Disconnect`를 건너뛴다. 기존 stale disconnect guard와 결합해 safe.
2. **테스트 방식**: Go test의 기본 `-timeout` (30s)으로 deadlock을 감지한다. 명시적 `time.After` 타임아웃 없이 `setClient` 호출 완료 후 상태를 assert했다. listener가 재진입하면 `setClient` 호출 자체가 blocking해 테스트가 자연히 timeout된다. PLAN이 "타임아웃으로 감지하지 않고 positive assert"를 요구했으므로 이 방식을 채택했다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 SDD 결정 또는 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- `setClient`가 `s.mu`를 잡은 채 `TcpClient.Close()`를 호출하지 않는지 확인한다.
- duplicate replacement 테스트가 production disconnect listener 재진입 경로를 실제로 포함하는지 확인한다.
- old client disconnect listener가 current client와 registry online 상태를 덮지 않는지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
### REVIEW_API-1 중간 검증
```bash
$ cd services/core && go test -count=1 -timeout=30s ./internal/runnersocket
ok github.com/toki/oto/services/core/internal/runnersocket 0.047s
```
### 최종 검증
```bash
$ cd services/core && go test -count=1 ./...
? github.com/toki/oto/services/core/cmd/oto-core [no test files]
ok github.com/toki/oto/services/core/internal/cicdstate 0.002s
ok github.com/toki/oto/services/core/internal/httpserver 0.068s
ok github.com/toki/oto/services/core/internal/runnerregistry 0.004s
ok github.com/toki/oto/services/core/internal/runnersocket 0.053s
? github.com/toki/oto/services/core/oto [no test files]
$ cd apps/runner && dart test test/oto_agent_registration_test.dart test/oto_server_connection_smoke_test.dart
00:12 +50: All tests passed!
$ cd apps/runner && dart analyze
Analyzing runner...
No issues found!
```
---
> **[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
- spec conformance: Pass
- 발견된 문제: 없음
- 다음 단계:
- PASS: `complete.log`를 작성하고 task directory를 archive로 이동한다.

View file

@ -0,0 +1,51 @@
# Complete - m-runner-proto-socket-transport-hardening/01_socket_lifecycle
## 완료 일시
2026-06-20
## 요약
Runner socket session lifecycle 보강을 2회 리뷰 루프로 완료했다. 최종 판정은 PASS다.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_cloud_G07_0.log` | `code_review_cloud_G07_0.log` | FAIL | duplicate runner client replacement에서 old client close가 server mutex 재진입 deadlock을 만들 수 있어 follow-up 필요 |
| `plan_local_G06_1.log` | `code_review_local_G06_1.log` | PASS | old client close를 mutex 밖으로 이동하고 production-style disconnect listener 재진입 테스트로 고정 |
## 구현/정리 내용
- `services/core/internal/runnersocket.Server.setClient`가 새 client를 map에 저장하고 lock을 해제한 뒤 old client를 닫도록 정리했다.
- duplicate replacement 중 old client disconnect listener가 `removeClient`로 재진입해도 current client와 registry 상태를 덮지 않는 테스트를 추가했다.
- Go/Dart socket session lifecycle 검증으로 SDD `S02` evidence를 충족했다.
## 최종 검증
- `cd services/core && go test -count=1 ./internal/runnersocket` - PASS; `ok github.com/toki/oto/services/core/internal/runnersocket 0.048s`
- `cd services/core && go test -count=1 ./...` - PASS; core packages all passed, including `internal/runnersocket`
- `cd apps/runner && dart test test/oto_agent_registration_test.dart test/oto_server_connection_smoke_test.dart` - PASS; `00:07 +50: All tests passed!`
- `cd apps/runner && dart analyze` - PASS; `No issues found!`
## Roadmap Completion
- Milestone: `agent-roadmap/phase/control-plane-product-surface/milestones/runner-proto-socket-transport-hardening.md`
- Completed task ids:
- `session-lifecycle`: PASS; evidence=`agent-task/archive/2026/06/m-runner-proto-socket-transport-hardening/01_socket_lifecycle/plan_local_G06_1.log`, `agent-task/archive/2026/06/m-runner-proto-socket-transport-hardening/01_socket_lifecycle/code_review_local_G06_1.log`; verification=`cd services/core && go test -count=1 ./internal/runnersocket`, `cd services/core && go test -count=1 ./...`, `cd apps/runner && dart test test/oto_agent_registration_test.dart test/oto_server_connection_smoke_test.dart`, `cd apps/runner && dart analyze`
- Not completed task ids: 없음
## Spec Completion
- SDD: `agent-roadmap/sdd/control-plane-product-surface/runner-proto-socket-transport-hardening/SDD.md`
- Completed scenario ids:
- `S02`: PASS; task=`session-lifecycle`; evidence=`agent-task/archive/2026/06/m-runner-proto-socket-transport-hardening/01_socket_lifecycle/plan_local_G06_1.log`, `agent-task/archive/2026/06/m-runner-proto-socket-transport-hardening/01_socket_lifecycle/code_review_local_G06_1.log`; verification=`cd services/core && go test -count=1 ./internal/runnersocket`, `cd services/core && go test -count=1 ./...`, `cd apps/runner && dart test test/oto_agent_registration_test.dart test/oto_server_connection_smoke_test.dart`, `cd apps/runner && dart analyze`
- Not completed scenario ids: 없음
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -0,0 +1,187 @@
<!-- task=m-runner-proto-socket-transport-hardening/01_socket_lifecycle plan=0 tag=API -->
# Plan - API
## 이 파일을 읽는 구현 에이전트에게
`CODE_REVIEW-cloud-G07.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우는 것까지가 구현입니다. 검증을 실행하고 실제 stdout/stderr를 붙인 뒤 active 파일을 그대로 두고 review ready로 보고하세요. 선택된 SDD 결정 또는 Milestone `구현 잠금 > 결정 필요` 항목이 막으면 review stub의 `사용자 리뷰 요청` 섹션을 채우고 멈추며, 직접 사용자에게 질문하거나 `USER_REVIEW.md`, `complete.log`, archive를 만들지 않습니다. 환경/secret/서비스 문제나 증거 공백은 일반 follow-up 대상으로 기록합니다.
## 배경
현 Milestone은 기본 runner transport를 proto-socket 장기 세션으로 고정한다. 서버는 register, heartbeat, duplicate client close, disconnect listener를 갖고 있지만 `runnersocket.Server` 자체 lifecycle 테스트가 없다. 이 작업은 후속 dispatch/action/smoke 작업이 의존할 runner id reconciliation과 disconnect/timeout 기준을 먼저 안정화한다.
## 사용자 리뷰 요청 흐름
사용자 리뷰 요청은 선택된 SDD 결정 또는 선택된 Milestone lock 결정이 실구현을 차단할 때만 active `CODE_REVIEW-cloud-G07.md`의 `사용자 리뷰 요청` 섹션에 기록한다. 구현 중 직접 사용자 프롬프트, 채팅 선택지, `request_user_input`, `USER_REVIEW.md` 생성은 금지이며, code-review가 검증과 파일 생성을 소유한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/control-plane-product-surface/milestones/runner-proto-socket-transport-hardening.md`
- Task ids:
- `session-lifecycle`: runner id reconciliation, duplicate connection close, disconnect/timeout, reconnect 후보, heartbeat 실패 처리의 socket session lifecycle을 정리한다.
- Completion mode: check-on-pass
## Spec Targets
- SDD: `agent-roadmap/sdd/control-plane-product-surface/runner-proto-socket-transport-hardening/SDD.md`
- Acceptance scenarios:
- `S02`: task=`session-lifecycle`; evidence=`Go/Dart unit 또는 smoke가 server-assigned runner id, duplicate connection close, disconnect/timeout, heartbeat failure를 검증한다.`
- Completion mode: spec-check-on-pass
## 분석 결과
### 읽은 파일
- `agent-test/local/rules.md`
- `agent-test/local/agent-smoke.md`
- `agent-test/local/framework-smoke.md`
- `agent-ops/rules/project/domain/agent/rules.md`
- `agent-ops/rules/project/domain/framework/rules.md`
- `agent-roadmap/phase/control-plane-product-surface/milestones/runner-proto-socket-transport-hardening.md`
- `agent-roadmap/sdd/control-plane-product-surface/runner-proto-socket-transport-hardening/SDD.md`
- `services/core/internal/runnersocket/server.go`
- `services/core/internal/runnerregistry/registry.go`
- `services/core/internal/runnerregistry/registry_test.go`
- `services/core/internal/httpserver/server.go`
- `services/core/internal/httpserver/routes.go`
- `services/core/internal/httpserver/server_test.go`
- `services/core/internal/cicdstate/store.go`
- `services/core/internal/cicdstate/store_test.go`
- `apps/runner/lib/oto/agent/registration_client.dart`
- `apps/runner/lib/oto/agent/agent_runner.dart`
- `apps/runner/lib/oto/agent/agent_config.dart`
- `apps/runner/lib/oto/agent/oto_server_job_client.dart`
- `apps/runner/test/oto_agent_registration_test.dart`
- `apps/runner/test/oto_server_connection_smoke_test.dart`
- `proto/oto/runner.proto`
- `Makefile`
- `apps/runner/pubspec.yaml`
- `services/core/go.mod`
### 테스트 환경 규칙
`test_env=local`을 적용했다. `agent-test/local/rules.md`는 원격 runner 기준이며 agent 변경은 `agent-test/local/agent-smoke.md`의 `cd apps/runner && dart analyze`, agent 관련 `dart test`, `cd services/core && go test ./...`를 요구한다. `proto/**` 변경은 `framework-smoke`의 `make proto`가 필요하지만, 이 plan은 proto 파일을 바꾸지 않는다. Go state/lifecycle 검증은 fresh 실행이 중요하므로 `go test -count=1 ./...`를 사용한다.
### 테스트 커버리지 공백
- server socket duplicate connection close: `services/core/internal/runnersocket/server.go`에는 `setClient`가 old client를 닫지만 전용 테스트가 없다.
- socket disconnect가 current client에만 `registry.Disconnect`를 적용하는지: disconnect listener는 있으나 stale client 경합 테스트가 없다.
- Dart socket heartbeat failure 처리: `_sendHeartbeat`는 오류를 무시하지만 heartbeat 실패 후 close/stream 정리 테스트가 없다.
### 심볼 참조
renamed/removed symbol 없음. `OtoServerPushJobSession`, `OtoServerSocketRegistrationClient`, `DispatchQueuedJobs`, `CancelExecution` 참조는 `rg --sort path`로 확인했다.
### 분할 판단
split decision policy를 먼저 적용했다. 공유 task group은 `agent-task/m-runner-proto-socket-transport-hardening/`이다. `01_socket_lifecycle`은 선행 작업이 없는 기반 작업이다. 후속 `02+01_dispatch_state`, `03+01,02_runner_actions`, `04+01_compat_boundary`, `05+02,03,04_socket_smoke`가 이 작업의 `complete.log`에 의존한다.
### 범위 결정 근거
이 plan은 runner socket session lifecycle만 다룬다. queued job dispatch, invalid RunRequest, cancel/status/self-update, HTTP compatibility test renaming, smoke 기준 문서 갱신은 후속 split plan에서 다룬다. `proto/oto/runner.proto`는 현재 register/heartbeat/cancel/run/report 메시지가 충분하므로 변경하지 않는다.
### 빌드 등급
`cloud-G07`: long-running socket/session behavior와 Go/Dart 양쪽 검증이 걸려 있고, 로컬 단위 테스트만으로 잘못된 구현이 통과할 위험이 있다.
## 구현 체크리스트
- [ ] [API-1] 서버 socket lifecycle 테스트와 필요한 보강을 구현한다. 검증: `cd services/core && go test -count=1 ./...`
- [ ] [API-2] Dart socket session heartbeat/close behavior 테스트와 필요한 보강을 구현한다. 검증: `cd apps/runner && dart test test/oto_agent_registration_test.dart test/oto_server_connection_smoke_test.dart`
- [ ] 전체 agent 검증을 실행한다. 검증: `cd apps/runner && dart analyze`
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [API-1] 서버 socket lifecycle 테스트와 보강
문제: [server.go](/config/workspace/oto/services/core/internal/runnersocket/server.go:136)의 register handler는 accepted runner id를 client map에 저장하고 [server.go](/config/workspace/oto/services/core/internal/runnersocket/server.go:187)의 disconnect listener가 registry를 disconnected로 바꾼다. [server.go](/config/workspace/oto/services/core/internal/runnersocket/server.go:351)의 `setClient`는 duplicate client를 닫지만 stale client disconnect가 current session 상태를 덮지 않는지 검증이 없다.
해결 방법:
```go
// before: services/core/internal/runnersocket/server.go:351
func (s *Server) setClient(runnerID string, client *protoSocket.TcpClient) {
s.mu.Lock()
defer s.mu.Unlock()
if old := s.clients[runnerID]; old != nil && old != client {
_ = old.Close()
}
s.clients[runnerID] = client
}
```
`services/core/internal/runnersocket/server_test.go`를 추가한다. real proto-socket TCP client를 쓰거나 같은 package test helper로 `setClient/removeClient/connectedRunnerIDs`와 registry transition을 검증한다. stale client disconnect가 current client를 삭제하지 않는 현재 `removeClient` guard를 테스트로 고정하고, 필요하면 disconnect listener에서 current client 확인 후 `registry.Disconnect`하도록 보강한다.
수정 파일 및 체크리스트:
- [ ] `services/core/internal/runnersocket/server_test.go` 추가
- [ ] stale client disconnect가 current client를 지우지 않는 테스트 추가
- [ ] duplicate connection close 또는 replacement behavior 테스트 추가
- [ ] heartbeat unknown/terminal response가 registry state와 일치하는지 테스트 추가
- [ ] 필요 시 `services/core/internal/runnersocket/server.go` 수정
테스트 작성: 작성한다. 테스트 이름 후보: `TestServerReplacesDuplicateRunnerClient`, `TestServerIgnoresStaleClientDisconnect`, `TestServerHeartbeatUsesRegisteredRunnerID`.
중간 검증:
```bash
cd services/core && go test -count=1 ./...
```
기대 결과: 모든 Go package 통과.
### [API-2] Dart socket session heartbeat/close 테스트와 보강
문제: [registration_client.dart](/config/workspace/oto/apps/runner/lib/oto/agent/registration_client.dart:344)는 pushed `RunRequest` listener를 열고, [registration_client.dart](/config/workspace/oto/apps/runner/lib/oto/agent/registration_client.dart:350)는 heartbeat timer를 시작한다. [registration_client.dart](/config/workspace/oto/apps/runner/lib/oto/agent/registration_client.dart:372)는 close에서 timer와 stream/socket을 닫지만 socket session 전용 test가 부족하다.
해결 방법:
```dart
// before: apps/runner/lib/oto/agent/registration_client.dart:358
Future<void> _sendHeartbeat() async {
if (_closed) return;
try {
await _client.sendRequest<oto.HeartbeatRequest, oto.HeartbeatResponse>(...);
} catch (_) {
// Ignore background heartbeat errors; proto-socket heartbeat owns liveness.
}
}
```
`apps/runner/test/oto_agent_registration_test.dart`에 socket registration/session 전용 group을 보강한다. 기존 HTTP session tests는 compatibility 경로로 유지하고, socket session은 fake or loopback proto-socket으로 first heartbeat, close cancels future heartbeat, runRequests stream close를 검증한다. 직접 fake가 어려우면 `oto_server_connection_smoke_test.dart`의 real Go server helper를 재사용하되 test name에 socket을 명시한다.
수정 파일 및 체크리스트:
- [ ] `apps/runner/test/oto_agent_registration_test.dart` 또는 `apps/runner/test/oto_server_connection_smoke_test.dart`에 socket session lifecycle test 추가
- [ ] heartbeat failure가 process를 죽이지 않고 close가 정리되는지 검증
- [ ] `AgentSession.close()` 후 runRequests stream이 닫히는지 검증
- [ ] 필요 시 `apps/runner/lib/oto/agent/registration_client.dart` 수정
테스트 작성: 작성한다. 테스트 이름 후보: `OtoServerSocketRegistrationClient sends heartbeat and closes pushed session`.
중간 검증:
```bash
cd apps/runner && dart test test/oto_agent_registration_test.dart test/oto_server_connection_smoke_test.dart
```
기대 결과: 두 테스트 파일 통과.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `services/core/internal/runnersocket/server.go` | API-1 |
| `services/core/internal/runnersocket/server_test.go` | API-1 |
| `apps/runner/lib/oto/agent/registration_client.dart` | API-2 |
| `apps/runner/test/oto_agent_registration_test.dart` | API-2 |
| `apps/runner/test/oto_server_connection_smoke_test.dart` | API-2 |
## 최종 검증
```bash
cd services/core && go test -count=1 ./...
cd apps/runner && dart test test/oto_agent_registration_test.dart test/oto_server_connection_smoke_test.dart
cd apps/runner && dart analyze
```
기대 결과: Go tests 통과, Dart agent tests 통과, analyzer issue 없음. 모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,116 @@
<!-- task=m-runner-proto-socket-transport-hardening/01_socket_lifecycle plan=1 tag=REVIEW_API -->
# Plan - REVIEW_API
## 이 파일을 읽는 구현 에이전트에게
이 plan은 직전 code-review FAIL의 Required 1건만 해결한다. `CODE_REVIEW-local-G06.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우는 것까지가 구현이다. 구현 중 직접 사용자에게 질문하거나 `request_user_input`, `USER_REVIEW.md`, `complete.log`, archive 생성을 하지 않는다. 선택된 SDD 결정 또는 Milestone `구현 잠금 > 결정 필요` 항목이 막을 때만 review stub의 `사용자 리뷰 요청`을 채운다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/control-plane-product-surface/milestones/runner-proto-socket-transport-hardening.md`
- Task ids:
- `session-lifecycle`: runner id reconciliation, duplicate connection close, disconnect/timeout, reconnect 후보, heartbeat 실패 처리의 socket session lifecycle을 정리한다.
- Completion mode: check-on-pass
## Spec Targets
- SDD: `agent-roadmap/sdd/control-plane-product-surface/runner-proto-socket-transport-hardening/SDD.md`
- Acceptance scenarios:
- `S02`: task=`session-lifecycle`; evidence=`Go/Dart unit 또는 smoke가 server-assigned runner id, duplicate connection close, disconnect/timeout, heartbeat failure를 검증한다.`
- Completion mode: spec-check-on-pass
## Archive Evidence Snapshot
- Current archived plan: `agent-task/m-runner-proto-socket-transport-hardening/01_socket_lifecycle/plan_cloud_G07_0.log`
- Current archived review: `agent-task/m-runner-proto-socket-transport-hardening/01_socket_lifecycle/code_review_cloud_G07_0.log`
- Verdict: FAIL
- Required summary:
- `services/core/internal/runnersocket/server.go:350`의 `setClient`가 `s.mu`를 잡은 상태에서 old `TcpClient.Close()`를 호출한다. production client에는 `attachClientHandlers`의 disconnect listener가 붙어 있고, proto-socket `Close()`는 listener를 동기 호출하므로 duplicate registration에서 `setClient` -> `old.Close()` -> listener -> `removeClient()` -> 같은 `s.mu` 재획득 경로로 교착될 수 있다.
- Affected files:
- `services/core/internal/runnersocket/server.go`
- `services/core/internal/runnersocket/server_test.go`
- Prior verification evidence:
- `cd services/core && go test -count=1 ./...` 통과
- `cd apps/runner && dart test test/oto_agent_registration_test.dart test/oto_server_connection_smoke_test.dart` 통과
- `cd apps/runner && dart analyze` 통과
- Verification trust note:
- 기존 Go test는 listener가 없는 `pipeClient`만 `setClient`에 넣어 production disconnect listener 재진입을 검증하지 못했다.
- Roadmap/spec carryover:
- Roadmap Task `session-lifecycle` 및 SDD `S02`를 계속 완료 대상으로 유지한다.
- Allowed narrow reread:
- 필요하면 위 current archived plan/review log만 좁게 다시 읽는다. `agent-task/archive/**`는 읽지 않는다.
## 배경
직전 구현은 stale disconnect guard와 Dart close/heartbeat smoke를 추가했고 모든 명령은 통과했다. 하지만 duplicate runner connection을 교체하는 실제 production 경로에서는 old client에 disconnect listener가 등록되어 있으므로, mutex 안에서 old client를 닫으면 listener가 같은 mutex를 다시 잡으려 해 멈출 수 있다.
## 범위 결정 근거
후속 범위는 서버 socket duplicate replacement의 교착 제거와 테스트 보강만 포함한다. Dart client, dispatch 상태 전이, cancel/status/self-update, compatibility boundary, smoke 문서 갱신은 이 plan에서 수정하지 않는다.
## 빌드 등급
`local-G06`: 문제와 수정 범위가 `services/core/internal/runnersocket` 안의 mutex/close 순서와 단위 테스트로 좁고, 결정적 Go 테스트로 검증 가능하다.
## 구현 체크리스트
- [ ] [REVIEW_API-1] duplicate socket replacement deadlock을 제거하고 production listener 경로 테스트를 추가한다. 검증: `cd services/core && go test -count=1 ./internal/runnersocket`
- [ ] 전체 Go 회귀를 실행한다. 검증: `cd services/core && go test -count=1 ./...`
- [ ] 전체 agent 검증을 재실행한다. 검증: `cd apps/runner && dart test test/oto_agent_registration_test.dart test/oto_server_connection_smoke_test.dart` 및 `cd apps/runner && dart analyze`
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [REVIEW_API-1] duplicate socket replacement deadlock 제거
문제: `services/core/internal/runnersocket/server.go:350`의 `setClient`는 `s.mu`를 잡은 상태에서 old client를 닫는다. proto-socket `Close()`는 disconnect listener를 동기 호출하고, production listener는 `s.removeClient()`로 같은 mutex를 다시 잡는다. 기존 테스트는 listener 없는 `pipeClient`를 사용해 이 재진입 경로를 놓쳤다.
해결 방법:
```go
// 방향 예시
func (s *Server) setClient(runnerID string, client *protoSocket.TcpClient) {
var old *protoSocket.TcpClient
s.mu.Lock()
old = s.clients[runnerID]
s.clients[runnerID] = client
s.mu.Unlock()
if old != nil && old != client {
_ = old.Close()
}
}
```
새 client를 먼저 map에 저장하고 lock을 푼 뒤 old client를 닫는다. 그러면 old listener가 실행되어도 `removeClient(runnerID, old)`는 false를 반환하고 current client와 registry online 상태를 유지할 수 있다.
수정 파일 및 체크리스트:
- [ ] `services/core/internal/runnersocket/server.go`에서 old client close를 mutex 밖으로 이동한다.
- [ ] `services/core/internal/runnersocket/server_test.go`에 `attachClientHandlers` 또는 동일한 production listener 재진입을 가진 client로 duplicate replacement가 완료되는 테스트를 추가한다.
- [ ] 테스트는 deadlock을 타임아웃으로 감지하지 않고, `setClient` 호출 완료와 current client 유지, stale disconnect skip을 명확히 assert한다.
중간 검증:
```bash
cd services/core && go test -count=1 ./internal/runnersocket
```
기대 결과: runnersocket package 통과.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `services/core/internal/runnersocket/server.go` | REVIEW_API-1 |
| `services/core/internal/runnersocket/server_test.go` | REVIEW_API-1 |
## 최종 검증
```bash
cd services/core && go test -count=1 ./internal/runnersocket
cd services/core && go test -count=1 ./...
cd apps/runner && dart test test/oto_agent_registration_test.dart test/oto_server_connection_smoke_test.dart
cd apps/runner && dart analyze
```
기대 결과: 모든 명령 통과. `CODE_REVIEW-local-G06.md`의 구현 에이전트 소유 섹션에 실제 stdout/stderr를 붙인다.

View file

@ -0,0 +1,137 @@
<!-- task=m-runner-proto-socket-transport-hardening/02+01_dispatch_state plan=0 tag=API -->
# Code Review Reference - API
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a selected SDD decision or selected Milestone `구현 잠금 > 결정 필요` item, fill `사용자 리뷰 요청` with linked evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Environment/secret/service blockers, generic scope changes, repeated failures, and evidence gaps that a follow-up agent can close 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 only SDD/Milestone lock decisions 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-20
task=m-runner-proto-socket-transport-hardening/02+01_dispatch_state, plan=0, tag=API
## Roadmap Targets
- Milestone: `agent-roadmap/phase/control-plane-product-surface/milestones/runner-proto-socket-transport-hardening.md`
- Task ids:
- `dispatch-state`: queued job dispatch, active execution gate, invalid RunRequest failure, terminal state transition을 socket transport 기준으로 검증한다.
- Completion mode: check-on-pass
- Split dependency:
- `01_socket_lifecycle` PASS complete evidence가 있어야 구현을 시작한다.
## Spec Targets
- SDD: `agent-roadmap/sdd/control-plane-product-surface/runner-proto-socket-transport-hardening/SDD.md`
- Acceptance scenarios:
- `S03`: task=`dispatch-state`; evidence=`Socket RunRequest dispatch와 terminal report가 queued/running/completed/failed/cancelled 상태 전이를 검증한다.`
- Completion mode: spec-check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[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-runner-proto-socket-transport-hardening/02+01_dispatch_state/`로 이동한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [API-1] socket queued dispatch와 invalid RunRequest failure | [ ] |
| [API-2] socket terminal report와 active execution gate | [ ] |
## 구현 체크리스트
- [ ] 선행 `01_socket_lifecycle` PASS complete evidence를 확인한다.
- [ ] [API-1] socket queued dispatch와 invalid RunRequest failure 테스트/보강을 구현한다. 검증: `cd services/core && go test -count=1 ./...`
- [ ] [API-2] socket terminal report와 active execution gate 테스트/보강을 구현한다. 검증: `cd services/core && go test -count=1 ./...`
- [ ] runner socket smoke 관련 회귀를 확인한다. 검증: `cd apps/runner && dart test test/oto_server_connection_smoke_test.dart`
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [ ] `코드리뷰 결과``PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [ ] active `CODE_REVIEW-*-G??.md``code_review_cloud_G07_N.log`로 아카이브한다.
- [ ] active `PLAN-*-G??.md``plan_cloud_G07_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-runner-proto-socket-transport-hardening/02+01_dispatch_state/``agent-task/archive/YYYY/MM/m-runner-proto-socket-transport-hardening/02+01_dispatch_state/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-runner-proto-socket-transport-hardening/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active plan/review 파일 또는 follow-up plan을 작성하고 `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`를 남기지 않는다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 SDD 결정 또는 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- queued execution이 socket dispatch에서 정확히 한 번 running으로 claim되는지 확인한다.
- invalid or failed `RunRequest` send가 execution을 어중간한 running 상태로 남기지 않는지 확인한다.
- terminal report가 ownership/state guard를 우회하지 않는지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
### API-1 중간 검증
```bash
$ cd services/core && go test -count=1 ./...
(output)
```
### API-2 중간 검증
```bash
$ cd services/core && go test -count=1 ./...
(output)
```
### 최종 검증
```bash
$ cd services/core && go test -count=1 ./...
(output)
$ cd apps/runner && dart test test/oto_server_connection_smoke_test.dart
(output)
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.

View file

@ -0,0 +1,175 @@
<!-- task=m-runner-proto-socket-transport-hardening/02+01_dispatch_state plan=0 tag=API -->
# Plan - API
## 이 파일을 읽는 구현 에이전트에게
`CODE_REVIEW-cloud-G07.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우는 것까지가 구현입니다. 검증을 실행하고 실제 stdout/stderr를 붙인 뒤 active 파일을 그대로 두고 review ready로 보고하세요. 선택된 SDD 결정 또는 Milestone `구현 잠금 > 결정 필요` 항목이 막으면 review stub의 `사용자 리뷰 요청` 섹션을 채우고 멈추며, 직접 사용자에게 질문하거나 `USER_REVIEW.md`, `complete.log`, archive를 만들지 않습니다. 환경/secret/서비스 문제나 증거 공백은 일반 follow-up 대상으로 기록합니다.
## 배경
현 Milestone은 runner 기본 transport를 proto-socket push/session 기준으로 고정한다. `DispatchQueuedJobs`는 queued execution을 claim한 뒤 socket runner에게 `RunRequest`를 보내고, runner는 terminal report를 socket으로 돌려준다. 이 작업은 queued job dispatch와 execution state transition을 socket-first 경로에서 검증해 HTTP claim/polling 잔존 동작과 섞이지 않도록 한다.
## 사용자 리뷰 요청 흐름
사용자 리뷰 요청은 선택된 SDD 결정 또는 선택된 Milestone lock 결정이 실구현을 차단할 때만 active `CODE_REVIEW-cloud-G07.md``사용자 리뷰 요청` 섹션에 기록한다. 구현 중 직접 사용자 프롬프트, 채팅 선택지, `request_user_input`, `USER_REVIEW.md` 생성은 금지이며, code-review가 검증과 파일 생성을 소유한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/control-plane-product-surface/milestones/runner-proto-socket-transport-hardening.md`
- Task ids:
- `dispatch-state`: queued job dispatch, active execution gate, invalid RunRequest failure, terminal state transition을 socket transport 기준으로 검증한다.
- Completion mode: check-on-pass
- Split dependency:
- `01_socket_lifecycle` PASS complete evidence가 있어야 구현을 시작한다. 미충족이면 코드 변경 없이 dependency pending으로 보고한다.
## Spec Targets
- SDD: `agent-roadmap/sdd/control-plane-product-surface/runner-proto-socket-transport-hardening/SDD.md`
- Acceptance scenarios:
- `S03`: task=`dispatch-state`; evidence=`Socket RunRequest dispatch와 terminal report가 queued/running/completed/failed/cancelled 상태 전이를 검증한다.`
- Completion mode: spec-check-on-pass
## 분석 결과
### 읽은 파일
- `agent-test/local/rules.md`
- `agent-test/local/agent-smoke.md`
- `agent-test/local/framework-smoke.md`
- `agent-ops/rules/project/domain/agent/rules.md`
- `agent-ops/rules/project/domain/framework/rules.md`
- `agent-roadmap/phase/control-plane-product-surface/milestones/runner-proto-socket-transport-hardening.md`
- `agent-roadmap/sdd/control-plane-product-surface/runner-proto-socket-transport-hardening/SDD.md`
- `services/core/internal/runnersocket/server.go`
- `services/core/internal/httpserver/server_test.go`
- `services/core/internal/cicdstate/store.go`
- `services/core/internal/cicdstate/store_test.go`
- `apps/runner/lib/oto/agent/registration_client.dart`
- `apps/runner/lib/oto/agent/agent_runner.dart`
- `apps/runner/test/oto_server_connection_smoke_test.dart`
- `proto/oto/runner.proto`
- `Makefile`
### 테스트 환경 규칙
`test_env=local`을 적용했다. `agent-test/local/rules.md`는 agent 변경 시 `cd apps/runner && dart analyze`, 관련 `dart test`, `cd services/core && go test ./...`를 요구한다. 이 plan은 proto schema 변경 없이 socket dispatch/state test와 필요한 server/runner 보강만 다룬다.
### 테스트 커버리지 공백
- socket queued dispatch가 `claimNextQueuedJob`을 통해 하나의 queued execution만 running으로 전환하는지 직접 검증이 부족하다.
- invalid `RunRequest` 전송 실패가 execution을 failed로 마감하는지 socket server 단위 테스트가 없다.
- socket terminal report가 running 상태에서 completed/failed/cancelled 계열로만 전이하는지 HTTP report tests와 분리된 증거가 부족하다.
### 심볼 참조
`runnersocket.Server.DispatchQueuedJobs`, `claimNextQueuedJob`, `ReportExecutionResult`, `proto.RunRequest`, `ReportRunResultRequest`, `OtoServerPushJobSession`을 중심으로 확인했다. removed/renamed symbol 없음.
### 분할 판단
이 작업은 `01_socket_lifecycle`의 session 안정화 결과에 의존한다. 후속 `03+01,02_runner_actions``05+02,03,04_socket_smoke`가 이 작업의 PASS evidence에 의존한다.
### 범위 결정 근거
이 plan은 queued execution dispatch와 state transition만 다룬다. cancel/status/self-update operator action 방향은 `03+01,02_runner_actions`, HTTP compatibility 경계는 `04+01_compat_boundary`, smoke 문서/명령 정리는 `05+02,03,04_socket_smoke`에서 다룬다.
### 빌드 등급
`cloud-G07`: state transition은 Go server store와 socket transport, Dart runner session이 함께 얽히며 회귀 영향이 커서 full local Go test와 targeted Dart smoke가 필요하다.
## 구현 체크리스트
- [ ] 선행 `01_socket_lifecycle` PASS complete evidence를 확인한다.
- [ ] [API-1] socket queued dispatch와 invalid RunRequest failure 테스트/보강을 구현한다. 검증: `cd services/core && go test -count=1 ./...`
- [ ] [API-2] socket terminal report와 active execution gate 테스트/보강을 구현한다. 검증: `cd services/core && go test -count=1 ./...`
- [ ] runner socket smoke 관련 회귀를 확인한다. 검증: `cd apps/runner && dart test test/oto_server_connection_smoke_test.dart`
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [API-1] socket queued dispatch와 invalid RunRequest failure
문제: [server.go](/config/workspace/oto/services/core/internal/runnersocket/server.go:75)의 `DispatchQueuedJobs`는 connected runner 목록을 순회하고 [server.go](/config/workspace/oto/services/core/internal/runnersocket/server.go:270)의 `claimNextQueuedJob`으로 queued execution을 running으로 바꾼다. 다만 socket dispatch 단위에서 "한 queued job만 claim", "runner 전송 실패 시 failed"가 고정되어 있지 않다.
해결 방법:
```go
// before: services/core/internal/runnersocket/server.go:75
func (s *Server) DispatchQueuedJobs(ctx context.Context) {
for _, runnerID := range s.connectedRunnerIDs() {
...
execution, job, ok, err := s.claimNextQueuedJob(ctx, runnerID)
...
}
}
```
`services/core/internal/runnersocket/server_test.go`에 dispatch 전용 tests를 추가한다. fake or loopback proto-socket client로 `RunRequest` 수신을 확인하고, 전송 오류/invalid request fixture에서는 `FailExecution` 또는 동일 정책의 failed transition을 검증한다. HTTP claim handler tests와 assertion 이름을 분리해 socket dispatch evidence로 읽히게 한다.
수정 파일 및 체크리스트:
- [ ] `services/core/internal/runnersocket/server_test.go`에 queued dispatch test 추가
- [ ] dispatch가 runner id와 queued execution filter를 지키는지 검증
- [ ] send failure 또는 invalid request가 execution을 failed로 마감하는지 검증
- [ ] 필요 시 `services/core/internal/runnersocket/server.go` 보강
테스트 작성: 작성한다. 테스트 이름 후보: `TestServerDispatchQueuedJobOverSocket`, `TestServerFailsExecutionWhenRunRequestCannotBeSent`.
중간 검증:
```bash
cd services/core && go test -count=1 ./...
```
기대 결과: 모든 Go package 통과.
### [API-2] socket terminal report와 active execution gate
문제: [server.go](/config/workspace/oto/services/core/internal/runnersocket/server.go:197)의 report handler는 runner terminal report를 받아 `ReportExecutionResult`로 넘긴다. [store.go](/config/workspace/oto/services/core/internal/cicdstate/store.go:262)의 state transition guard가 있으나 socket report path에서 active execution과 runner ownership이 어긋난 경우의 test evidence가 부족하다.
해결 방법:
```go
// before: services/core/internal/runnersocket/server.go:197
s.handlers.Register(func(ctx context.Context, req *pb.ReportRunResultRequest) (*pb.ReportRunResultResponse, error) {
err := s.state.ReportExecutionResult(ctx, req.ExecutionId, cicdstate.ExecutionResult{...})
...
})
```
socket report handler tests를 추가해 running execution만 terminal report를 받을 수 있는지, completed/failed report가 terminal state와 timestamps/log fields를 보존하는지 확인한다. cancelled 상태와 terminal report가 충돌하는 경우에는 현재 store 정책을 테스트로 고정하거나, SDD의 cancelled evidence가 필요하면 store guard를 보강한다.
수정 파일 및 체크리스트:
- [ ] socket report success/completed test 추가
- [ ] socket report failure test 추가
- [ ] wrong runner 또는 non-running execution report guard test 추가
- [ ] cancelled state와 terminal report 충돌 정책을 테스트로 고정
- [ ] 필요 시 `services/core/internal/cicdstate/store.go` 보강
테스트 작성: 작성한다. 테스트 이름 후보: `TestServerReportsRunResultOverSocket`, `TestServerRejectsTerminalReportForInactiveExecution`.
중간 검증:
```bash
cd services/core && go test -count=1 ./...
```
기대 결과: 모든 Go package 통과.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `services/core/internal/runnersocket/server.go` | API-1, API-2 |
| `services/core/internal/runnersocket/server_test.go` | API-1, API-2 |
| `services/core/internal/cicdstate/store.go` | API-2 |
| `services/core/internal/cicdstate/store_test.go` | API-2 |
| `apps/runner/test/oto_server_connection_smoke_test.dart` | API-1, API-2 |
## 최종 검증
```bash
cd services/core && go test -count=1 ./...
cd apps/runner && dart test test/oto_server_connection_smoke_test.dart
```
기대 결과: Go tests 통과, socket connection smoke 통과. 모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,147 @@
<!-- task=m-runner-proto-socket-transport-hardening/03+01,02_runner_actions plan=0 tag=API -->
# Code Review Reference - API
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a selected SDD decision or selected Milestone `구현 잠금 > 결정 필요` item, fill `사용자 리뷰 요청` with linked evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Environment/secret/service blockers, generic scope changes, repeated failures, and evidence gaps that a follow-up agent can close 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 only SDD/Milestone lock decisions 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-20
task=m-runner-proto-socket-transport-hardening/03+01,02_runner_actions, plan=0, tag=API
## Roadmap Targets
- Milestone: `agent-roadmap/phase/control-plane-product-surface/milestones/runner-proto-socket-transport-hardening.md`
- Task ids:
- `runner-actions`: cancel/status/self-update runner action의 socket/HTTP 방향을 정하고 취소와 terminal report 충돌을 막는다.
- Completion mode: check-on-pass
- Split dependency:
- `01_socket_lifecycle` PASS complete evidence가 있어야 구현을 시작한다.
- `02+01_dispatch_state` PASS complete evidence가 있어야 구현을 시작한다.
## Spec Targets
- SDD: `agent-roadmap/sdd/control-plane-product-surface/runner-proto-socket-transport-hardening/SDD.md`
- Acceptance scenarios:
- `S04`: task=`runner-actions`; evidence=`cancel/status/self-update 요청 경로가 socket-first flow 또는 compatibility 제외 범위로 명시되고 검증된다.`
- Completion mode: spec-check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[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-runner-proto-socket-transport-hardening/03+01,02_runner_actions/`로 이동한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [API-1] cancel/status action의 socket-first 방향과 상태 guard | [ ] |
| [API-2] runner-side cancel handling 또는 compatibility 제외 | [ ] |
| [API-3] self-update action 방향 명확화 | [ ] |
## 구현 체크리스트
- [ ] 선행 `01_socket_lifecycle`, `02+01_dispatch_state` PASS complete evidence를 확인한다.
- [ ] [API-1] cancel/status action의 socket-first 방향과 상태 guard를 구현/검증한다. 검증: `cd services/core && go test -count=1 ./...`
- [ ] [API-2] runner-side cancel handling 또는 명시적 compatibility 제외를 구현/검증한다. 검증: `cd apps/runner && dart test test/oto_server_connection_smoke_test.dart`
- [ ] [API-3] self-update action을 socket-first 구현 또는 compatibility 제외 범위로 명확히 한다. 검증: `cd apps/runner && dart analyze`
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [ ] `코드리뷰 결과``PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [ ] active `CODE_REVIEW-*-G??.md``code_review_cloud_G07_N.log`로 아카이브한다.
- [ ] active `PLAN-*-G??.md``plan_cloud_G07_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-runner-proto-socket-transport-hardening/03+01,02_runner_actions/``agent-task/archive/YYYY/MM/m-runner-proto-socket-transport-hardening/03+01,02_runner_actions/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-runner-proto-socket-transport-hardening/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active plan/review 파일 또는 follow-up plan을 작성하고 `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`를 남기지 않는다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 SDD 결정 또는 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- cancel endpoint와 socket cancel delivery의 관계가 테스트 또는 명시적 제외로 닫혔는지 확인한다.
- late terminal report가 cancelled state를 뒤집지 않는지 확인한다.
- self-update가 socket-first 구현이 아니라면 compatibility 제외 범위가 SDD S04 evidence와 충돌하지 않는지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
### API-1 중간 검증
```bash
$ cd services/core && go test -count=1 ./...
(output)
```
### API-2 중간 검증
```bash
$ cd apps/runner && dart test test/oto_server_connection_smoke_test.dart
(output)
```
### API-3 중간 검증
```bash
$ cd apps/runner && dart analyze
(output)
```
### 최종 검증
```bash
$ cd services/core && go test -count=1 ./...
(output)
$ cd apps/runner && dart test test/oto_server_connection_smoke_test.dart
(output)
$ cd apps/runner && dart analyze
(output)
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.

View file

@ -0,0 +1,203 @@
<!-- task=m-runner-proto-socket-transport-hardening/03+01,02_runner_actions plan=0 tag=API -->
# Plan - API
## 이 파일을 읽는 구현 에이전트에게
`CODE_REVIEW-cloud-G07.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우는 것까지가 구현입니다. 검증을 실행하고 실제 stdout/stderr를 붙인 뒤 active 파일을 그대로 두고 review ready로 보고하세요. 선택된 SDD 결정 또는 Milestone `구현 잠금 > 결정 필요` 항목이 막으면 review stub의 `사용자 리뷰 요청` 섹션을 채우고 멈추며, 직접 사용자에게 질문하거나 `USER_REVIEW.md`, `complete.log`, archive를 만들지 않습니다. 환경/secret/서비스 문제나 증거 공백은 일반 follow-up 대상으로 기록합니다.
## 배경
operator HTTP action 표면은 cancel/status/self-update 요청을 받지만, 현 Milestone의 기본 runner transport는 proto-socket이다. cancel은 server에서 runner socket으로 내려갈 수 있는 `CancelExecution` 경로가 있고, status는 state read 성격이 강하며, self-update는 runner-side 실행 경로가 아직 HTTP compatibility로 남아 있다. 이 작업은 세 action의 방향을 socket-first 기준으로 명확히 하고, cancel/report 충돌을 상태 전이에서 안전하게 막는다.
## 사용자 리뷰 요청 흐름
사용자 리뷰 요청은 선택된 SDD 결정 또는 선택된 Milestone lock 결정이 실구현을 차단할 때만 active `CODE_REVIEW-cloud-G07.md``사용자 리뷰 요청` 섹션에 기록한다. 구현 중 직접 사용자 프롬프트, 채팅 선택지, `request_user_input`, `USER_REVIEW.md` 생성은 금지이며, code-review가 검증과 파일 생성을 소유한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/control-plane-product-surface/milestones/runner-proto-socket-transport-hardening.md`
- Task ids:
- `runner-actions`: cancel/status/self-update runner action의 socket/HTTP 방향을 정하고 취소와 terminal report 충돌을 막는다.
- Completion mode: check-on-pass
- Split dependency:
- `01_socket_lifecycle` PASS complete evidence가 있어야 구현을 시작한다.
- `02+01_dispatch_state` PASS complete evidence가 있어야 구현을 시작한다.
## Spec Targets
- SDD: `agent-roadmap/sdd/control-plane-product-surface/runner-proto-socket-transport-hardening/SDD.md`
- Acceptance scenarios:
- `S04`: task=`runner-actions`; evidence=`cancel/status/self-update 요청 경로가 socket-first flow 또는 compatibility 제외 범위로 명시되고 검증된다.`
- Completion mode: spec-check-on-pass
## 분석 결과
### 읽은 파일
- `agent-test/local/rules.md`
- `agent-test/local/agent-smoke.md`
- `agent-test/local/framework-smoke.md`
- `agent-ops/rules/project/domain/agent/rules.md`
- `agent-ops/rules/project/domain/framework/rules.md`
- `agent-roadmap/phase/control-plane-product-surface/milestones/runner-proto-socket-transport-hardening.md`
- `agent-roadmap/sdd/control-plane-product-surface/runner-proto-socket-transport-hardening/SDD.md`
- `services/core/internal/httpserver/runner_cicd_handlers.go`
- `services/core/internal/runnersocket/server.go`
- `services/core/internal/cicdstate/store.go`
- `services/core/internal/httpserver/server_test.go`
- `apps/runner/lib/oto/agent/registration_client.dart`
- `apps/runner/lib/oto/agent/agent_runner.dart`
- `apps/runner/lib/oto/agent/oto_server_job_client.dart`
- `apps/runner/test/oto_server_connection_smoke_test.dart`
- `proto/oto/runner.proto`
- `Makefile`
### 테스트 환경 규칙
`test_env=local`을 적용했다. agent/core 양쪽 action behavior가 걸리므로 Go 전체 테스트, runner socket smoke, Dart analyzer를 실행한다. proto schema를 변경하면 `make proto`와 generated code 갱신이 추가로 필요하지만, 기본 plan은 기존 `CancelRunRequest`, `SelfUpdateRequest`, status HTTP DTO 범위에서 처리한다.
### 테스트 커버리지 공백
- operator cancel endpoint와 socket `CancelExecution` delivery가 하나의 흐름으로 검증되지 않는다.
- runner Dart session은 pushed `RunRequest`만 처리하며 `CancelRunRequest` listener가 없다.
- self-update request는 proto message와 HTTP client method가 있으나 socket consumer가 없어서 compatibility 제외 여부가 문서/테스트에서 불명확하다.
- cancel 이후 late terminal report가 cancelled state를 뒤집지 않는지 socket action scenario로 고정되어 있지 않다.
### 심볼 참조
`CancelExecution`, `CancelExecutionHandler`, `GetExecutionStatusHandler`, `RequestRunnerSelfUpdateHandler`, `CancelRunRequest`, `SelfUpdateRequest`, `OtoServerJobClient.cancelRun`, `requestSelfUpdate`를 중심으로 확인했다. removed/renamed symbol 없음.
### 분할 판단
이 작업은 session lifecycle과 dispatch/state guard가 선행되어야 의미가 있다. 후속 `05+02,03,04_socket_smoke`는 이 작업에서 확정한 action direction을 smoke pass criteria에 반영한다.
### 범위 결정 근거
이 plan은 action direction과 cancel/report 충돌만 다룬다. queued dispatch의 일반 상태 전이는 `02+01_dispatch_state`, HTTP fallback 명명과 compatibility boundary는 `04+01_compat_boundary`에서 다룬다.
### 빌드 등급
`cloud-G07`: action behavior가 operator HTTP endpoint, core state store, socket server, Dart runner session을 가로지른다. 반쪽 구현이 smoke에서 늦게 드러날 수 있어 full local core/agent verification이 필요하다.
## 구현 체크리스트
- [ ] 선행 `01_socket_lifecycle`, `02+01_dispatch_state` PASS complete evidence를 확인한다.
- [ ] [API-1] cancel/status action의 socket-first 방향과 상태 guard를 구현/검증한다. 검증: `cd services/core && go test -count=1 ./...`
- [ ] [API-2] runner-side cancel handling 또는 명시적 compatibility 제외를 구현/검증한다. 검증: `cd apps/runner && dart test test/oto_server_connection_smoke_test.dart`
- [ ] [API-3] self-update action을 socket-first 구현 또는 compatibility 제외 범위로 명확히 한다. 검증: `cd apps/runner && dart analyze`
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [API-1] cancel/status action의 socket-first 방향과 상태 guard
문제: [runner_cicd_handlers.go](/config/workspace/oto/services/core/internal/httpserver/runner_cicd_handlers.go:367)는 operator cancel endpoint를 제공하고, [server.go](/config/workspace/oto/services/core/internal/runnersocket/server.go:108)는 runner에게 socket cancel을 보낼 수 있다. 두 경로가 한 action flow로 묶여 있는지와 late terminal report guard가 부족하다.
해결 방법:
```go
// before: services/core/internal/runnersocket/server.go:108
func (s *Server) CancelExecution(ctx context.Context, runnerID string, executionID string) error {
client := s.clientForRunner(runnerID)
...
}
```
operator cancel handler가 state cancel만 수행하는지, socket runner cancel delivery까지 수행해야 하는지 현재 wiring을 확인한다. SDD S04 기준으로 socket-first가 목표이면 cancel endpoint에서 runner id를 찾아 `CancelExecution`을 호출할 수 있도록 dependency 주입을 보강한다. status endpoint는 state read action으로 명시하고 socket delivery 대상이 아님을 테스트/문서에 남긴다.
수정 파일 및 체크리스트:
- [ ] cancel endpoint에서 state cancel과 runner socket cancel delivery 관계를 명확히 함
- [ ] cancel 후 late terminal report가 cancelled state를 뒤집지 않는 테스트 추가
- [ ] status endpoint가 socket command가 아니라 state query임을 테스트 이름/설명으로 명확히 함
- [ ] 필요 시 `services/core/internal/httpserver/server.go` 또는 handler dependency 보강
테스트 작성: 작성한다. 테스트 이름 후보: `TestCancelExecutionSendsSocketCancelToRunner`, `TestCancelPreventsLateTerminalReportFromOverwritingCancelledState`.
중간 검증:
```bash
cd services/core && go test -count=1 ./...
```
기대 결과: 모든 Go package 통과.
### [API-2] runner-side cancel handling 또는 compatibility 제외
문제: [registration_client.dart](/config/workspace/oto/apps/runner/lib/oto/agent/registration_client.dart:418)는 `CancelRunRequest` parser를 등록하지만 [agent_runner.dart](/config/workspace/oto/apps/runner/lib/oto/agent/agent_runner.dart:108)의 socket push loop는 `RunRequest`만 처리한다. socket cancel을 실제로 보낼 경우 runner가 수신 후 active execution을 중단하거나, 현재 Milestone에서 제외한다고 명시해야 한다.
해결 방법:
```dart
// before: apps/runner/lib/oto/agent/agent_runner.dart:108
await for (final runRequest in session.runRequests) {
...
}
```
가능한 범위에서 push session을 typed command stream으로 확장해 `CancelRunRequest`를 처리한다. active process cancel이 runner executor 구조상 큰 변경이면, 이번 Milestone에서는 cancel delivery evidence를 core socket send까지로 제한하고 runner process cancel은 compatibility/follow-up으로 명시한다. 단, 이 제외는 SDD S04 evidence에 남겨야 하며 smoke pass criteria와 충돌하면 사용자 리뷰 요청이 아니라 follow-up plan 대상으로 기록한다.
수정 파일 및 체크리스트:
- [ ] runner socket client가 `CancelRunRequest`를 수신할 수 있는지 확인/테스트
- [ ] active execution cancel handling 구현 가능성을 판단하고 구현 또는 범위 제외 기록
- [ ] core에서 cancel send 실패 시 operator action response/state 정책을 고정
- [ ] 필요 시 runner smoke에 cancel request 수신 검증 추가
테스트 작성: 작성한다. 테스트 이름 후보: `runner receives socket cancel for active execution`.
중간 검증:
```bash
cd apps/runner && dart test test/oto_server_connection_smoke_test.dart
```
기대 결과: socket smoke 통과.
### [API-3] self-update action 방향 명확화
문제: [proto/runner.proto](/config/workspace/oto/proto/oto/runner.proto:169)는 `SelfUpdateRequest`를 정의하고 [oto_server_job_client.dart](/config/workspace/oto/apps/runner/lib/oto/agent/oto_server_job_client.dart:344)는 HTTP self-update client를 제공한다. 그러나 server-side self-update socket dispatch와 runner consumer가 없다.
해결 방법:
이번 Milestone에서 self-update를 socket command로 구현할지, HTTP compatibility action으로 제외할지 결정하고 코드/테스트 이름/문서에 반영한다. 기본 추천은 product surface가 아직 operator action 수준이므로 self-update는 compatibility 제외로 명시하고, socket command 구현은 별도 Milestone으로 넘기는 것이다. 단, 기존 code path가 socket-first rule을 위반해 기본 path처럼 보이면 이름과 테스트를 compatibility로 정리한다.
수정 파일 및 체크리스트:
- [ ] self-update HTTP path가 compatibility/follow-up인지 명시
- [ ] socket-first smoke가 self-update를 필수 socket command로 오해하지 않게 정리
- [ ] 필요 시 roadmap SDD evidence에 excluded compatibility wording 업데이트
- [ ] Dart analyzer 통과 확인
테스트 작성: 변경 범위에 따라 작성한다. 문서/테스트 이름만 바꾸면 기존 smoke와 analyzer로 검증한다.
중간 검증:
```bash
cd apps/runner && dart analyze
```
기대 결과: analyzer issue 없음.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `services/core/internal/httpserver/runner_cicd_handlers.go` | API-1, API-3 |
| `services/core/internal/httpserver/server.go` | API-1 |
| `services/core/internal/runnersocket/server.go` | API-1 |
| `services/core/internal/cicdstate/store.go` | API-1 |
| `services/core/internal/httpserver/server_test.go` | API-1, API-3 |
| `apps/runner/lib/oto/agent/agent_runner.dart` | API-2 |
| `apps/runner/lib/oto/agent/registration_client.dart` | API-2 |
| `apps/runner/lib/oto/agent/oto_server_job_client.dart` | API-3 |
| `apps/runner/test/oto_server_connection_smoke_test.dart` | API-2, API-3 |
## 최종 검증
```bash
cd services/core && go test -count=1 ./...
cd apps/runner && dart test test/oto_server_connection_smoke_test.dart
cd apps/runner && dart analyze
```
기대 결과: Go tests 통과, runner socket smoke 통과, analyzer issue 없음. 모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,148 @@
<!-- task=m-runner-proto-socket-transport-hardening/04+01_compat_boundary plan=0 tag=API -->
# Code Review Reference - API
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a selected SDD decision or selected Milestone `구현 잠금 > 결정 필요` item, fill `사용자 리뷰 요청` with linked evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Environment/secret/service blockers, generic scope changes, repeated failures, and evidence gaps that a follow-up agent can close 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 only SDD/Milestone lock decisions 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-20
task=m-runner-proto-socket-transport-hardening/04+01_compat_boundary, plan=0, tag=API
## Roadmap Targets
- Milestone: `agent-roadmap/phase/control-plane-product-surface/milestones/runner-proto-socket-transport-hardening.md`
- Task ids:
- `compat-boundary`: HTTP job claim/polling/report/log/artifact 경로를 compatibility fallback으로 격리하고 기본 runner path 명명에서 제외한다.
- Completion mode: check-on-pass
- Split dependency:
- `01_socket_lifecycle` PASS complete evidence가 있어야 구현을 시작한다.
## Spec Targets
- SDD: `agent-roadmap/sdd/control-plane-product-surface/runner-proto-socket-transport-hardening/SDD.md`
- Acceptance scenarios:
- `S05`: task=`compat-boundary`; evidence=`HTTP job claim/report/log/artifact 경로가 compatibility fallback으로 격리되고 기본 smoke/문서가 socket path를 기준으로 한다.`
- Completion mode: spec-check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[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-runner-proto-socket-transport-hardening/04+01_compat_boundary/`로 이동한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [API-1] runner default path와 HTTP fallback boundary 분리 | [ ] |
| [API-2] core HTTP job endpoints compatibility evidence 정리 | [ ] |
| [API-3] grep evidence와 smoke/docs 정리 | [ ] |
## 구현 체크리스트
- [ ] 선행 `01_socket_lifecycle` PASS complete evidence를 확인한다.
- [ ] [API-1] runner default path와 HTTP fallback boundary를 코드/테스트 이름에서 분리한다. 검증: `cd apps/runner && dart test test/oto_agent_registration_test.dart test/oto_server_connection_smoke_test.dart`
- [ ] [API-2] core HTTP job endpoints가 compatibility로 유지되는지 tests/docs evidence를 정리한다. 검증: `cd services/core && go test -count=1 ./...`
- [ ] [API-3] deterministic grep evidence로 기본 smoke/docs가 socket-first인지 확인한다. 검증: `rg --sort path -n "OtoServerRegistrationClient|claimNextJob|jobs/claim|HTTP polling|compatibility|fallback|socket" apps/runner/lib apps/runner/test services/core/internal/httpserver agent-ops/rules/project/domain/agent/rules.md agent-test/local/agent-smoke.md`
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [ ] `코드리뷰 결과``PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [ ] active `CODE_REVIEW-*-G??.md``code_review_cloud_G07_N.log`로 아카이브한다.
- [ ] active `PLAN-*-G??.md``plan_cloud_G07_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-runner-proto-socket-transport-hardening/04+01_compat_boundary/``agent-task/archive/YYYY/MM/m-runner-proto-socket-transport-hardening/04+01_compat_boundary/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-runner-proto-socket-transport-hardening/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active plan/review 파일 또는 follow-up plan을 작성하고 `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`를 남기지 않는다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 SDD 결정 또는 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- HTTP job routes가 삭제되지 않고 compatibility fallback으로 보존됐는지 확인한다.
- default runner/smoke 경로가 socket session을 기준으로 읽히는지 확인한다.
- grep evidence가 단순 키워드 나열이 아니라 fallback 경계 확인에 충분한지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
### API-1 중간 검증
```bash
$ cd apps/runner && dart test test/oto_agent_registration_test.dart test/oto_server_connection_smoke_test.dart
(output)
```
### API-2 중간 검증
```bash
$ cd services/core && go test -count=1 ./...
(output)
```
### API-3 중간 검증
```bash
$ rg --sort path -n "OtoServerRegistrationClient|claimNextJob|jobs/claim|HTTP polling|compatibility|fallback|socket" apps/runner/lib apps/runner/test services/core/internal/httpserver agent-ops/rules/project/domain/agent/rules.md agent-test/local/agent-smoke.md
(output)
```
### 최종 검증
```bash
$ cd apps/runner && dart test test/oto_agent_registration_test.dart test/oto_server_connection_smoke_test.dart
(output)
$ cd services/core && go test -count=1 ./...
(output)
$ cd apps/runner && dart analyze
(output)
$ rg --sort path -n "OtoServerRegistrationClient|claimNextJob|jobs/claim|HTTP polling|compatibility|fallback|socket" apps/runner/lib apps/runner/test services/core/internal/httpserver agent-ops/rules/project/domain/agent/rules.md agent-test/local/agent-smoke.md
(output)
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.

View file

@ -0,0 +1,192 @@
<!-- task=m-runner-proto-socket-transport-hardening/04+01_compat_boundary plan=0 tag=API -->
# Plan - API
## 이 파일을 읽는 구현 에이전트에게
`CODE_REVIEW-cloud-G07.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우는 것까지가 구현입니다. 검증을 실행하고 실제 stdout/stderr를 붙인 뒤 active 파일을 그대로 두고 review ready로 보고하세요. 선택된 SDD 결정 또는 Milestone `구현 잠금 > 결정 필요` 항목이 막으면 review stub의 `사용자 리뷰 요청` 섹션을 채우고 멈추며, 직접 사용자에게 질문하거나 `USER_REVIEW.md`, `complete.log`, archive를 만들지 않습니다. 환경/secret/서비스 문제나 증거 공백은 일반 follow-up 대상으로 기록합니다.
## 배경
Milestone SDD는 runner 기본 연결을 proto-socket으로 고정하고 HTTP job claim/report/log/artifact 경로를 compatibility fallback으로 축소한다. 현재 runner는 socket registration 실패 시 HTTP session으로 fallback하고, HTTP route/tests는 여전히 기본 runner path처럼 보이는 이름과 smoke 배치를 일부 갖고 있다. 이 작업은 HTTP 경로를 제거하지 않고 경계와 이름, 검증 기준을 정리한다.
## 사용자 리뷰 요청 흐름
사용자 리뷰 요청은 선택된 SDD 결정 또는 선택된 Milestone lock 결정이 실구현을 차단할 때만 active `CODE_REVIEW-cloud-G07.md``사용자 리뷰 요청` 섹션에 기록한다. 구현 중 직접 사용자 프롬프트, 채팅 선택지, `request_user_input`, `USER_REVIEW.md` 생성은 금지이며, code-review가 검증과 파일 생성을 소유한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/control-plane-product-surface/milestones/runner-proto-socket-transport-hardening.md`
- Task ids:
- `compat-boundary`: HTTP job claim/polling/report/log/artifact 경로를 compatibility fallback으로 격리하고 기본 runner path 명명에서 제외한다.
- Completion mode: check-on-pass
- Split dependency:
- `01_socket_lifecycle` PASS complete evidence가 있어야 구현을 시작한다.
## Spec Targets
- SDD: `agent-roadmap/sdd/control-plane-product-surface/runner-proto-socket-transport-hardening/SDD.md`
- Acceptance scenarios:
- `S05`: task=`compat-boundary`; evidence=`HTTP job claim/report/log/artifact 경로가 compatibility fallback으로 격리되고 기본 smoke/문서가 socket path를 기준으로 한다.`
- Completion mode: spec-check-on-pass
## 분석 결과
### 읽은 파일
- `agent-test/local/rules.md`
- `agent-test/local/agent-smoke.md`
- `agent-test/local/framework-smoke.md`
- `agent-ops/rules/project/domain/agent/rules.md`
- `agent-ops/rules/project/domain/framework/rules.md`
- `agent-roadmap/phase/control-plane-product-surface/milestones/runner-proto-socket-transport-hardening.md`
- `agent-roadmap/sdd/control-plane-product-surface/runner-proto-socket-transport-hardening/SDD.md`
- `services/core/internal/httpserver/routes.go`
- `services/core/internal/httpserver/job_handlers.go`
- `services/core/internal/httpserver/runner_handlers.go`
- `services/core/internal/httpserver/server_test.go`
- `apps/runner/lib/oto/agent/agent_runner.dart`
- `apps/runner/lib/oto/agent/registration_client.dart`
- `apps/runner/lib/oto/agent/oto_server_job_client.dart`
- `apps/runner/test/oto_agent_registration_test.dart`
- `apps/runner/test/oto_server_connection_smoke_test.dart`
- `Makefile`
### 테스트 환경 규칙
`test_env=local`을 적용했다. 이 작업은 대부분 naming, fallback boundary, tests/docs 정리지만 agent/core 경로를 건드릴 수 있으므로 Go test, Dart targeted tests, analyzer, deterministic `rg --sort path` evidence를 사용한다.
### 테스트 커버리지 공백
- HTTP polling loop가 fallback임을 test names/pass criteria가 일관되게 표현하지 않는다.
- `OtoServerRegistrationClient``OtoServerJobClient.claimNextJob`가 기본 path smoke처럼 보일 수 있다.
- HTTP routes는 유지되어야 하지만 compatibility route로 분류되는 evidence가 부족하다.
### 심볼 참조
`OtoServerPushJobSession`, `OtoServerSocketRegistrationClient`, `OtoServerRegistrationClient`, `OtoServerJobClient.claimNextJob`, `/api/runners/jobs/claim`, `jobs/{id}/report`, `jobs/{id}/logs`, `jobs/{id}/artifacts`를 중심으로 확인했다. removed/renamed symbol 없음.
### 분할 판단
이 작업은 session lifecycle이 먼저 안정화되어야 fallback naming이 의미 있다. dispatch/action 구현과 직접 충돌하지 않도록 HTTP compatibility naming과 test grouping만 좁게 다룬다.
### 범위 결정 근거
HTTP job endpoints를 삭제하지 않는다. 기존 외부/legacy runner compatibility를 깨지 않고, 기본 agent path와 smoke 기준에서 socket-first를 명확히 하는 데 집중한다.
### 빌드 등급
`cloud-G07`: 기능 삭제는 아니지만 route/test naming과 default path 기준이 바뀌면 runner smoke와 운영 문서 신뢰도에 영향이 있다.
## 구현 체크리스트
- [ ] 선행 `01_socket_lifecycle` PASS complete evidence를 확인한다.
- [ ] [API-1] runner default path와 HTTP fallback boundary를 코드/테스트 이름에서 분리한다. 검증: `cd apps/runner && dart test test/oto_agent_registration_test.dart test/oto_server_connection_smoke_test.dart`
- [ ] [API-2] core HTTP job endpoints가 compatibility로 유지되는지 tests/docs evidence를 정리한다. 검증: `cd services/core && go test -count=1 ./...`
- [ ] [API-3] deterministic grep evidence로 기본 smoke/docs가 socket-first인지 확인한다. 검증: 아래 `rg --sort path` 명령
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [API-1] runner default path와 HTTP fallback boundary 분리
문제: [agent_runner.dart](/config/workspace/oto/apps/runner/lib/oto/agent/agent_runner.dart:77)는 socket session을 먼저 시도하고 실패 시 HTTP session으로 fallback한다. 그러나 [agent_runner.dart](/config/workspace/oto/apps/runner/lib/oto/agent/agent_runner.dart:135)의 HTTP polling loop와 tests가 기본 runner 동작처럼 읽힐 수 있다.
해결 방법:
```dart
// before: apps/runner/lib/oto/agent/agent_runner.dart:77
final socketSession = await _tryStartSocketSession(config, registration);
if (socketSession != null) {
return _runPushLoop(socketSession);
}
return _runPollingLoop(...);
```
fallback behavior는 유지하되, function/test group/log wording을 compatibility fallback으로 정리한다. socket path test를 first/default smoke로 두고 HTTP polling tests는 legacy/compatibility group으로 분리한다.
수정 파일 및 체크리스트:
- [ ] runner test group names에서 socket default와 HTTP compatibility를 구분
- [ ] HTTP fallback log/test wording이 default path로 읽히지 않게 정리
- [ ] socket session 실패 시 fallback behavior는 기존대로 보존
- [ ] 필요 시 `agent-ops/rules/project/domain/agent/rules.md` 문구와 일치하도록 테스트명 보강
테스트 작성: 기존 tests rename/grouping 또는 신규 assertion 작성.
중간 검증:
```bash
cd apps/runner && dart test test/oto_agent_registration_test.dart test/oto_server_connection_smoke_test.dart
```
기대 결과: targeted Dart tests 통과.
### [API-2] core HTTP job endpoints compatibility evidence 정리
문제: [routes.go](/config/workspace/oto/services/core/internal/httpserver/routes.go:107)는 HTTP runner job routes를 계속 노출한다. 이는 legacy compatibility로 필요하지만 Milestone 기본 transport와 혼동되면 안 된다.
해결 방법:
HTTP job claim/report/log/artifact tests는 compatibility/fallback group으로 유지한다. route 제거는 하지 않는다. endpoint docs/comments/test names가 기본 transport처럼 보이면 compatibility wording으로 정리한다.
수정 파일 및 체크리스트:
- [ ] HTTP job claim/report/log/artifact tests가 compatibility fallback evidence로 읽히도록 정리
- [ ] route behavior는 유지
- [ ] socket dispatch tests와 HTTP claim tests가 서로 다른 책임을 갖도록 이름 분리
테스트 작성: 기존 Go tests 이름/grouping 또는 comments 정리.
중간 검증:
```bash
cd services/core && go test -count=1 ./...
```
기대 결과: 모든 Go package 통과.
### [API-3] grep evidence와 smoke/docs 정리
문제: smoke criteria와 docs가 socket-first를 명확히 하지 않으면 후속 작업이 HTTP polling을 기본 evidence로 제출할 수 있다.
해결 방법:
`agent-test/local/agent-smoke.md`, domain rule, tests를 정리한 뒤 deterministic grep으로 socket-first/fallback wording을 확인한다.
수정 파일 및 체크리스트:
- [ ] `agent-test/local/agent-smoke.md`가 socket smoke를 기본으로 요구하는지 확인/수정
- [ ] HTTP 관련 wording이 compatibility fallback으로 제한되는지 확인
- [ ] `rg --sort path` 결과를 CODE_REVIEW 검증 결과에 붙임
중간 검증:
```bash
rg --sort path -n "OtoServerRegistrationClient|claimNextJob|jobs/claim|HTTP polling|compatibility|fallback|socket" apps/runner/lib apps/runner/test services/core/internal/httpserver agent-ops/rules/project/domain/agent/rules.md agent-test/local/agent-smoke.md
```
기대 결과: HTTP client/claim/polling 언급이 compatibility/fallback 맥락으로 분리되어 보인다.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `apps/runner/lib/oto/agent/agent_runner.dart` | API-1 |
| `apps/runner/lib/oto/agent/registration_client.dart` | API-1 |
| `apps/runner/lib/oto/agent/oto_server_job_client.dart` | API-1 |
| `apps/runner/test/oto_agent_registration_test.dart` | API-1 |
| `apps/runner/test/oto_server_connection_smoke_test.dart` | API-1, API-3 |
| `services/core/internal/httpserver/routes.go` | API-2 |
| `services/core/internal/httpserver/server_test.go` | API-2 |
| `agent-test/local/agent-smoke.md` | API-3 |
| `agent-ops/rules/project/domain/agent/rules.md` | API-3 |
## 최종 검증
```bash
cd apps/runner && dart test test/oto_agent_registration_test.dart test/oto_server_connection_smoke_test.dart
cd services/core && go test -count=1 ./...
cd apps/runner && dart analyze
rg --sort path -n "OtoServerRegistrationClient|claimNextJob|jobs/claim|HTTP polling|compatibility|fallback|socket" apps/runner/lib apps/runner/test services/core/internal/httpserver agent-ops/rules/project/domain/agent/rules.md agent-test/local/agent-smoke.md
```
기대 결과: tests/analyzer 통과, grep 결과에서 HTTP path는 compatibility/fallback 맥락으로 구분된다. 모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,152 @@
<!-- task=m-runner-proto-socket-transport-hardening/05+02,03,04_socket_smoke plan=0 tag=API -->
# Code Review Reference - API
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a selected SDD decision or selected Milestone `구현 잠금 > 결정 필요` item, fill `사용자 리뷰 요청` with linked evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Environment/secret/service blockers, generic scope changes, repeated failures, and evidence gaps that a follow-up agent can close 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 only SDD/Milestone lock decisions 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-20
task=m-runner-proto-socket-transport-hardening/05+02,03,04_socket_smoke, plan=0, tag=API
## Roadmap Targets
- Milestone: `agent-roadmap/phase/control-plane-product-surface/milestones/runner-proto-socket-transport-hardening.md`
- Task ids:
- `socket-smoke`: local/remote smoke 기준을 socket-first runner transport evidence로 갱신한다.
- Completion mode: check-on-pass
- Split dependency:
- `02+01_dispatch_state` PASS complete evidence가 있어야 구현을 시작한다.
- `03+01,02_runner_actions` PASS complete evidence가 있어야 구현을 시작한다.
- `04+01_compat_boundary` PASS complete evidence가 있어야 구현을 시작한다.
## Spec Targets
- SDD: `agent-roadmap/sdd/control-plane-product-surface/runner-proto-socket-transport-hardening/SDD.md`
- Acceptance scenarios:
- `S06`: task=`socket-smoke`; evidence=`agent local smoke와 runner/core smoke가 socket-first lifecycle, dispatch, action, compatibility boundary를 검증한다.`
- Completion mode: spec-check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[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-runner-proto-socket-transport-hardening/05+02,03,04_socket_smoke/`로 이동한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [API-1] smoke 문서와 Makefile/test grouping 갱신 | [ ] |
| [API-2] runner socket smoke 대표성 보강 | [ ] |
| [API-3] full local smoke 기록 | [ ] |
## 구현 체크리스트
- [ ] 선행 `02+01_dispatch_state`, `03+01,02_runner_actions`, `04+01_compat_boundary` PASS complete evidence를 확인한다.
- [ ] [API-1] `agent-test/local/agent-smoke.md`와 Makefile/test grouping을 socket-first 기준으로 갱신한다. 검증: `cd apps/runner && dart analyze`
- [ ] [API-2] runner socket smoke가 lifecycle/dispatch/action/compat boundary evidence를 대표하도록 보강한다. 검증: `cd apps/runner && dart test test/oto_server_connection_smoke_test.dart`
- [ ] [API-3] full local smoke 명령을 실행하고 결과를 기록한다. 검증: Go full test와 runner required test list
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [ ] `코드리뷰 결과``PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [ ] active `CODE_REVIEW-*-G??.md``code_review_cloud_G07_N.log`로 아카이브한다.
- [ ] active `PLAN-*-G??.md``plan_cloud_G07_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-runner-proto-socket-transport-hardening/05+02,03,04_socket_smoke/``agent-task/archive/YYYY/MM/m-runner-proto-socket-transport-hardening/05+02,03,04_socket_smoke/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-runner-proto-socket-transport-hardening/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active plan/review 파일 또는 follow-up plan을 작성하고 `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`를 남기지 않는다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 SDD 결정 또는 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- smoke 문서가 실제 실행 가능한 명령만 요구하는지 확인한다.
- socket smoke가 HTTP registration/polling을 기본 evidence로 제출하지 않는지 확인한다.
- S06 evidence가 S02-S05 선행 결과를 무리하게 중복 구현하지 않고 종합 검증으로 닫는지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
### API-1 중간 검증
```bash
$ cd apps/runner && dart analyze
(output)
```
### API-2 중간 검증
```bash
$ cd apps/runner && dart test test/oto_server_connection_smoke_test.dart
(output)
```
### API-3 중간 검증
```bash
$ cd services/core && go test -count=1 ./...
(output)
$ cd apps/runner && dart analyze
(output)
$ cd apps/runner && dart test test/oto_agent_migration_plan_test.dart test/oto_agent_bootstrap_script_test.dart test/oto_agent_cli_test.dart test/oto_agent_config_test.dart test/oto_agent_registration_test.dart test/oto_server_connection_smoke_test.dart
(output)
```
### 최종 검증
```bash
$ cd services/core && go test -count=1 ./...
(output)
$ cd apps/runner && dart analyze
(output)
$ cd apps/runner && dart test test/oto_agent_migration_plan_test.dart test/oto_agent_bootstrap_script_test.dart test/oto_agent_cli_test.dart test/oto_agent_config_test.dart test/oto_agent_registration_test.dart test/oto_server_connection_smoke_test.dart
(output)
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.

View file

@ -0,0 +1,168 @@
<!-- task=m-runner-proto-socket-transport-hardening/05+02,03,04_socket_smoke plan=0 tag=API -->
# Plan - API
## 이 파일을 읽는 구현 에이전트에게
`CODE_REVIEW-cloud-G07.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우는 것까지가 구현입니다. 검증을 실행하고 실제 stdout/stderr를 붙인 뒤 active 파일을 그대로 두고 review ready로 보고하세요. 선택된 SDD 결정 또는 Milestone `구현 잠금 > 결정 필요` 항목이 막으면 review stub의 `사용자 리뷰 요청` 섹션을 채우고 멈추며, 직접 사용자에게 질문하거나 `USER_REVIEW.md`, `complete.log`, archive를 만들지 않습니다. 환경/secret/서비스 문제나 증거 공백은 일반 follow-up 대상으로 기록합니다.
## 배경
Milestone의 마지막 socket-transport task는 local/remote smoke evidence를 socket-centered 기준으로 고정하는 것이다. 앞선 lifecycle, dispatch/state, runner-actions, compatibility-boundary 결과를 받아 `agent-test/local/agent-smoke.md`, Makefile/test grouping, runner smoke가 HTTP registration/polling을 기본 evidence처럼 쓰지 않도록 정리한다.
## 사용자 리뷰 요청 흐름
사용자 리뷰 요청은 선택된 SDD 결정 또는 선택된 Milestone lock 결정이 실구현을 차단할 때만 active `CODE_REVIEW-cloud-G07.md``사용자 리뷰 요청` 섹션에 기록한다. 구현 중 직접 사용자 프롬프트, 채팅 선택지, `request_user_input`, `USER_REVIEW.md` 생성은 금지이며, code-review가 검증과 파일 생성을 소유한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/control-plane-product-surface/milestones/runner-proto-socket-transport-hardening.md`
- Task ids:
- `socket-smoke`: local/remote smoke 기준을 socket-first runner transport evidence로 갱신한다.
- Completion mode: check-on-pass
- Split dependency:
- `02+01_dispatch_state` PASS complete evidence가 있어야 구현을 시작한다.
- `03+01,02_runner_actions` PASS complete evidence가 있어야 구현을 시작한다.
- `04+01_compat_boundary` PASS complete evidence가 있어야 구현을 시작한다.
## Spec Targets
- SDD: `agent-roadmap/sdd/control-plane-product-surface/runner-proto-socket-transport-hardening/SDD.md`
- Acceptance scenarios:
- `S06`: task=`socket-smoke`; evidence=`agent local smoke와 runner/core smoke가 socket-first lifecycle, dispatch, action, compatibility boundary를 검증한다.`
- Completion mode: spec-check-on-pass
## 분석 결과
### 읽은 파일
- `agent-test/local/rules.md`
- `agent-test/local/agent-smoke.md`
- `agent-test/local/framework-smoke.md`
- `agent-ops/rules/project/domain/agent/rules.md`
- `agent-ops/rules/project/domain/framework/rules.md`
- `agent-roadmap/phase/control-plane-product-surface/milestones/runner-proto-socket-transport-hardening.md`
- `agent-roadmap/sdd/control-plane-product-surface/runner-proto-socket-transport-hardening/SDD.md`
- `Makefile`
- `apps/runner/test/oto_agent_registration_test.dart`
- `apps/runner/test/oto_server_connection_smoke_test.dart`
- `apps/runner/lib/oto/agent/agent_runner.dart`
- `apps/runner/lib/oto/agent/registration_client.dart`
- `services/core/internal/runnersocket/server.go`
- `services/core/internal/httpserver/server_test.go`
### 테스트 환경 규칙
`test_env=local`을 적용했다. `agent-test/local/agent-smoke.md`의 agent 필수 명령, Makefile의 runner test list, Go core full test를 모두 socket-centered evidence로 맞춘다. 이 작업은 앞선 split 결과를 종합하므로 targeted tests만 통과해도 안 되고 smoke 문서 기준과 실제 명령이 함께 맞아야 한다.
### 테스트 커버리지 공백
- `oto_server_connection_smoke_test.dart`의 첫 smoke는 HTTP registration client를 쓰고 있어 socket-first smoke의 대표 evidence로 부적합하다.
- `_startCore`는 socket listener env를 준비하지만 pass criteria가 socket lifecycle/dispatch/action을 명확히 요구하지 않는다.
- `agent-test/local/agent-smoke.md`는 runner transport hardening 전용 pass/fail criteria가 부족하다.
### 심볼 참조
`oto_server_connection_smoke_test.dart`, `_startCore`, `OtoServerSocketRegistrationClient`, `OtoServerPushJobSession`, `Makefile``test-runner`, `agent-test/local/agent-smoke.md`의 runner 명령을 중심으로 확인했다.
### 분할 판단
이 작업은 최종 smoke 정리이므로 `dispatch-state`, `runner-actions`, `compat-boundary` 결과를 모두 기다린다. 선행 task가 완료되지 않았으면 smoke 기준을 고정하지 않는다.
### 범위 결정 근거
이 plan은 smoke 문서/테스트 기준을 정리하고 필요한 작은 test grouping을 보강한다. lifecycle/state/action 자체 구현은 선행 split task에서 끝난 상태를 전제로 한다.
### 빌드 등급
`cloud-G07`: smoke 기준이 이후 agent 작업의 완료 판정을 좌우한다. 문서와 실제 명령이 어긋나면 Milestone 전체가 잘못 완료될 수 있어 전체 local smoke 명령을 요구한다.
## 구현 체크리스트
- [ ] 선행 `02+01_dispatch_state`, `03+01,02_runner_actions`, `04+01_compat_boundary` PASS complete evidence를 확인한다.
- [ ] [API-1] `agent-test/local/agent-smoke.md`와 Makefile/test grouping을 socket-first 기준으로 갱신한다. 검증: `cd apps/runner && dart analyze`
- [ ] [API-2] runner socket smoke가 lifecycle/dispatch/action/compat boundary evidence를 대표하도록 보강한다. 검증: `cd apps/runner && dart test test/oto_server_connection_smoke_test.dart`
- [ ] [API-3] full local smoke 명령을 실행하고 결과를 기록한다. 검증: Go full test와 runner required test list
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [API-1] smoke 문서와 Makefile/test grouping 갱신
문제: [agent-smoke.md](/config/workspace/oto/agent-test/local/agent-smoke.md:50)의 agent smoke 기준과 [Makefile](/config/workspace/oto/Makefile:7)의 runner test list가 socket-transport hardening의 pass criteria를 충분히 드러내지 않는다.
해결 방법:
`agent-test/local/agent-smoke.md`에 runner transport hardening 기준을 추가하거나 기존 agent smoke 명령 설명을 조정한다. Makefile test list가 이미 필요한 runner tests를 포함하면 변경하지 않고 evidence만 명시한다. 문서 변경은 사람이 실행할 수 있는 실제 명령과 일치해야 한다.
수정 파일 및 체크리스트:
- [ ] agent smoke 문서에 socket-first lifecycle/dispatch/action/compatibility evidence 기준 추가
- [ ] Makefile runner test list가 smoke 문서와 어긋나지 않는지 확인
- [ ] 불필요한 test command duplication은 피함
중간 검증:
```bash
cd apps/runner && dart analyze
```
기대 결과: analyzer issue 없음.
### [API-2] runner socket smoke 대표성 보강
문제: [oto_server_connection_smoke_test.dart](/config/workspace/oto/apps/runner/test/oto_server_connection_smoke_test.dart:1)는 server connection smoke를 포함하지만 HTTP registration/polling evidence와 socket evidence가 섞여 있다. Milestone 완료 evidence로는 socket-first scenario가 명확해야 한다.
해결 방법:
socket registration, server-assigned runner id, pushed `RunRequest`, report result, cancel/action or explicit excluded compatibility evidence가 한눈에 보이도록 smoke test names/groups를 정리한다. `_startCore`의 socket listener env를 계속 사용한다.
수정 파일 및 체크리스트:
- [ ] socket smoke group을 대표 evidence로 배치
- [ ] HTTP compatibility smoke는 fallback group으로 분리
- [ ] lifecycle/dispatch/action 선행 tests를 중복하지 않고 smoke-level assertion만 유지
중간 검증:
```bash
cd apps/runner && dart test test/oto_server_connection_smoke_test.dart
```
기대 결과: smoke test 통과.
### [API-3] full local smoke 기록
문제: Milestone 완료는 단일 Dart smoke만으로 충분하지 않다. core socket server/state tests와 runner required tests가 함께 통과해야 한다.
해결 방법:
최종 검증에서 core Go 전체, Dart analyzer, agent-smoke에 명시된 runner tests를 실행하고 CODE_REVIEW에 stdout/stderr를 붙인다.
중간 검증:
```bash
cd services/core && go test -count=1 ./...
cd apps/runner && dart analyze
cd apps/runner && dart test test/oto_agent_migration_plan_test.dart test/oto_agent_bootstrap_script_test.dart test/oto_agent_cli_test.dart test/oto_agent_config_test.dart test/oto_agent_registration_test.dart test/oto_server_connection_smoke_test.dart
```
기대 결과: 모든 명령 통과.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `agent-test/local/agent-smoke.md` | API-1 |
| `Makefile` | API-1 |
| `apps/runner/test/oto_server_connection_smoke_test.dart` | API-2 |
| `apps/runner/test/oto_agent_registration_test.dart` | API-2 |
| `agent-roadmap/sdd/control-plane-product-surface/runner-proto-socket-transport-hardening/SDD.md` | API-1, API-3 |
## 최종 검증
```bash
cd services/core && go test -count=1 ./...
cd apps/runner && dart analyze
cd apps/runner && dart test test/oto_agent_migration_plan_test.dart test/oto_agent_bootstrap_script_test.dart test/oto_agent_cli_test.dart test/oto_agent_config_test.dart test/oto_agent_registration_test.dart test/oto_server_connection_smoke_test.dart
```
기대 결과: core Go full test 통과, Dart analyzer 통과, agent required tests 통과. 모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -3,6 +3,7 @@ Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$serverUrl = '' $serverUrl = ''
$socketUrl = ''
$agentId = '' $agentId = ''
$enrollmentToken = '' $enrollmentToken = ''
$releaseBaseUrl = '' $releaseBaseUrl = ''
@ -18,6 +19,7 @@ while ($i -lt $args.Count) {
switch ($args[$i]) { switch ($args[$i]) {
'--' { $i++ } '--' { $i++ }
'--server-url' { $serverUrl = $args[$i + 1]; $i += 2 } '--server-url' { $serverUrl = $args[$i + 1]; $i += 2 }
'--socket-url' { $socketUrl = $args[$i + 1]; $i += 2 }
'--agent-id' { $agentId = $args[$i + 1]; $i += 2 } '--agent-id' { $agentId = $args[$i + 1]; $i += 2 }
'--enrollment-token' { $enrollmentToken = $args[$i + 1]; $i += 2 } '--enrollment-token' { $enrollmentToken = $args[$i + 1]; $i += 2 }
'--release-base-url' { $releaseBaseUrl = $args[$i + 1]; $i += 2 } '--release-base-url' { $releaseBaseUrl = $args[$i + 1]; $i += 2 }
@ -82,9 +84,14 @@ try {
$agentAliasYaml = $agentAlias.Replace("'", "''") $agentAliasYaml = $agentAlias.Replace("'", "''")
$enrollmentTokenYaml = $enrollmentToken.Replace("'", "''") $enrollmentTokenYaml = $enrollmentToken.Replace("'", "''")
$serverUrlYaml = $serverUrl.Replace("'", "''") $serverUrlYaml = $serverUrl.Replace("'", "''")
$socketUrlYaml = $socketUrl.Replace("'", "''")
$installDirYaml = ($installDir -replace '\\', '/').Replace("'", "''") $installDirYaml = ($installDir -replace '\\', '/').Replace("'", "''")
$workspaceRootYaml = ($workspaceRoot -replace '\\', '/').Replace("'", "''") $workspaceRootYaml = ($workspaceRoot -replace '\\', '/').Replace("'", "''")
$logDirYaml = ($logDir -replace '\\', '/').Replace("'", "''") $logDirYaml = ($logDir -replace '\\', '/').Replace("'", "''")
$socketUrlLine = ''
if ($socketUrl) {
$socketUrlLine = " socket_url: '$socketUrlYaml'`n"
}
$configContent = @" $configContent = @"
agent: agent:
@ -93,6 +100,7 @@ agent:
enrollment_token: '$enrollmentTokenYaml' enrollment_token: '$enrollmentTokenYaml'
server: server:
url: '$serverUrlYaml' url: '$serverUrlYaml'
$socketUrlLine
runtime: runtime:
install_dir: '$installDirYaml' install_dir: '$installDirYaml'
workspace_root: '$workspaceRootYaml' workspace_root: '$workspaceRootYaml'

View file

@ -2,6 +2,7 @@
set -euo pipefail set -euo pipefail
server_url="" server_url=""
socket_url=""
agent_id="" agent_id=""
enrollment_token="" enrollment_token=""
release_base_url="" release_base_url=""
@ -13,8 +14,9 @@ log_dir="${HOME}/.oto/agent/log"
background="true" background="true"
usage() { usage() {
echo "Usage: $0 --server-url <url> --agent-id <id> --enrollment-token <token> --release-base-url <url> [options]" echo "Usage: $0 --server-url <url> --socket-url <url> --agent-id <id> --enrollment-token <token> --release-base-url <url> [options]"
echo "Options:" echo "Options:"
echo " --socket-url <url> Runner proto-socket URL (default: derived by agent)"
echo " --agent-alias <alias> Alias for the agent" echo " --agent-alias <alias> Alias for the agent"
echo " --install-dir <path> Directory to install OTO agent (default: \$HOME/.oto/bin)" echo " --install-dir <path> Directory to install OTO agent (default: \$HOME/.oto/bin)"
echo " --config-path <path> Path to config file (default: \$HOME/.oto/agent/config.yaml)" echo " --config-path <path> Path to config file (default: \$HOME/.oto/agent/config.yaml)"
@ -39,6 +41,10 @@ while [[ $# -gt 0 ]]; do
server_url="$2" server_url="$2"
shift 2 shift 2
;; ;;
--socket-url)
socket_url="$2"
shift 2
;;
--agent-id) --agent-id)
agent_id="$2" agent_id="$2"
shift 2 shift 2
@ -155,6 +161,11 @@ agent:
enrollment_token: "$(yaml_escape "$enrollment_token")" enrollment_token: "$(yaml_escape "$enrollment_token")"
server: server:
url: "$(yaml_escape "$server_url")" url: "$(yaml_escape "$server_url")"
EOF
if [[ -n "$socket_url" ]]; then
printf ' socket_url: "%s"\n' "$(yaml_escape "$socket_url")" >> "$config_path"
fi
cat <<EOF >> "$config_path"
runtime: runtime:
install_dir: "$(yaml_escape "$install_dir")" install_dir: "$(yaml_escape "$install_dir")"
workspace_root: "$(yaml_escape "$workspace_root")" workspace_root: "$(yaml_escape "$workspace_root")"

View file

@ -23,12 +23,13 @@ class AgentIdentityConfig {
class ServerConnectionConfig { class ServerConnectionConfig {
final String url; final String url;
final String? socketUrl;
const ServerConnectionConfig({required this.url}); const ServerConnectionConfig({required this.url, this.socketUrl});
} }
class EdgeConnectionConfig extends ServerConnectionConfig { class EdgeConnectionConfig extends ServerConnectionConfig {
const EdgeConnectionConfig({required super.url}); const EdgeConnectionConfig({required super.url, super.socketUrl});
} }
class AgentRuntimeConfig { class AgentRuntimeConfig {
@ -55,7 +56,8 @@ class AgentConfig {
required this.runtime, required this.runtime,
}) : server = server ?? edge!; }) : server = server ?? edge!;
EdgeConnectionConfig get edge => EdgeConnectionConfig(url: server.url); EdgeConnectionConfig get edge =>
EdgeConnectionConfig(url: server.url, socketUrl: server.socketUrl);
static String _requiredString(Map map, String sectionName, String key) { static String _requiredString(Map map, String sectionName, String key) {
if (!map.containsKey(key)) { if (!map.containsKey(key)) {
@ -146,6 +148,11 @@ class AgentConfig {
} }
final serverUrl = _requiredString(connectionMap, connectionSection, 'url'); final serverUrl = _requiredString(connectionMap, connectionSection, 'url');
final socketUrl = _optionalString(
connectionMap,
connectionSection,
'socket_url',
);
final runtimeMap = root['runtime']; final runtimeMap = root['runtime'];
if (runtimeMap is! Map) { if (runtimeMap is! Map) {
@ -166,7 +173,7 @@ class AgentConfig {
alias: alias, alias: alias,
enrollmentToken: enrollmentToken, enrollmentToken: enrollmentToken,
), ),
server: ServerConnectionConfig(url: serverUrl), server: ServerConnectionConfig(url: serverUrl, socketUrl: socketUrl),
runtime: AgentRuntimeConfig( runtime: AgentRuntimeConfig(
installDir: installDir, installDir: installDir,
workspaceRoot: workspaceRoot, workspaceRoot: workspaceRoot,

View file

@ -32,7 +32,7 @@ class DefaultAgentRunner implements AgentRunner {
Future<void>? shutdownFuture, Future<void>? shutdownFuture,
}) : _client = }) : _client =
client ?? client ??
OtoServerRegistrationClient( OtoServerSocketRegistrationClient(
capabilityProvider: const CommandCatalogRunnerCapabilityProvider(), capabilityProvider: const CommandCatalogRunnerCapabilityProvider(),
), ),
_onLog = onLog, _onLog = onLog,
@ -71,20 +71,67 @@ class DefaultAgentRunner implements AgentRunner {
// _awaitShutdown so signal listeners are not duplicated. // _awaitShutdown so signal listeners are not duplicated.
final shutdownFuture = _shutdownFuture ?? _buildSignalShutdownFuture(); final shutdownFuture = _shutdownFuture ?? _buildSignalShutdownFuture();
var loopWaitedForShutdown = false;
if (_runJobs != null) { if (_runJobs != null) {
await _runJobs(config, session); await _runJobs(config, session);
} else if (session is OtoServerPushJobSession) {
await _pushJobLoop(config, session, shutdownFuture);
loopWaitedForShutdown = true;
} else if (session is OtoServerJobSession) { } else if (session is OtoServerJobSession) {
// Default: run remote job loop when no custom job loop is injected. // Default: run remote job loop when no custom job loop is injected.
await _defaultJobLoop(config, session, shutdownFuture); await _defaultJobLoop(config, session, shutdownFuture);
loopWaitedForShutdown = true;
} }
_log('Agent connected. Waiting for shutdown signal...'); if (!loopWaitedForShutdown) {
await shutdownFuture; _log('Agent connected. Waiting for shutdown signal...');
await shutdownFuture;
}
} finally { } finally {
await session.close(); await session.close();
} }
} }
/// Default proto-socket job loop: the server pushes RunRequest messages over
/// the open runner socket. Jobs are executed sequentially per agent process.
Future<void> _pushJobLoop(
AgentConfig config,
OtoServerPushJobSession session,
Future<void> shutdownFuture,
) async {
final executor = RemoteRunExecutor(
parser: RunRequestParser(workspaceRoot: config.runtime.workspaceRoot),
jobClient: session.jobReporter,
onLog: _log,
);
var runChain = Future<void>.value();
final subscription = session.runRequests.listen((request) {
runChain = runChain.then((_) async {
if (request.jobId.isEmpty || request.executionId.isEmpty) {
_log('Received RunRequest without job_id or execution_id; skipping.');
return;
}
final buildResult = await executor.runOnce(
jobId: request.jobId,
executionId: request.executionId,
runRequest: request,
);
if (!buildResult.success) {
_log(
'Job ${request.jobId} failed: '
'${buildResult.message}',
);
}
});
});
_log('Agent connected via proto-socket. Waiting for pushed jobs...');
await shutdownFuture;
await subscription.cancel();
await runChain;
}
/// Default remote job loop: claim -> execute -> report, polling the /// Default remote job loop: claim -> execute -> report, polling the
/// OTO Server for new jobs. Uses [runOnce] so tests can drive a single /// OTO Server for new jobs. Uses [runOnce] so tests can drive a single
/// iteration without an infinite loop. /// iteration without an infinite loop.

View file

@ -5,6 +5,7 @@ import 'package:http/http.dart' as http;
import 'package:oto/oto/agent/oto/runner.pb.dart' as oto; import 'package:oto/oto/agent/oto/runner.pb.dart' as oto;
import 'package:oto/oto/core/build_result.dart'; import 'package:oto/oto/core/build_result.dart';
import 'package:oto/oto/core/execution_context.dart'; import 'package:oto/oto/core/execution_context.dart';
import 'package:proto_socket/proto_socket.dart';
class JobClaimResult { class JobClaimResult {
final bool accepted; final bool accepted;
@ -123,8 +124,13 @@ class RunnerStatusResult {
accepted: json['accepted'] == true, accepted: json['accepted'] == true,
runnerId: (json['runner_id'] ?? json['runnerId'] ?? '').toString(), runnerId: (json['runner_id'] ?? json['runnerId'] ?? '').toString(),
status: (json['status'] ?? '').toString(), status: (json['status'] ?? '').toString(),
currentJobId: _emptyToNull((json['current_job_id'] ?? json['currentJobId'] ?? '').toString()), currentJobId: _emptyToNull(
currentExecutionId: _emptyToNull((json['current_execution_id'] ?? json['currentExecutionId'] ?? '').toString()), (json['current_job_id'] ?? json['currentJobId'] ?? '').toString(),
),
currentExecutionId: _emptyToNull(
(json['current_execution_id'] ?? json['currentExecutionId'] ?? '')
.toString(),
),
message: _emptyToNull((json['message'] ?? '').toString()), message: _emptyToNull((json['message'] ?? '').toString()),
); );
} }
@ -150,13 +156,36 @@ class RunnerSelfUpdateResult {
success: json['success'] == true, success: json['success'] == true,
accepted: json['accepted'] == true, accepted: json['accepted'] == true,
deferred: json['deferred'] == true, deferred: json['deferred'] == true,
targetVersion: _emptyToNull((json['target_version'] ?? json['targetVersion'] ?? '').toString()), targetVersion: _emptyToNull(
(json['target_version'] ?? json['targetVersion'] ?? '').toString(),
),
message: _emptyToNull((json['message'] ?? '').toString()), message: _emptyToNull((json['message'] ?? '').toString()),
); );
} }
} }
class OtoServerJobClient { abstract class RemoteJobReporter {
Future<ExecutionReportResult> reportExecution({
required String jobId,
required String executionId,
required BuildResult result,
});
Future<void> appendLog({required String executionId, required String line});
Future<void> reportArtifact({
required String executionId,
required String name,
required String path,
});
Future<void> reportArtifacts({
required String executionId,
required List<BuildArtifact> artifacts,
});
}
class OtoServerJobClient implements RemoteJobReporter {
final String serverUrl; final String serverUrl;
final String runnerId; final String runnerId;
final http.Client _client; final http.Client _client;
@ -198,6 +227,7 @@ class OtoServerJobClient {
return claimJob(jobId: '', executionId: ''); return claimJob(jobId: '', executionId: '');
} }
@override
Future<ExecutionReportResult> reportExecution({ Future<ExecutionReportResult> reportExecution({
required String jobId, required String jobId,
required String executionId, required String executionId,
@ -218,6 +248,7 @@ class OtoServerJobClient {
return ExecutionReportResult.fromJson(response); return ExecutionReportResult.fromJson(response);
} }
@override
Future<void> appendLog({ Future<void> appendLog({
required String executionId, required String executionId,
required String line, required String line,
@ -244,6 +275,7 @@ class OtoServerJobClient {
} }
} }
@override
Future<void> reportArtifact({ Future<void> reportArtifact({
required String executionId, required String executionId,
required String name, required String name,
@ -276,6 +308,7 @@ class OtoServerJobClient {
/// ///
/// Stops and rethrows on the first failure so the caller can surface the HTTP /// Stops and rethrows on the first failure so the caller can surface the HTTP
/// error; partial reporting is preferred over silently swallowing failures. /// error; partial reporting is preferred over silently swallowing failures.
@override
Future<void> reportArtifacts({ Future<void> reportArtifacts({
required String executionId, required String executionId,
required List<BuildArtifact> artifacts, required List<BuildArtifact> artifacts,
@ -306,9 +339,7 @@ class OtoServerJobClient {
} }
Future<RunnerStatusResult> fetchRunnerStatus() async { Future<RunnerStatusResult> fetchRunnerStatus() async {
final response = await _getJson( final response = await _getJson(_runnerUri('/status'));
_runnerUri('/status'),
);
return RunnerStatusResult.fromJson(response); return RunnerStatusResult.fromJson(response);
} }
@ -321,10 +352,7 @@ class OtoServerJobClient {
'version': version, 'version': version,
'download_url': downloadUrl, 'download_url': downloadUrl,
}; };
final response = await _postJson( final response = await _postJson(_runnerUri('/self-update'), body);
_runnerUri('/self-update'),
body,
);
return RunnerSelfUpdateResult.fromJson(response); return RunnerSelfUpdateResult.fromJson(response);
} }
@ -369,10 +397,7 @@ class OtoServerJobClient {
int expectedStatus = HttpStatus.ok, int expectedStatus = HttpStatus.ok,
}) async { }) async {
final response = await _client final response = await _client
.get( .get(uri, headers: {'accept': 'application/json'})
uri,
headers: {'accept': 'application/json'},
)
.timeout(const Duration(seconds: 5)); .timeout(const Duration(seconds: 5));
if (response.statusCode != expectedStatus) { if (response.statusCode != expectedStatus) {
throw HttpException( throw HttpException(
@ -387,6 +412,101 @@ class OtoServerJobClient {
} }
} }
class OtoServerSocketJobClient implements RemoteJobReporter {
final String runnerId;
final ProtobufClient _socket;
OtoServerSocketJobClient({
required this.runnerId,
required ProtobufClient socket,
}) : _socket = socket;
@override
Future<ExecutionReportResult> reportExecution({
required String jobId,
required String executionId,
required BuildResult result,
}) async {
final request = oto.ExecutionReportRequest()
..runnerId = runnerId
..jobId = jobId
..executionId = executionId
..success = result.success
..exitCode = result.exitCode
..message = result.message
..stepEvents.addAll(result.stepEvents.map(_stepEventReport));
final response = await _socket
.sendRequest<oto.ExecutionReportRequest, oto.ExecutionReportResponse>(
request,
);
return ExecutionReportResult(
accepted: response.accepted,
errorMessage: _emptyToNull(response.errorMessage),
jobId: response.jobId,
executionId: response.executionId,
state: response.state,
);
}
@override
Future<void> appendLog({
required String executionId,
required String line,
}) async {
final request = oto.LogAppendRequest()
..runnerId = runnerId
..executionId = executionId
..line = line;
final response = await _socket
.sendRequest<oto.LogAppendRequest, oto.LogAppendResponse>(request);
if (!response.accepted) {
throw HttpException(
response.errorMessage.isNotEmpty
? response.errorMessage
: 'log rejected',
);
}
}
@override
Future<void> reportArtifact({
required String executionId,
required String name,
required String path,
}) async {
final request = oto.ArtifactReportRequest()
..runnerId = runnerId
..executionId = executionId
..name = name
..path = path;
final response = await _socket
.sendRequest<oto.ArtifactReportRequest, oto.ArtifactReportResponse>(
request,
);
if (!response.accepted) {
throw HttpException(
response.errorMessage.isNotEmpty
? response.errorMessage
: 'artifact rejected',
);
}
}
@override
Future<void> reportArtifacts({
required String executionId,
required List<BuildArtifact> artifacts,
}) async {
for (final artifact in artifacts) {
await reportArtifact(
executionId: executionId,
name: artifact.name,
path: artifact.path,
);
}
}
}
Uri _apiUri(String serverUrl, String endpointPath) { Uri _apiUri(String serverUrl, String endpointPath) {
var raw = serverUrl; var raw = serverUrl;
if (!raw.contains('://')) { if (!raw.contains('://')) {

View file

@ -7,6 +7,8 @@ import 'package:oto/oto/agent/agent_config.dart';
import 'package:oto/oto/agent/oto_server_job_client.dart'; import 'package:oto/oto/agent/oto_server_job_client.dart';
import 'package:oto/oto/agent/oto/runner.pb.dart' as oto; import 'package:oto/oto/agent/oto/runner.pb.dart' as oto;
import 'package:oto/oto/agent/runner_capability_provider.dart'; import 'package:oto/oto/agent/runner_capability_provider.dart';
import 'package:protobuf/protobuf.dart';
import 'package:proto_socket/proto_socket.dart';
const otoRunnerProtocolVersion = 'oto.runner.v1'; const otoRunnerProtocolVersion = 'oto.runner.v1';
const otoRunnerCapabilityName = 'oto-runner'; const otoRunnerCapabilityName = 'oto-runner';
@ -98,6 +100,11 @@ abstract class OtoServerJobSession implements AgentSession {
OtoServerJobClient get jobs; OtoServerJobClient get jobs;
} }
abstract class OtoServerPushJobSession implements AgentSession {
RemoteJobReporter get jobReporter;
Stream<oto.RunRequest> get runRequests;
}
abstract class RegistrationClient { abstract class RegistrationClient {
Future<AgentSession> openSession(AgentConfig config); Future<AgentSession> openSession(AgentConfig config);
@ -111,6 +118,62 @@ abstract class RegistrationClient {
} }
} }
class OtoServerSocketRegistrationClient extends RegistrationClient {
final RunnerCapabilityProvider _capabilityProvider;
final Duration _heartbeatInterval;
OtoServerSocketRegistrationClient({
Iterable<String>? commandTypes,
RunnerCapabilityProvider? capabilityProvider,
Duration heartbeatInterval = const Duration(seconds: 30),
}) : _capabilityProvider =
capabilityProvider ??
_StaticRunnerCapabilityProvider(commandTypes ?? const []),
_heartbeatInterval = heartbeatInterval;
@override
Future<AgentSession> openSession(AgentConfig config) async {
final endpoint = _RunnerSocketEndpoint.fromConfig(config.server);
final socket = await ProtobufClient.connect(
endpoint.host,
endpoint.port,
).timeout(const Duration(seconds: 5));
final client = _OtoRunnerSocketClient(socket);
try {
final response = await client
.sendRequest<oto.RegisterRunnerRequest, oto.RegisterRunnerResponse>(
buildRegisterRunnerRequest(config),
timeout: const Duration(seconds: 5),
);
final result = RegistrationResult.fromOtoResponse(response);
final runnerId = result.runnerId ?? config.agent.id;
return _OtoServerSocketAgentSession(
result,
client,
runnerId,
heartbeatInterval: _heartbeatInterval,
);
} catch (_) {
await client.close();
rethrow;
}
}
oto.RegisterRunnerRequest buildRegisterRunnerRequest(AgentConfig config) {
final capability = _capabilityProvider.snapshot();
return oto.RegisterRunnerRequest()
..enrollmentToken = config.agent.enrollmentToken
..runnerId = config.agent.id
..alias = config.agent.alias ?? ''
..protocolVersion = otoRunnerProtocolVersion
..capability = (oto.RunnerCapability()
..name = capability.name
..version = capability.version)
..commandCatalog = (oto.CommandCatalogSummary()
..commandTypes.addAll(capability.commandTypes));
}
}
class OtoServerRegistrationClient extends RegistrationClient { class OtoServerRegistrationClient extends RegistrationClient {
final http.Client? _client; final http.Client? _client;
final RunnerCapabilityProvider _capabilityProvider; final RunnerCapabilityProvider _capabilityProvider;
@ -150,12 +213,13 @@ class OtoServerRegistrationClient extends RegistrationClient {
throw const FormatException('Invalid registration response body'); throw const FormatException('Invalid registration response body');
} }
final result = RegistrationResult.fromOtoJson(decoded); final result = RegistrationResult.fromOtoJson(decoded);
final runnerId = result.runnerId ?? config.agent.id;
return _OtoServerAgentSession( return _OtoServerAgentSession(
result, result,
client, client,
shouldCloseClient, shouldCloseClient,
config.server.url, config.server.url,
config.agent.id, runnerId,
heartbeatInterval: _heartbeatInterval, heartbeatInterval: _heartbeatInterval,
); );
} catch (_) { } catch (_) {
@ -245,6 +309,133 @@ class OtoServerRegistrationClient extends RegistrationClient {
}; };
} }
class _OtoRunnerSocketClient extends ProtobufClient {
_OtoRunnerSocketClient(Socket socket)
: super(socket, 30, 45, _otoRunnerSocketParserMap());
}
class _OtoServerSocketAgentSession implements OtoServerPushJobSession {
final _OtoRunnerSocketClient _client;
final String _runnerId;
final StreamController<oto.RunRequest> _runRequests =
StreamController<oto.RunRequest>();
Timer? _heartbeatTimer;
bool _closed = false;
@override
final RegistrationResult result;
@override
late final RemoteJobReporter jobReporter;
@override
Stream<oto.RunRequest> get runRequests => _runRequests.stream;
_OtoServerSocketAgentSession(
this.result,
this._client,
this._runnerId, {
required Duration heartbeatInterval,
}) {
jobReporter = OtoServerSocketJobClient(
runnerId: _runnerId,
socket: _client,
);
_client.addListener<oto.RunRequest>((request) {
if (!_runRequests.isClosed) {
_runRequests.add(request);
}
});
if (result.accepted) {
_heartbeatTimer = Timer.periodic(
heartbeatInterval,
(_) => _sendHeartbeat(),
);
scheduleMicrotask(() => _sendHeartbeat());
}
}
Future<void> _sendHeartbeat() async {
if (_closed) return;
try {
await _client.sendRequest<oto.HeartbeatRequest, oto.HeartbeatResponse>(
oto.HeartbeatRequest()
..runnerId = _runnerId
..status = oto.HeartbeatStatus.HEARTBEAT_STATUS_HEALTHY,
timeout: const Duration(seconds: 5),
);
} catch (_) {
// Ignore background heartbeat errors; proto-socket heartbeat owns liveness.
}
}
@override
Future<void> close() async {
if (_closed) return;
_closed = true;
_heartbeatTimer?.cancel();
_heartbeatTimer = null;
unawaited(_runRequests.close());
await _client.close();
}
}
class _RunnerSocketEndpoint {
final String host;
final int port;
const _RunnerSocketEndpoint({required this.host, required this.port});
factory _RunnerSocketEndpoint.fromConfig(ServerConnectionConfig config) {
final raw = config.socketUrl ?? _deriveSocketUrl(config.url);
var parsedRaw = raw;
if (!parsedRaw.contains('://')) {
parsedRaw = 'tcp://$parsedRaw';
}
final uri = Uri.parse(parsedRaw);
if (uri.host.isEmpty) {
throw FormatException('Invalid runner socket url: "$raw"');
}
return _RunnerSocketEndpoint(
host: uri.host,
port: uri.hasPort ? uri.port : 18080,
);
}
static String _deriveSocketUrl(String serverUrl) {
var raw = serverUrl;
if (!raw.contains('://')) {
raw = 'http://$raw';
}
final uri = Uri.parse(raw);
if (uri.host.isEmpty) {
throw FormatException('Invalid server url: "$serverUrl"');
}
return 'tcp://${uri.host}:18080';
}
}
Map<String, GeneratedMessage Function(List<int>)> _otoRunnerSocketParserMap() {
return {
oto.RegisterRunnerResponse.getDefault().info_.qualifiedMessageName:
oto.RegisterRunnerResponse.fromBuffer,
oto.HeartbeatResponse.getDefault().info_.qualifiedMessageName:
oto.HeartbeatResponse.fromBuffer,
oto.RunRequest.getDefault().info_.qualifiedMessageName:
oto.RunRequest.fromBuffer,
oto.ExecutionReportResponse.getDefault().info_.qualifiedMessageName:
oto.ExecutionReportResponse.fromBuffer,
oto.LogAppendResponse.getDefault().info_.qualifiedMessageName:
oto.LogAppendResponse.fromBuffer,
oto.ArtifactReportResponse.getDefault().info_.qualifiedMessageName:
oto.ArtifactReportResponse.fromBuffer,
oto.CancelRunRequest.getDefault().info_.qualifiedMessageName:
oto.CancelRunRequest.fromBuffer,
oto.SelfUpdateRequest.getDefault().info_.qualifiedMessageName:
oto.SelfUpdateRequest.fromBuffer,
};
}
class _StaticRunnerCapabilityProvider implements RunnerCapabilityProvider { class _StaticRunnerCapabilityProvider implements RunnerCapabilityProvider {
final List<String> _commandTypes; final List<String> _commandTypes;

View file

@ -53,12 +53,16 @@ class RunRequestParser {
} }
throw ArgumentError( throw ArgumentError(
'RunRequest has neither pipelineYaml nor pipelineYamlPath.'); 'RunRequest has neither pipelineYaml nor pipelineYamlPath.',
);
} }
String _resolvePathConfinement(String rawPath) { String _resolvePathConfinement(String rawPath) {
return resolveWithinWorkspace(rawPath, workspaceRoot, return resolveWithinWorkspace(
label: 'pipelineYamlPath'); rawPath,
workspaceRoot,
label: 'pipelineYamlPath',
);
} }
Map<String, String> _toMap(Map<String, String> fields) { Map<String, String> _toMap(Map<String, String> fields) {
@ -74,8 +78,11 @@ class RunRequestParser {
/// ///
/// `path.equals`/`path.isWithin` correctly reject sibling-prefix attacks such as /// `path.equals`/`path.isWithin` correctly reject sibling-prefix attacks such as
/// `/var/workspace2` against a root of `/var/workspace`. /// `/var/workspace2` against a root of `/var/workspace`.
String resolveWithinWorkspace(String rawPath, String workspaceRoot, String resolveWithinWorkspace(
{required String label}) { String rawPath,
String workspaceRoot, {
required String label,
}) {
final canonicalRoot = path.canonicalize(workspaceRoot); final canonicalRoot = path.canonicalize(workspaceRoot);
final absolute = path.canonicalize( final absolute = path.canonicalize(
path.isAbsolute(rawPath) ? rawPath : path.join(canonicalRoot, rawPath), path.isAbsolute(rawPath) ? rawPath : path.join(canonicalRoot, rawPath),
@ -83,7 +90,8 @@ String resolveWithinWorkspace(String rawPath, String workspaceRoot,
if (!path.equals(canonicalRoot, absolute) && if (!path.equals(canonicalRoot, absolute) &&
!path.isWithin(canonicalRoot, absolute)) { !path.isWithin(canonicalRoot, absolute)) {
throw ArgumentError( throw ArgumentError(
'$label escapes workspace: "$rawPath" resolved to "$absolute"'); '$label escapes workspace: "$rawPath" resolved to "$absolute"',
);
} }
return absolute; return absolute;
} }
@ -95,26 +103,26 @@ String resolveWithinWorkspace(String rawPath, String workspaceRoot,
/// infinite loop. /// infinite loop.
class RemoteRunExecutor { class RemoteRunExecutor {
final RunRequestParser _parser; final RunRequestParser _parser;
final OtoServerJobClient _jobClient; final RemoteJobReporter _jobClient;
final void Function(String)? _onLog; final void Function(String)? _onLog;
final Duration pollInterval; final Duration pollInterval;
RemoteRunExecutor({ RemoteRunExecutor({
required RunRequestParser parser, required RunRequestParser parser,
required OtoServerJobClient jobClient, required RemoteJobReporter jobClient,
void Function(String)? onLog, void Function(String)? onLog,
this.pollInterval = const Duration(seconds: 2), this.pollInterval = const Duration(seconds: 2),
}) : _parser = parser, }) : _parser = parser,
_jobClient = jobClient, _jobClient = jobClient,
_onLog = onLog; _onLog = onLog;
/// Executes a single job identified by [jobId] and [executionId]. /// Executes a single job identified by [jobId] and [executionId].
/// ///
/// Flow: /// Flow:
/// 1. Parse the [RunRequest] if this fails, a failure report is sent. /// 1. Parse the [RunRequest] if this fails, a failure report is sent.
/// 2. Log "run started" to server via [OtoServerJobClient.appendLog]. /// 2. Log "run started" to server via [RemoteJobReporter.appendLog].
/// 3. Run [Application.build] with the resolved YAML + merged variables. /// 3. Run [Application.build] with the resolved YAML + merged variables.
/// 4. Always call [OtoServerJobClient.reportExecution] with the result /// 4. Always call [RemoteJobReporter.reportExecution] with the result
/// (success or failure), including failures from steps 13. /// (success or failure), including failures from steps 13.
/// ///
/// Returns the [BuildResult] regardless of success or failure. /// Returns the [BuildResult] regardless of success or failure.
@ -221,7 +229,8 @@ class RemoteRunExecutor {
if (declarations == null) return const []; if (declarations == null) return const [];
if (declarations is! List) { if (declarations is! List) {
throw ArgumentError( throw ArgumentError(
'property.artifacts must be a list of {name, path} maps.'); 'property.artifacts must be a list of {name, path} maps.',
);
} }
final result = <BuildArtifact>[]; final result = <BuildArtifact>[];
@ -229,21 +238,27 @@ class RemoteRunExecutor {
final entry = declarations[i]; final entry = declarations[i];
if (entry is! Map) { if (entry is! Map) {
throw ArgumentError( throw ArgumentError(
'property.artifacts[$i] must be a map with name and path.'); 'property.artifacts[$i] must be a map with name and path.',
);
} }
final name = entry['name']; final name = entry['name'];
if (name is! String || name.trim().isEmpty) { if (name is! String || name.trim().isEmpty) {
throw ArgumentError( throw ArgumentError(
'property.artifacts[$i] "name" must be a non-empty string.'); 'property.artifacts[$i] "name" must be a non-empty string.',
);
} }
final declaredPath = entry['path']; final declaredPath = entry['path'];
if (declaredPath is! String || declaredPath.trim().isEmpty) { if (declaredPath is! String || declaredPath.trim().isEmpty) {
throw ArgumentError( throw ArgumentError(
'property.artifacts[$i] (name: "$name") "path" must be a non-empty string.'); 'property.artifacts[$i] (name: "$name") "path" must be a non-empty string.',
);
} }
// Confine the declared path; the declared form is kept for reporting. // Confine the declared path; the declared form is kept for reporting.
resolveWithinWorkspace(declaredPath, workspaceRoot, resolveWithinWorkspace(
label: 'property.artifacts[$i] path'); declaredPath,
workspaceRoot,
label: 'property.artifacts[$i] path',
);
result.add(BuildArtifact(name: name, path: declaredPath)); result.add(BuildArtifact(name: name, path: declaredPath));
} }
return result; return result;
@ -304,4 +319,4 @@ class RemoteRunExecutor {
print('[RemoteRunExecutor] $message'); print('[RemoteRunExecutor] $message');
} }
} }
} }

View file

@ -24,12 +24,7 @@ void main() {
final serverAddr = '$_host:$port'; final serverAddr = '$_host:$port';
// Start Go OTO Server in background // Start Go OTO Server in background
final serverProcess = await Process.start( final serverProcess = await _startCore(serverAddr);
'go',
['run', './cmd/oto-core'],
workingDirectory: '../../services/core',
environment: {'OTO_CORE_ADDR': serverAddr},
);
final output = StringBuffer(); final output = StringBuffer();
final stdoutSub = serverProcess.stdout final stdoutSub = serverProcess.stdout
@ -150,14 +145,9 @@ void main() {
final serverAddr = '$_host:$port'; final serverAddr = '$_host:$port';
// Start Go OTO Server in background // Start Go OTO Server in background
final serverProcess = await Process.start( final serverProcess = await _startCore(
'go', serverAddr,
['run', './cmd/oto-core'], releaseBaseUrl: 'https://example.com/releases',
workingDirectory: '../../services/core',
environment: {
'OTO_CORE_ADDR': serverAddr,
'OTO_RUNNER_RELEASE_BASE_URL': 'https://example.com/releases',
},
); );
final output = StringBuffer(); final output = StringBuffer();
@ -355,12 +345,7 @@ fi
const jobId = 'oto-smoke-job'; const jobId = 'oto-smoke-job';
const executionId = 'oto-smoke-execution'; const executionId = 'oto-smoke-execution';
final serverProcess = await Process.start( final serverProcess = await _startCore(serverAddr);
'go',
['run', './cmd/oto-core'],
workingDirectory: '../../services/core',
environment: {'OTO_CORE_ADDR': serverAddr},
);
final output = StringBuffer(); final output = StringBuffer();
final stdoutSub = serverProcess.stdout final stdoutSub = serverProcess.stdout
@ -538,12 +523,7 @@ fi
const jobId = 'remote-smoke-job'; const jobId = 'remote-smoke-job';
const executionId = 'remote-smoke-exec'; const executionId = 'remote-smoke-exec';
final serverProcess = await Process.start( final serverProcess = await _startCore(serverAddr);
'go',
['run', './cmd/oto-core'],
workingDirectory: '../../services/core',
environment: {'OTO_CORE_ADDR': serverAddr},
);
final output = StringBuffer(); final output = StringBuffer();
final stdoutSub = serverProcess.stdout final stdoutSub = serverProcess.stdout
@ -574,8 +554,11 @@ fi
commandTypes: ['Shell', 'Git'], commandTypes: ['Shell', 'Git'],
); );
final session = await regClient.openSession(agentConfig); final session = await regClient.openSession(agentConfig);
expect(session.result.accepted, isTrue, expect(
reason: 'Registration was rejected'); session.result.accepted,
isTrue,
reason: 'Registration was rejected',
);
final jobs = (session as OtoServerJobSession).jobs; final jobs = (session as OtoServerJobSession).jobs;
final httpClient = http.Client(); final httpClient = http.Client();
@ -605,8 +588,11 @@ pipeline:
}, },
}), }),
); );
expect(createResp.statusCode, equals(201), expect(
reason: 'Create job failed: ${createResp.body}'); createResp.statusCode,
equals(201),
reason: 'Create job failed: ${createResp.body}',
);
final claim = await jobs.claimJob( final claim = await jobs.claimJob(
jobId: jobId, jobId: jobId,
@ -629,8 +615,11 @@ pipeline:
runRequest: claim.runRequest!, runRequest: claim.runRequest!,
); );
expect(buildResult.success, isTrue, expect(
reason: 'Execution failed: ${buildResult.message}'); buildResult.success,
isTrue,
reason: 'Execution failed: ${buildResult.message}',
);
expect(buildResult.exitCode, 0); expect(buildResult.exitCode, 0);
expect(buildResult.stepEvents, isNotEmpty); expect(buildResult.stepEvents, isNotEmpty);
@ -642,12 +631,10 @@ pipeline:
final logsResp = await httpClient.get( final logsResp = await httpClient.get(
Uri.parse('http://$serverAddr/api/v1/executions/$executionId/logs'), Uri.parse('http://$serverAddr/api/v1/executions/$executionId/logs'),
); );
final logsJson = final logsJson = jsonDecode(logsResp.body) as Map<String, dynamic>;
jsonDecode(logsResp.body) as Map<String, dynamic>;
final logsList = logsJson['logs'] as List<dynamic>; final logsList = logsJson['logs'] as List<dynamic>;
final lines = logsList final lines = logsList
.map((e) => .map((e) => (e as Map<String, dynamic>)['Line'] ?? e['line'])
(e as Map<String, dynamic>)['Line'] ?? e['line'])
.map((l) => l.toString()) .map((l) => l.toString())
.toList(); .toList();
expect(lines, contains('run started for job $jobId')); expect(lines, contains('run started for job $jobId'));
@ -655,12 +642,10 @@ pipeline:
// Verify execution state // Verify execution state
final execResp = await httpClient.get( final execResp = await httpClient.get(
Uri.parse( Uri.parse('http://$serverAddr/api/v1/executions/$executionId'),
'http://$serverAddr/api/v1/executions/$executionId'),
); );
expect(execResp.statusCode, 200); expect(execResp.statusCode, 200);
final execJson = final execJson = jsonDecode(execResp.body) as Map<String, dynamic>;
jsonDecode(execResp.body) as Map<String, dynamic>;
expect(execJson['state'], equals('succeeded')); expect(execJson['state'], equals('succeeded'));
expect(execJson['job_id'], equals(jobId)); expect(execJson['job_id'], equals(jobId));
@ -692,12 +677,7 @@ pipeline:
const jobId = 'var-merge-job'; const jobId = 'var-merge-job';
const executionId = 'var-merge-exec'; const executionId = 'var-merge-exec';
final serverProcess = await Process.start( final serverProcess = await _startCore(serverAddr);
'go',
['run', './cmd/oto-core'],
workingDirectory: '../../services/core',
environment: {'OTO_CORE_ADDR': serverAddr},
);
final output = StringBuffer(); final output = StringBuffer();
final stdoutSub = serverProcess.stdout final stdoutSub = serverProcess.stdout
@ -726,7 +706,11 @@ pipeline:
final regClient = OtoServerRegistrationClient(commandTypes: ['Print']); final regClient = OtoServerRegistrationClient(commandTypes: ['Print']);
final session = await regClient.openSession(agentConfig); final session = await regClient.openSession(agentConfig);
expect(session.result.accepted, isTrue, reason: 'Registration rejected'); expect(
session.result.accepted,
isTrue,
reason: 'Registration rejected',
);
final jobs = (session as OtoServerJobSession).jobs; final jobs = (session as OtoServerJobSession).jobs;
final httpClient = http.Client(); final httpClient = http.Client();
@ -758,8 +742,11 @@ pipeline:
}, },
}), }),
); );
expect(createResp.statusCode, equals(201), expect(
reason: 'Create job failed: ${createResp.body}'); createResp.statusCode,
equals(201),
reason: 'Create job failed: ${createResp.body}',
);
final claim = await jobs.claimJob( final claim = await jobs.claimJob(
jobId: jobId, jobId: jobId,
@ -782,10 +769,16 @@ pipeline:
runRequest: claim.runRequest!, runRequest: claim.runRequest!,
); );
expect(buildResult.success, isTrue, expect(
reason: 'Execution failed: ${buildResult.message}'); buildResult.success,
expect(buildResult.stepEvents, isNotEmpty, isTrue,
reason: 'Expected step events from the build'); reason: 'Execution failed: ${buildResult.message}',
);
expect(
buildResult.stepEvents,
isNotEmpty,
reason: 'Expected step events from the build',
);
// REVIEW_API2-2: assert the remote variable actually overrode the // REVIEW_API2-2: assert the remote variable actually overrode the
// YAML value. After a successful build, Application.instance.property // YAML value. After a successful build, Application.instance.property
@ -793,16 +786,24 @@ pipeline:
// to resolve `<!property.FLAVOR>`. It must be the remote 'release', // to resolve `<!property.FLAVOR>`. It must be the remote 'release',
// not the YAML default 'original'. // not the YAML default 'original'.
final mergedFlavor = Application.instance.property['FLAVOR']; final mergedFlavor = Application.instance.property['FLAVOR'];
expect(mergedFlavor, equals('release'), expect(
reason: mergedFlavor,
'remote variable FLAVOR was not substituted; got: $mergedFlavor'); equals('release'),
expect(mergedFlavor, isNot(equals('original')), reason:
reason: 'remote variable FLAVOR was not substituted; got: $mergedFlavor',
'YAML default "original" leaked through instead of the remote override'); );
expect(
expect(capturedLogs.any((l) => l.contains('success=true')), isTrue, mergedFlavor,
reason: 'Expected successful execution report; logs: $capturedLogs'); isNot(equals('original')),
reason:
'YAML default "original" leaked through instead of the remote override',
);
expect(
capturedLogs.any((l) => l.contains('success=true')),
isTrue,
reason: 'Expected successful execution report; logs: $capturedLogs',
);
} finally { } finally {
httpClient.close(); httpClient.close();
await session.close(); await session.close();
@ -831,12 +832,7 @@ pipeline:
const jobId = 'bad-prop-job'; const jobId = 'bad-prop-job';
const executionId = 'bad-prop-exec'; const executionId = 'bad-prop-exec';
final serverProcess = await Process.start( final serverProcess = await _startCore(serverAddr);
'go',
['run', './cmd/oto-core'],
workingDirectory: '../../services/core',
environment: {'OTO_CORE_ADDR': serverAddr},
);
final output = StringBuffer(); final output = StringBuffer();
final stdoutSub = serverProcess.stdout final stdoutSub = serverProcess.stdout
@ -865,7 +861,11 @@ pipeline:
final regClient = OtoServerRegistrationClient(commandTypes: ['Print']); final regClient = OtoServerRegistrationClient(commandTypes: ['Print']);
final session = await regClient.openSession(agentConfig); final session = await regClient.openSession(agentConfig);
expect(session.result.accepted, isTrue, reason: 'Registration rejected'); expect(
session.result.accepted,
isTrue,
reason: 'Registration rejected',
);
final jobs = (session as OtoServerJobSession).jobs; final jobs = (session as OtoServerJobSession).jobs;
final httpClient = http.Client(); final httpClient = http.Client();
@ -897,8 +897,11 @@ pipeline:
}, },
}), }),
); );
expect(createResp.statusCode, equals(201), expect(
reason: 'Create job failed: ${createResp.body}'); createResp.statusCode,
equals(201),
reason: 'Create job failed: ${createResp.body}',
);
final claim = await jobs.claimJob( final claim = await jobs.claimJob(
jobId: jobId, jobId: jobId,
@ -922,8 +925,11 @@ pipeline:
// Build must fail on the invalid property; the merge must not have // Build must fail on the invalid property; the merge must not have
// rewritten it into a valid map. // rewritten it into a valid map.
expect(buildResult.success, isFalse, expect(
reason: 'Expected failure from invalid property type'); buildResult.success,
isFalse,
reason: 'Expected failure from invalid property type',
);
// Server job should be in failed state because reportExecution ran. // Server job should be in failed state because reportExecution ran.
final jobResp = await httpClient.get( final jobResp = await httpClient.get(
@ -931,9 +937,12 @@ pipeline:
); );
expect(jobResp.statusCode, 200); expect(jobResp.statusCode, 200);
final jobJson = jsonDecode(jobResp.body) as Map<String, dynamic>; final jobJson = jsonDecode(jobResp.body) as Map<String, dynamic>;
expect(jobJson['state'], equals('failed'), expect(
reason: jobJson['state'],
'Server did not receive failure report; got: ${jobJson['state']}'); equals('failed'),
reason:
'Server did not receive failure report; got: ${jobJson['state']}',
);
} finally { } finally {
httpClient.close(); httpClient.close();
await session.close(); await session.close();
@ -962,12 +971,7 @@ pipeline:
const jobId = 'parse-fail-job'; const jobId = 'parse-fail-job';
const executionId = 'parse-fail-exec'; const executionId = 'parse-fail-exec';
final serverProcess = await Process.start( final serverProcess = await _startCore(serverAddr);
'go',
['run', './cmd/oto-core'],
workingDirectory: '../../services/core',
environment: {'OTO_CORE_ADDR': serverAddr},
);
final output = StringBuffer(); final output = StringBuffer();
final stdoutSub = serverProcess.stdout final stdoutSub = serverProcess.stdout
@ -996,7 +1000,11 @@ pipeline:
final regClient = OtoServerRegistrationClient(commandTypes: ['Shell']); final regClient = OtoServerRegistrationClient(commandTypes: ['Shell']);
final session = await regClient.openSession(agentConfig); final session = await regClient.openSession(agentConfig);
expect(session.result.accepted, isTrue, reason: 'Registration rejected'); expect(
session.result.accepted,
isTrue,
reason: 'Registration rejected',
);
final jobs = (session as OtoServerJobSession).jobs; final jobs = (session as OtoServerJobSession).jobs;
final httpClient = http.Client(); final httpClient = http.Client();
@ -1015,8 +1023,11 @@ pipeline:
}, },
}), }),
); );
expect(createResp.statusCode, equals(201), expect(
reason: 'Create job failed: ${createResp.body}'); createResp.statusCode,
equals(201),
reason: 'Create job failed: ${createResp.body}',
);
final claim = await jobs.claimJob( final claim = await jobs.claimJob(
jobId: jobId, jobId: jobId,
@ -1040,8 +1051,11 @@ pipeline:
); );
// Build should have failed. // Build should have failed.
expect(buildResult.success, isFalse, expect(
reason: 'Expected failure from parse error'); buildResult.success,
isFalse,
reason: 'Expected failure from parse error',
);
// Server job should be in failed state because reportExecution was called. // Server job should be in failed state because reportExecution was called.
final jobResp = await httpClient.get( final jobResp = await httpClient.get(
@ -1049,8 +1063,12 @@ pipeline:
); );
expect(jobResp.statusCode, 200); expect(jobResp.statusCode, 200);
final jobJson = jsonDecode(jobResp.body) as Map<String, dynamic>; final jobJson = jsonDecode(jobResp.body) as Map<String, dynamic>;
expect(jobJson['state'], equals('failed'), expect(
reason: 'Server did not receive failure report; got: ${jobJson['state']}'); jobJson['state'],
equals('failed'),
reason:
'Server did not receive failure report; got: ${jobJson['state']}',
);
} finally { } finally {
httpClient.close(); httpClient.close();
await session.close(); await session.close();
@ -1079,12 +1097,7 @@ pipeline:
const jobId = 'artifact-smoke-job'; const jobId = 'artifact-smoke-job';
const executionId = 'artifact-smoke-exec'; const executionId = 'artifact-smoke-exec';
final serverProcess = await Process.start( final serverProcess = await _startCore(serverAddr);
'go',
['run', './cmd/oto-core'],
workingDirectory: '../../services/core',
environment: {'OTO_CORE_ADDR': serverAddr},
);
final output = StringBuffer(); final output = StringBuffer();
final stdoutSub = serverProcess.stdout final stdoutSub = serverProcess.stdout
@ -1113,7 +1126,11 @@ pipeline:
final regClient = OtoServerRegistrationClient(commandTypes: ['Print']); final regClient = OtoServerRegistrationClient(commandTypes: ['Print']);
final session = await regClient.openSession(agentConfig); final session = await regClient.openSession(agentConfig);
expect(session.result.accepted, isTrue, reason: 'Registration rejected'); expect(
session.result.accepted,
isTrue,
reason: 'Registration rejected',
);
final jobs = (session as OtoServerJobSession).jobs; final jobs = (session as OtoServerJobSession).jobs;
final httpClient = http.Client(); final httpClient = http.Client();
@ -1147,8 +1164,11 @@ pipeline:
}, },
}), }),
); );
expect(createResp.statusCode, equals(201), expect(
reason: 'Create job failed: ${createResp.body}'); createResp.statusCode,
equals(201),
reason: 'Create job failed: ${createResp.body}',
);
final claim = await jobs.claimJob( final claim = await jobs.claimJob(
jobId: jobId, jobId: jobId,
@ -1171,8 +1191,11 @@ pipeline:
runRequest: claim.runRequest!, runRequest: claim.runRequest!,
); );
expect(buildResult.success, isTrue, expect(
reason: 'Execution failed: ${buildResult.message}'); buildResult.success,
isTrue,
reason: 'Execution failed: ${buildResult.message}',
);
expect(buildResult.artifacts, hasLength(1)); expect(buildResult.artifacts, hasLength(1));
expect(buildResult.artifacts.first.name, 'smoke-report'); expect(buildResult.artifacts.first.name, 'smoke-report');
@ -1215,12 +1238,7 @@ pipeline:
const jobId = 'smoke-job-cancel'; const jobId = 'smoke-job-cancel';
const executionId = 'smoke-exec-cancel'; const executionId = 'smoke-exec-cancel';
final serverProcess = await Process.start( final serverProcess = await _startCore(serverAddr);
'go',
['run', './cmd/oto-core'],
workingDirectory: '../../services/core',
environment: {'OTO_CORE_ADDR': serverAddr},
);
final output = StringBuffer(); final output = StringBuffer();
final stdoutSub = serverProcess.stdout final stdoutSub = serverProcess.stdout
@ -1333,6 +1351,223 @@ pipeline:
}, },
timeout: const Timeout(Duration(seconds: 30)), timeout: const Timeout(Duration(seconds: 30)),
); );
test(
'OtoServerSocketRegistrationClient socket session sends heartbeat and disconnects on close',
() async {
final port = await _freePort();
final serverAddr = '$_host:$port';
final (:process, :socketAddr) = await _startCoreWithSocket(serverAddr);
final output = StringBuffer();
final stdoutSub = process.stdout
.transform(systemEncoding.decoder)
.listen(output.write, onError: output.write);
final stderrSub = process.stderr
.transform(systemEncoding.decoder)
.listen(output.write, onError: output.write);
try {
await _waitForPort(_host, port, process, output);
final agentConfig = AgentConfig(
agent: const AgentIdentityConfig(
id: _runnerId,
alias: _runnerAlias,
enrollmentToken: _token,
),
server: ServerConnectionConfig(
url: 'http://$serverAddr',
socketUrl: 'tcp://$socketAddr',
),
runtime: const AgentRuntimeConfig(
installDir: '/tmp/install',
workspaceRoot: '/tmp/workspace',
logDir: '/tmp/log',
),
);
final client = OtoServerSocketRegistrationClient(
commandTypes: ['Shell', 'Git'],
heartbeatInterval: const Duration(milliseconds: 200),
);
final session = await client.openSession(agentConfig);
expect(session, isA<OtoServerPushJobSession>());
expect(session.result.accepted, isTrue);
expect(session.result.runnerId, _runnerId);
// Capture runRequests stream completion after close().
final streamDone = Completer<void>();
final sub = (session as OtoServerPushJobSession).runRequests.listen(
(_) {},
onDone: streamDone.complete,
);
final httpClient = http.Client();
try {
final isOnline = await _pollRunnerStatus(
httpClient,
serverAddr,
_runnerId,
'online',
);
expect(
isOnline,
isTrue,
reason: 'socket runner did not go online in registry',
);
// close() must cancel the heartbeat timer and close runRequests.
await session.close();
await streamDone.future.timeout(
const Duration(seconds: 2),
onTimeout: () => fail('runRequests stream did not close after session.close()'),
);
await sub.cancel();
final isDisconnected = await _pollRunnerStatus(
httpClient,
serverAddr,
_runnerId,
'disconnected',
timeout: const Duration(seconds: 5),
);
expect(
isDisconnected,
isTrue,
reason: 'socket runner did not transition to disconnected',
);
} finally {
httpClient.close();
}
} finally {
process.kill(ProcessSignal.sigterm);
await process.exitCode.timeout(
const Duration(seconds: 5),
onTimeout: () {
process.kill(ProcessSignal.sigkill);
return process.exitCode;
},
);
await stdoutSub.cancel();
await stderrSub.cancel();
}
},
timeout: const Timeout(Duration(seconds: 30)),
);
test(
'OtoServerSocketRegistrationClient socket session close is idempotent after server disconnect',
() async {
final port = await _freePort();
final serverAddr = '$_host:$port';
final (:process, :socketAddr) = await _startCoreWithSocket(serverAddr);
final output = StringBuffer();
final stdoutSub = process.stdout
.transform(systemEncoding.decoder)
.listen(output.write, onError: output.write);
final stderrSub = process.stderr
.transform(systemEncoding.decoder)
.listen(output.write, onError: output.write);
try {
await _waitForPort(_host, port, process, output);
final agentConfig = AgentConfig(
agent: const AgentIdentityConfig(
id: _runnerId,
alias: _runnerAlias,
enrollmentToken: _token,
),
server: ServerConnectionConfig(
url: 'http://$serverAddr',
socketUrl: 'tcp://$socketAddr',
),
runtime: const AgentRuntimeConfig(
installDir: '/tmp/install',
workspaceRoot: '/tmp/workspace',
logDir: '/tmp/log',
),
);
final client = OtoServerSocketRegistrationClient(
commandTypes: ['Shell'],
// Short interval so in-flight heartbeats exercise the catch block.
heartbeatInterval: const Duration(milliseconds: 50),
);
final session = await client.openSession(agentConfig);
expect(session.result.accepted, isTrue);
// Graceful server shutdown: closes all proto-socket clients cleanly,
// sending EOF to the Dart side. In-flight heartbeat sendRequest calls
// receive StateError('connection closed'), which is swallowed by the
// _sendHeartbeat catch block.
process.kill(ProcessSignal.sigterm);
await process.exitCode.timeout(const Duration(seconds: 5));
// Allow the EOF to propagate and the transport to auto-close.
await Future<void>.delayed(const Duration(milliseconds: 300));
// session.close() must complete without throwing even though the
// proto-socket transport is already closed by the EOF handler.
await expectLater(session.close(), completes);
} finally {
process.kill(ProcessSignal.sigkill);
await process.exitCode.timeout(
const Duration(seconds: 2),
onTimeout: () => -1,
);
await stdoutSub.cancel();
await stderrSub.cancel();
}
},
timeout: const Timeout(Duration(seconds: 30)),
);
}
// Socket session lifecycle tests
typedef _CoreWithSocket = ({Process process, String socketAddr});
Future<_CoreWithSocket> _startCoreWithSocket(String serverAddr) async {
final socketPort = await _freePort();
final socketAddr = '$_host:$socketPort';
final process = await Process.start(
'go',
['run', './cmd/oto-core'],
workingDirectory: '../../services/core',
environment: {
'OTO_CORE_ADDR': serverAddr,
'OTO_RUNNER_SOCKET_ADDR': socketAddr,
'OTO_RUNNER_SOCKET_PUBLIC_URL': 'tcp://$socketAddr',
},
);
return (process: process, socketAddr: socketAddr);
}
Future<bool> _pollRunnerStatus(
http.Client client,
String serverAddr,
String runnerId,
String expected, {
Duration timeout = const Duration(seconds: 10),
}) async {
final url = Uri.parse('http://$serverAddr/api/v1/runners/$runnerId');
final deadline = DateTime.now().add(timeout);
while (DateTime.now().isBefore(deadline)) {
final resp = await client.get(url);
if (resp.statusCode == 200) {
final data = jsonDecode(resp.body);
if (data['status'] == expected) return true;
}
await Future<void>.delayed(const Duration(milliseconds: 100));
}
return false;
} }
Future<int> _freePort() async { Future<int> _freePort() async {
@ -1342,6 +1577,24 @@ Future<int> _freePort() async {
return port; return port;
} }
Future<Process> _startCore(String serverAddr, {String? releaseBaseUrl}) async {
final socketPort = await _freePort();
final environment = <String, String>{
'OTO_CORE_ADDR': serverAddr,
'OTO_RUNNER_SOCKET_ADDR': '$_host:$socketPort',
'OTO_RUNNER_SOCKET_PUBLIC_URL': 'tcp://$_host:$socketPort',
};
if (releaseBaseUrl != null) {
environment['OTO_RUNNER_RELEASE_BASE_URL'] = releaseBaseUrl;
}
return Process.start(
'go',
['run', './cmd/oto-core'],
workingDirectory: '../../services/core',
environment: environment,
);
}
Future<void> _waitForPort( Future<void> _waitForPort(
String host, String host,
int port, int port,

View file

@ -19,6 +19,12 @@ func main() {
log.Printf("Starting OTO Core server on %s", addr) log.Printf("Starting OTO Core server on %s", addr)
server := httpserver.NewServer(addr) server := httpserver.NewServer(addr)
runnerSocketAddr := os.Getenv("OTO_RUNNER_SOCKET_ADDR")
if runnerSocketAddr == "" {
runnerSocketAddr = "127.0.0.1:18080"
}
server.EnableRunnerSocket(runnerSocketAddr)
log.Printf("Starting OTO runner proto-socket listener on %s", runnerSocketAddr)
// Channel to listen for errors from the server run // Channel to listen for errors from the server run
serverError := make(chan error, 1) serverError := make(chan error, 1)

View file

@ -2,4 +2,11 @@ module github.com/toki/oto/services/core
go 1.26 go 1.26
require google.golang.org/protobuf v1.36.11 require (
git.toki-labs.com/toki/proto-socket/go v0.0.0
google.golang.org/protobuf v1.36.11
)
require nhooyr.io/websocket v1.8.17 // indirect
replace git.toki-labs.com/toki/proto-socket/go => ../../../proto-socket/go

View file

@ -2,3 +2,5 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y=
nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=

View file

@ -22,9 +22,9 @@ var validTransitions = map[string][]string{
StateCanceled: nil, StateCanceled: nil,
} }
// RunInput holds the remote run request payload stored on a job so that a // RunInput holds the remote run request payload stored on a job so that the
// runner can receive it when it claims the job. It deliberately avoids any // runner transport can dispatch it later. It deliberately avoids any protobuf
// protobuf dependency; conversion to/from otopb.RunRequest happens at the HTTP // dependency; conversion to/from otopb.RunRequest happens at the HTTP or socket
// boundary. // boundary.
type RunInput struct { type RunInput struct {
PipelineYAMLPath string PipelineYAMLPath string

View file

@ -27,7 +27,7 @@ func jobToJSON(j *cicdstate.Job) map[string]interface{} {
return out return out
} }
// validateRunInput enforces the remote claim contract: exactly one of // validateRunInput enforces the remote dispatch contract: exactly one of
// pipeline_yaml or pipeline_yaml_path must be provided. // pipeline_yaml or pipeline_yaml_path must be provided.
func validateRunInput(in *cicdstate.RunInput) error { func validateRunInput(in *cicdstate.RunInput) error {
hasYAML := strings.TrimSpace(in.PipelineYAML) != "" hasYAML := strings.TrimSpace(in.PipelineYAML) != ""
@ -59,8 +59,8 @@ func runRequestFromJSON(rr *otopb.RunRequest) *cicdstate.RunInput {
return in return in
} }
// runRequestToProto builds the claim response RunRequest from the stored input, // runRequestToProto builds the runner-facing RunRequest from the stored input,
// filling in the runner/job/execution identifiers resolved at claim time. // filling in the runner/job/execution identifiers resolved at dispatch time.
func runRequestToProto(in *cicdstate.RunInput, runnerID, jobID, execID string) *otopb.RunRequest { func runRequestToProto(in *cicdstate.RunInput, runnerID, jobID, execID string) *otopb.RunRequest {
if in == nil { if in == nil {
return nil return nil

View file

@ -11,7 +11,7 @@ import (
otopb "github.com/toki/oto/services/core/oto" otopb "github.com/toki/oto/services/core/oto"
) )
func handleCreateJob(store *cicdstate.Store) http.HandlerFunc { func handleCreateJob(store *cicdstate.Store, dispatcher runnerControlDispatcher) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
ID string `json:"id"` ID string `json:"id"`
@ -31,6 +31,9 @@ func handleCreateJob(store *cicdstate.Store) http.HandlerFunc {
writeResponse(w, http.StatusConflict, map[string]string{"error": err.Error()}) writeResponse(w, http.StatusConflict, map[string]string{"error": err.Error()})
return return
} }
if dispatcher != nil {
go dispatcher.DispatchQueuedJobs()
}
writeResponse(w, http.StatusCreated, jobToJSON(job)) writeResponse(w, http.StatusCreated, jobToJSON(job))
} }
} }

View file

@ -3,6 +3,7 @@ Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$serverUrl = '' $serverUrl = ''
$socketUrl = ''
$agentId = '' $agentId = ''
$enrollmentToken = '' $enrollmentToken = ''
$releaseBaseUrl = '' $releaseBaseUrl = ''
@ -18,6 +19,7 @@ while ($i -lt $args.Count) {
switch ($args[$i]) { switch ($args[$i]) {
'--' { $i++ } '--' { $i++ }
'--server-url' { $serverUrl = $args[$i + 1]; $i += 2 } '--server-url' { $serverUrl = $args[$i + 1]; $i += 2 }
'--socket-url' { $socketUrl = $args[$i + 1]; $i += 2 }
'--agent-id' { $agentId = $args[$i + 1]; $i += 2 } '--agent-id' { $agentId = $args[$i + 1]; $i += 2 }
'--enrollment-token' { $enrollmentToken = $args[$i + 1]; $i += 2 } '--enrollment-token' { $enrollmentToken = $args[$i + 1]; $i += 2 }
'--release-base-url' { $releaseBaseUrl = $args[$i + 1]; $i += 2 } '--release-base-url' { $releaseBaseUrl = $args[$i + 1]; $i += 2 }
@ -82,9 +84,14 @@ try {
$agentAliasYaml = $agentAlias.Replace("'", "''") $agentAliasYaml = $agentAlias.Replace("'", "''")
$enrollmentTokenYaml = $enrollmentToken.Replace("'", "''") $enrollmentTokenYaml = $enrollmentToken.Replace("'", "''")
$serverUrlYaml = $serverUrl.Replace("'", "''") $serverUrlYaml = $serverUrl.Replace("'", "''")
$socketUrlYaml = $socketUrl.Replace("'", "''")
$installDirYaml = ($installDir -replace '\\', '/').Replace("'", "''") $installDirYaml = ($installDir -replace '\\', '/').Replace("'", "''")
$workspaceRootYaml = ($workspaceRoot -replace '\\', '/').Replace("'", "''") $workspaceRootYaml = ($workspaceRoot -replace '\\', '/').Replace("'", "''")
$logDirYaml = ($logDir -replace '\\', '/').Replace("'", "''") $logDirYaml = ($logDir -replace '\\', '/').Replace("'", "''")
$socketUrlLine = ''
if ($socketUrl) {
$socketUrlLine = " socket_url: '$socketUrlYaml'`n"
}
$configContent = @" $configContent = @"
agent: agent:
@ -93,6 +100,7 @@ agent:
enrollment_token: '$enrollmentTokenYaml' enrollment_token: '$enrollmentTokenYaml'
server: server:
url: '$serverUrlYaml' url: '$serverUrlYaml'
$socketUrlLine
runtime: runtime:
install_dir: '$installDirYaml' install_dir: '$installDirYaml'
workspace_root: '$workspaceRootYaml' workspace_root: '$workspaceRootYaml'

View file

@ -2,6 +2,7 @@
set -euo pipefail set -euo pipefail
server_url="" server_url=""
socket_url=""
agent_id="" agent_id=""
enrollment_token="" enrollment_token=""
release_base_url="" release_base_url=""
@ -13,8 +14,9 @@ log_dir="${HOME}/.oto/agent/log"
background="true" background="true"
usage() { usage() {
echo "Usage: $0 --server-url <url> --agent-id <id> --enrollment-token <token> --release-base-url <url> [options]" echo "Usage: $0 --server-url <url> --socket-url <url> --agent-id <id> --enrollment-token <token> --release-base-url <url> [options]"
echo "Options:" echo "Options:"
echo " --socket-url <url> Runner proto-socket URL (default: derived by agent)"
echo " --agent-alias <alias> Alias for the agent" echo " --agent-alias <alias> Alias for the agent"
echo " --install-dir <path> Directory to install OTO agent (default: \$HOME/.oto/bin)" echo " --install-dir <path> Directory to install OTO agent (default: \$HOME/.oto/bin)"
echo " --config-path <path> Path to config file (default: \$HOME/.oto/agent/config.yaml)" echo " --config-path <path> Path to config file (default: \$HOME/.oto/agent/config.yaml)"
@ -39,6 +41,10 @@ while [[ $# -gt 0 ]]; do
server_url="$2" server_url="$2"
shift 2 shift 2
;; ;;
--socket-url)
socket_url="$2"
shift 2
;;
--agent-id) --agent-id)
agent_id="$2" agent_id="$2"
shift 2 shift 2
@ -155,6 +161,11 @@ agent:
enrollment_token: "$(yaml_escape "$enrollment_token")" enrollment_token: "$(yaml_escape "$enrollment_token")"
server: server:
url: "$(yaml_escape "$server_url")" url: "$(yaml_escape "$server_url")"
EOF
if [[ -n "$socket_url" ]]; then
printf ' socket_url: "%s"\n' "$(yaml_escape "$socket_url")" >> "$config_path"
fi
cat <<EOF >> "$config_path"
runtime: runtime:
install_dir: "$(yaml_escape "$install_dir")" install_dir: "$(yaml_escape "$install_dir")"
workspace_root: "$(yaml_escape "$workspace_root")" workspace_root: "$(yaml_escape "$workspace_root")"

View file

@ -8,10 +8,15 @@ import (
"github.com/toki/oto/services/core/internal/runnerregistry" "github.com/toki/oto/services/core/internal/runnerregistry"
) )
type runnerControlDispatcher interface {
DispatchQueuedJobs()
CancelExecution(runnerID, execID, reason string) error
}
// registerRoutes registers all HTTP routes on the given ServeMux. // registerRoutes registers all HTTP routes on the given ServeMux.
// This file exists to separate route registration logic from handler // This file exists to separate route registration logic from handler
// implementation, making it easier to refactor handlers and DTOs later. // implementation, making it easier to refactor handlers and DTOs later.
func registerRoutes(mux *http.ServeMux, registry *runnerregistry.Registry, store *cicdstate.Store) { func registerRoutes(mux *http.ServeMux, registry *runnerregistry.Registry, store *cicdstate.Store, dispatcher runnerControlDispatcher) {
mux.HandleFunc("/healthz", withCORS(handleHealthz)) mux.HandleFunc("/healthz", withCORS(handleHealthz))
mux.HandleFunc("/readyz", withCORS(handleReadyz)) mux.HandleFunc("/readyz", withCORS(handleReadyz))
mux.HandleFunc("/api/v1/runners/register", withCORS(handleRunnerRegister(registry))) mux.HandleFunc("/api/v1/runners/register", withCORS(handleRunnerRegister(registry)))
@ -21,7 +26,7 @@ func registerRoutes(mux *http.ServeMux, registry *runnerregistry.Registry, store
mux.HandleFunc("/api/v1/runners/{id}", withCORS(handleGetRunner(registry))) mux.HandleFunc("/api/v1/runners/{id}", withCORS(handleGetRunner(registry)))
mux.HandleFunc("/bootstrap/oto-agent.sh", withCORS(handleServeBootstrapScript(embeddedBootstrapProvider{}))) mux.HandleFunc("/bootstrap/oto-agent.sh", withCORS(handleServeBootstrapScript(embeddedBootstrapProvider{})))
mux.HandleFunc("/bootstrap/oto-agent.ps1", withCORS(handleServeBootstrapPs1(embeddedBootstrapPs1Provider{}))) mux.HandleFunc("/bootstrap/oto-agent.ps1", withCORS(handleServeBootstrapPs1(embeddedBootstrapPs1Provider{})))
mux.HandleFunc("/api/v1/", withCORS(handleRouter(store, registry))) mux.HandleFunc("/api/v1/", withCORS(handleRouterWithDispatcher(store, registry, dispatcher)))
} }
func withCORS(next http.HandlerFunc) http.HandlerFunc { func withCORS(next http.HandlerFunc) http.HandlerFunc {
@ -52,6 +57,10 @@ func apiPathSegments(path string) []string {
} }
func handleRouter(store *cicdstate.Store, registry *runnerregistry.Registry) http.HandlerFunc { func handleRouter(store *cicdstate.Store, registry *runnerregistry.Registry) http.HandlerFunc {
return handleRouterWithDispatcher(store, registry, nil)
}
func handleRouterWithDispatcher(store *cicdstate.Store, registry *runnerregistry.Registry, dispatcher runnerControlDispatcher) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
parts := apiPathSegments(r.URL.Path) parts := apiPathSegments(r.URL.Path)
if parts == nil { if parts == nil {
@ -63,7 +72,7 @@ func handleRouter(store *cicdstate.Store, registry *runnerregistry.Registry) htt
case "jobs": case "jobs":
switch { switch {
case len(parts) == 1 && r.Method == http.MethodPost: case len(parts) == 1 && r.Method == http.MethodPost:
handleCreateJob(store)(w, r) handleCreateJob(store, dispatcher)(w, r)
case len(parts) == 1 && r.Method == http.MethodGet: case len(parts) == 1 && r.Method == http.MethodGet:
writeResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) writeResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return return
@ -109,7 +118,7 @@ func handleRouter(store *cicdstate.Store, registry *runnerregistry.Registry) htt
case len(parts) == 4 && parts[2] == "jobs" && parts[3] == "claim" && r.Method == http.MethodPost: case len(parts) == 4 && parts[2] == "jobs" && parts[3] == "claim" && r.Method == http.MethodPost:
handleRunnerClaimJob(store, registry, runnerID)(w, r) handleRunnerClaimJob(store, registry, runnerID)(w, r)
case len(parts) == 5 && parts[2] == "executions" && parts[4] == "cancel" && r.Method == http.MethodPost: case len(parts) == 5 && parts[2] == "executions" && parts[4] == "cancel" && r.Method == http.MethodPost:
handleRunnerCancelExecution(store, registry, runnerID, parts[3])(w, r) handleRunnerCancelExecution(store, registry, dispatcher, runnerID, parts[3])(w, r)
case len(parts) == 5 && parts[2] == "executions" && parts[4] == "report" && r.Method == http.MethodPost: case len(parts) == 5 && parts[2] == "executions" && parts[4] == "report" && r.Method == http.MethodPost:
handleRunnerReportExecution(store, registry, runnerID, parts[3])(w, r) handleRunnerReportExecution(store, registry, runnerID, parts[3])(w, r)
case len(parts) == 5 && parts[2] == "executions" && parts[4] == "logs" && r.Method == http.MethodPost: case len(parts) == 5 && parts[2] == "executions" && parts[4] == "logs" && r.Method == http.MethodPost:

View file

@ -185,6 +185,14 @@ func handleRunnerReportExecution(store *cicdstate.Store, registry *runnerregistr
}) })
return return
} }
if exec.RunnerID != runnerID {
writeResponse(w, http.StatusBadRequest, &otopb.ExecutionReportResponse{
RunnerId: runnerID,
ExecutionId: execID,
ErrorMessage: "execution owner mismatch",
})
return
}
jobID := strings.TrimSpace(req.GetJobId()) jobID := strings.TrimSpace(req.GetJobId())
if jobID == "" { if jobID == "" {
jobID = exec.JobID jobID = exec.JobID
@ -249,6 +257,12 @@ func handleRunnerAppendLog(store *cicdstate.Store, registry *runnerregistry.Regi
}) })
return return
} }
if err := ensureExecutionOwner(store, runnerID, execID); err != nil {
writeResponse(w, runnerExecutionErrorStatus(err), &otopb.LogAppendResponse{
ErrorMessage: err.Error(),
})
return
}
line := strings.TrimSpace(req.GetLine()) line := strings.TrimSpace(req.GetLine())
if line == "" { if line == "" {
writeResponse(w, http.StatusBadRequest, &otopb.LogAppendResponse{ writeResponse(w, http.StatusBadRequest, &otopb.LogAppendResponse{
@ -283,6 +297,12 @@ func handleRunnerAppendArtifact(store *cicdstate.Store, registry *runnerregistry
}) })
return return
} }
if err := ensureExecutionOwner(store, runnerID, execID); err != nil {
writeResponse(w, runnerExecutionErrorStatus(err), &otopb.ArtifactReportResponse{
ErrorMessage: err.Error(),
})
return
}
name := strings.TrimSpace(req.GetName()) name := strings.TrimSpace(req.GetName())
path := strings.TrimSpace(req.GetPath()) path := strings.TrimSpace(req.GetPath())
if name == "" || path == "" { if name == "" || path == "" {
@ -326,7 +346,25 @@ func ensureRunnerKnown(registry *runnerregistry.Registry, runnerID string) error
return nil return nil
} }
func handleRunnerCancelExecution(store *cicdstate.Store, registry *runnerregistry.Registry, runnerID string, execID string) http.HandlerFunc { func ensureExecutionOwner(store *cicdstate.Store, runnerID string, execID string) error {
exec, err := store.GetExecution(execID)
if err != nil {
return fmt.Errorf("execution not found")
}
if exec.RunnerID != runnerID {
return fmt.Errorf("execution owner mismatch")
}
return nil
}
func runnerExecutionErrorStatus(err error) int {
if err != nil && err.Error() == "execution not found" {
return http.StatusNotFound
}
return http.StatusBadRequest
}
func handleRunnerCancelExecution(store *cicdstate.Store, registry *runnerregistry.Registry, dispatcher runnerControlDispatcher, runnerID string, execID string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
var req otopb.CancelRunRequest var req otopb.CancelRunRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@ -370,6 +408,10 @@ func handleRunnerCancelExecution(store *cicdstate.Store, registry *runnerregistr
return return
} }
if dispatcher != nil {
_ = dispatcher.CancelExecution(runnerID, execID, req.GetReason())
}
writeResponse(w, http.StatusOK, &otopb.CancelRunResponse{ writeResponse(w, http.StatusOK, &otopb.CancelRunResponse{
Success: true, Success: true,
}) })

View file

@ -3,6 +3,7 @@ package httpserver
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
@ -242,29 +243,36 @@ func handleRunnerBootstrapCommand(registry *runnerregistry.Registry) http.Handle
return return
} }
} }
socketURL, err := runnerSocketPublicURL(serverURL)
if err != nil {
writeResponse(w, http.StatusBadRequest, errorToJSON(err.Error()))
return
}
var bootstrapCmd string var bootstrapCmd string
if target == "windows" { if target == "windows" {
escapedScriptURL := powershellEscape(serverURL + "/bootstrap/oto-agent.ps1") escapedScriptURL := powershellEscape(serverURL + "/bootstrap/oto-agent.ps1")
escapedServerURL := powershellEscape(serverURL) escapedServerURL := powershellEscape(serverURL)
escapedSocketURL := powershellEscape(socketURL)
escapedRunnerID := powershellEscape(runnerID) escapedRunnerID := powershellEscape(runnerID)
escapedToken := powershellEscape(token) escapedToken := powershellEscape(token)
escapedReleaseURL := powershellEscape(releaseBaseURL) escapedReleaseURL := powershellEscape(releaseBaseURL)
bootstrapCmd = fmt.Sprintf( bootstrapCmd = fmt.Sprintf(
"powershell -ExecutionPolicy Bypass -Command \"& ([scriptblock]::Create((irm %s))) -- --server-url %s --agent-id %s --enrollment-token %s --release-base-url %s\"", "powershell -ExecutionPolicy Bypass -Command \"& ([scriptblock]::Create((irm %s))) -- --server-url %s --socket-url %s --agent-id %s --enrollment-token %s --release-base-url %s\"",
escapedScriptURL, escapedServerURL, escapedRunnerID, escapedToken, escapedReleaseURL, escapedScriptURL, escapedServerURL, escapedSocketURL, escapedRunnerID, escapedToken, escapedReleaseURL,
) )
} else { } else {
escapedScriptURL := shellEscape(serverURL + "/bootstrap/oto-agent.sh") escapedScriptURL := shellEscape(serverURL + "/bootstrap/oto-agent.sh")
escapedServerURL := shellEscape(serverURL) escapedServerURL := shellEscape(serverURL)
escapedSocketURL := shellEscape(socketURL)
escapedRunnerID := shellEscape(runnerID) escapedRunnerID := shellEscape(runnerID)
escapedToken := shellEscape(token) escapedToken := shellEscape(token)
escapedReleaseURL := shellEscape(releaseBaseURL) escapedReleaseURL := shellEscape(releaseBaseURL)
bootstrapCmd = fmt.Sprintf( bootstrapCmd = fmt.Sprintf(
"curl -fsSL %s | bash -s -- --server-url %s --agent-id %s --enrollment-token %s --release-base-url %s", "curl -fsSL %s | bash -s -- --server-url %s --socket-url %s --agent-id %s --enrollment-token %s --release-base-url %s",
escapedScriptURL, escapedServerURL, escapedRunnerID, escapedToken, escapedReleaseURL, escapedScriptURL, escapedServerURL, escapedSocketURL, escapedRunnerID, escapedToken, escapedReleaseURL,
) )
} }
@ -276,6 +284,24 @@ func handleRunnerBootstrapCommand(registry *runnerregistry.Registry) http.Handle
} }
} }
func runnerSocketPublicURL(serverURL string) (string, error) {
if configured := strings.TrimSpace(os.Getenv("OTO_RUNNER_SOCKET_PUBLIC_URL")); configured != "" {
if !strings.Contains(configured, "://") {
configured = "tcp://" + configured
}
u, err := url.Parse(configured)
if err != nil || u.Hostname() == "" {
return "", fmt.Errorf("invalid OTO_RUNNER_SOCKET_PUBLIC_URL")
}
return configured, nil
}
u, err := url.Parse(serverURL)
if err != nil || u.Hostname() == "" {
return "", fmt.Errorf("invalid server URL")
}
return "tcp://" + net.JoinHostPort(u.Hostname(), "18080"), nil
}
func handleServeBootstrapScript(provider bootstrapScriptProvider) http.HandlerFunc { func handleServeBootstrapScript(provider bootstrapScriptProvider) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {

View file

@ -9,6 +9,7 @@ import (
"github.com/toki/oto/services/core/internal/cicdstate" "github.com/toki/oto/services/core/internal/cicdstate"
"github.com/toki/oto/services/core/internal/runnerregistry" "github.com/toki/oto/services/core/internal/runnerregistry"
"github.com/toki/oto/services/core/internal/runnersocket"
) )
const ( const (
@ -26,13 +27,16 @@ type ServerConfig struct {
type Server struct { type Server struct {
httpServer *http.Server httpServer *http.Server
registry *runnerregistry.Registry registry *runnerregistry.Registry
store *cicdstate.Store
heartbeatTimeout time.Duration heartbeatTimeout time.Duration
scanInterval time.Duration scanInterval time.Duration
runnerSocket *runnersocket.Server
mu sync.Mutex mu sync.Mutex
loopCancel context.CancelFunc loopCancel context.CancelFunc
loopDone <-chan struct{} loopDone <-chan struct{}
startOnce sync.Once socketCancel context.CancelFunc
startOnce sync.Once
} }
// NewServer creates a new instance of Server. // NewServer creates a new instance of Server.
@ -57,17 +61,31 @@ func NewServerWithConfig(addr string, registry *runnerregistry.Registry, store *
func newServer(addr string, registry *runnerregistry.Registry, store *cicdstate.Store, heartbeatTimeout, scanInterval time.Duration) *Server { func newServer(addr string, registry *runnerregistry.Registry, store *cicdstate.Store, heartbeatTimeout, scanInterval time.Duration) *Server {
mux := http.NewServeMux() mux := http.NewServeMux()
registerRoutes(mux, registry, store)
return &Server{ s := &Server{
httpServer: &http.Server{ httpServer: &http.Server{
Addr: addr, Addr: addr,
Handler: mux, Handler: mux,
}, },
registry: registry, registry: registry,
store: store,
heartbeatTimeout: heartbeatTimeout, heartbeatTimeout: heartbeatTimeout,
scanInterval: scanInterval, scanInterval: scanInterval,
} }
registerRoutes(mux, registry, store, s)
return s
}
// EnableRunnerSocket starts a runner-facing proto-socket listener together with
// the HTTP server. The listener shares the same registry and CICD store.
func (s *Server) EnableRunnerSocket(addr string) {
s.mu.Lock()
defer s.mu.Unlock()
if addr == "" {
s.runnerSocket = nil
return
}
s.runnerSocket = runnersocket.New(addr, s.registry, s.store)
} }
func (s *Server) startTimeoutLoop() { func (s *Server) startTimeoutLoop() {
@ -108,10 +126,63 @@ func (s *Server) stopTimeoutLoop() {
} }
} }
func (s *Server) startRunnerSocket() error {
s.mu.Lock()
runnerSocket := s.runnerSocket
if runnerSocket == nil {
s.mu.Unlock()
return nil
}
ctx, cancel := context.WithCancel(context.Background())
s.socketCancel = cancel
s.mu.Unlock()
return runnerSocket.Start(ctx)
}
func (s *Server) stopRunnerSocket() {
s.mu.Lock()
cancel := s.socketCancel
runnerSocket := s.runnerSocket
s.socketCancel = nil
s.mu.Unlock()
if cancel != nil {
cancel()
}
if runnerSocket != nil {
_ = runnerSocket.Stop()
}
}
// DispatchQueuedJobs lets HTTP handlers notify the runner socket dispatcher.
func (s *Server) DispatchQueuedJobs() {
s.mu.Lock()
runnerSocket := s.runnerSocket
s.mu.Unlock()
if runnerSocket != nil {
runnerSocket.DispatchQueuedJobs()
}
}
// CancelExecution forwards a cancellation request to a connected runner socket.
func (s *Server) CancelExecution(runnerID, execID, reason string) error {
s.mu.Lock()
runnerSocket := s.runnerSocket
s.mu.Unlock()
if runnerSocket == nil {
return nil
}
return runnerSocket.CancelExecution(runnerID, execID, reason)
}
// Start starts the HTTP server. // Start starts the HTTP server.
func (s *Server) Start() error { func (s *Server) Start() error {
s.startOnce.Do(s.startTimeoutLoop) s.startOnce.Do(s.startTimeoutLoop)
if err := s.startRunnerSocket(); err != nil {
s.stopTimeoutLoop()
return err
}
err := s.httpServer.ListenAndServe() err := s.httpServer.ListenAndServe()
s.stopRunnerSocket()
s.stopTimeoutLoop() s.stopTimeoutLoop()
return err return err
} }
@ -120,13 +191,19 @@ func (s *Server) Start() error {
// Useful for testing with dynamic ports. // Useful for testing with dynamic ports.
func (s *Server) StartListener(ln net.Listener) error { func (s *Server) StartListener(ln net.Listener) error {
s.startOnce.Do(s.startTimeoutLoop) s.startOnce.Do(s.startTimeoutLoop)
if err := s.startRunnerSocket(); err != nil {
s.stopTimeoutLoop()
return err
}
err := s.httpServer.Serve(ln) err := s.httpServer.Serve(ln)
s.stopRunnerSocket()
s.stopTimeoutLoop() s.stopTimeoutLoop()
return err return err
} }
// Shutdown gracefully shuts down the server. // Shutdown gracefully shuts down the server.
func (s *Server) Shutdown(ctx context.Context) error { func (s *Server) Shutdown(ctx context.Context) error {
s.stopRunnerSocket()
s.stopTimeoutLoop() s.stopTimeoutLoop()
return s.httpServer.Shutdown(ctx) return s.httpServer.Shutdown(ctx)
} }

View file

@ -479,7 +479,7 @@ func TestHandleRunnerBootstrapCommand(t *testing.T) {
t.Fatalf("decode bootstrap command response: %v", err) t.Fatalf("decode bootstrap command response: %v", err)
} }
expectedCmd := "curl -fsSL 'http://localhost:8080/bootstrap/oto-agent.sh' | bash -s -- --server-url 'http://localhost:8080' --agent-id 'runner-123' --enrollment-token 'token-123' --release-base-url 'https://example.com/releases'" expectedCmd := "curl -fsSL 'http://localhost:8080/bootstrap/oto-agent.sh' | bash -s -- --server-url 'http://localhost:8080' --socket-url 'tcp://localhost:18080' --agent-id 'runner-123' --enrollment-token 'token-123' --release-base-url 'https://example.com/releases'"
if response.GetBootstrapCommand() != expectedCmd { if response.GetBootstrapCommand() != expectedCmd {
t.Fatalf("bootstrap_command = %q, want %q", response.GetBootstrapCommand(), expectedCmd) t.Fatalf("bootstrap_command = %q, want %q", response.GetBootstrapCommand(), expectedCmd)
} }
@ -499,7 +499,7 @@ func TestHandleRunnerBootstrapCommand(t *testing.T) {
t.Fatalf("decode bootstrap command response: %v", err) t.Fatalf("decode bootstrap command response: %v", err)
} }
expectedEscapedCmd := "curl -fsSL 'http://localhost:8080/bootstrap/oto-agent.sh' | bash -s -- --server-url 'http://localhost:8080' --agent-id 'runner; rm -rf /' --enrollment-token 'token'\\''$(say hello)'\\''' --release-base-url 'https://example.com/releases'" expectedEscapedCmd := "curl -fsSL 'http://localhost:8080/bootstrap/oto-agent.sh' | bash -s -- --server-url 'http://localhost:8080' --socket-url 'tcp://localhost:18080' --agent-id 'runner; rm -rf /' --enrollment-token 'token'\\''$(say hello)'\\''' --release-base-url 'https://example.com/releases'"
if response.GetBootstrapCommand() != expectedEscapedCmd { if response.GetBootstrapCommand() != expectedEscapedCmd {
t.Fatalf("bootstrap_command = %q, want %q", response.GetBootstrapCommand(), expectedEscapedCmd) t.Fatalf("bootstrap_command = %q, want %q", response.GetBootstrapCommand(), expectedEscapedCmd)
} }
@ -539,7 +539,7 @@ func TestHandleRunnerBootstrapCommand(t *testing.T) {
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil { if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
t.Fatalf("decode bootstrap command response: %v", err) t.Fatalf("decode bootstrap command response: %v", err)
} }
expectedLinuxCmd := "curl -fsSL 'http://localhost:8080/bootstrap/oto-agent.sh' | bash -s -- --server-url 'http://localhost:8080' --agent-id 'runner-123' --enrollment-token 'token-123' --release-base-url 'https://example.com/releases'" expectedLinuxCmd := "curl -fsSL 'http://localhost:8080/bootstrap/oto-agent.sh' | bash -s -- --server-url 'http://localhost:8080' --socket-url 'tcp://localhost:18080' --agent-id 'runner-123' --enrollment-token 'token-123' --release-base-url 'https://example.com/releases'"
if response.GetBootstrapCommand() != expectedLinuxCmd { if response.GetBootstrapCommand() != expectedLinuxCmd {
t.Fatalf("bootstrap_command for linux = %q, want %q", response.GetBootstrapCommand(), expectedLinuxCmd) t.Fatalf("bootstrap_command for linux = %q, want %q", response.GetBootstrapCommand(), expectedLinuxCmd)
} }
@ -557,7 +557,7 @@ func TestHandleRunnerBootstrapCommand(t *testing.T) {
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil { if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
t.Fatalf("decode bootstrap command response: %v", err) t.Fatalf("decode bootstrap command response: %v", err)
} }
expectedMacosCmd := "curl -fsSL 'http://localhost:8080/bootstrap/oto-agent.sh' | bash -s -- --server-url 'http://localhost:8080' --agent-id 'runner-123' --enrollment-token 'token-123' --release-base-url 'https://example.com/releases'" expectedMacosCmd := "curl -fsSL 'http://localhost:8080/bootstrap/oto-agent.sh' | bash -s -- --server-url 'http://localhost:8080' --socket-url 'tcp://localhost:18080' --agent-id 'runner-123' --enrollment-token 'token-123' --release-base-url 'https://example.com/releases'"
if response.GetBootstrapCommand() != expectedMacosCmd { if response.GetBootstrapCommand() != expectedMacosCmd {
t.Fatalf("bootstrap_command for macos = %q, want %q", response.GetBootstrapCommand(), expectedMacosCmd) t.Fatalf("bootstrap_command for macos = %q, want %q", response.GetBootstrapCommand(), expectedMacosCmd)
} }
@ -575,7 +575,7 @@ func TestHandleRunnerBootstrapCommand(t *testing.T) {
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil { if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
t.Fatalf("decode bootstrap command response: %v", err) t.Fatalf("decode bootstrap command response: %v", err)
} }
expectedWindowsCmd := "powershell -ExecutionPolicy Bypass -Command \"& ([scriptblock]::Create((irm 'http://localhost:8080/bootstrap/oto-agent.ps1'))) -- --server-url 'http://localhost:8080' --agent-id 'runner-123' --enrollment-token 'token-123' --release-base-url 'https://example.com/releases'\"" expectedWindowsCmd := "powershell -ExecutionPolicy Bypass -Command \"& ([scriptblock]::Create((irm 'http://localhost:8080/bootstrap/oto-agent.ps1'))) -- --server-url 'http://localhost:8080' --socket-url 'tcp://localhost:18080' --agent-id 'runner-123' --enrollment-token 'token-123' --release-base-url 'https://example.com/releases'\""
if response.GetBootstrapCommand() != expectedWindowsCmd { if response.GetBootstrapCommand() != expectedWindowsCmd {
t.Fatalf("bootstrap_command for windows = %q, want %q", response.GetBootstrapCommand(), expectedWindowsCmd) t.Fatalf("bootstrap_command for windows = %q, want %q", response.GetBootstrapCommand(), expectedWindowsCmd)
} }
@ -593,7 +593,7 @@ func TestHandleRunnerBootstrapCommand(t *testing.T) {
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil { if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
t.Fatalf("decode bootstrap command response: %v", err) t.Fatalf("decode bootstrap command response: %v", err)
} }
expectedWindowsEscapedCmd := "powershell -ExecutionPolicy Bypass -Command \"& ([scriptblock]::Create((irm 'http://localhost:8080/bootstrap/oto-agent.ps1'))) -- --server-url 'http://localhost:8080' --agent-id 'runner; rm -rf /' --enrollment-token 'token''$(say hello)''' --release-base-url 'https://example.com/releases'\"" expectedWindowsEscapedCmd := "powershell -ExecutionPolicy Bypass -Command \"& ([scriptblock]::Create((irm 'http://localhost:8080/bootstrap/oto-agent.ps1'))) -- --server-url 'http://localhost:8080' --socket-url 'tcp://localhost:18080' --agent-id 'runner; rm -rf /' --enrollment-token 'token''$(say hello)''' --release-base-url 'https://example.com/releases'\""
if response.GetBootstrapCommand() != expectedWindowsEscapedCmd { if response.GetBootstrapCommand() != expectedWindowsEscapedCmd {
t.Fatalf("bootstrap_command for windows malicious = %q, want %q", response.GetBootstrapCommand(), expectedWindowsEscapedCmd) t.Fatalf("bootstrap_command for windows malicious = %q, want %q", response.GetBootstrapCommand(), expectedWindowsEscapedCmd)
} }

View file

@ -0,0 +1,448 @@
package runnersocket
import (
"context"
"fmt"
"net"
"sort"
"strconv"
"strings"
"sync"
protoSocket "git.toki-labs.com/toki/proto-socket/go"
"github.com/toki/oto/services/core/internal/cicdstate"
"github.com/toki/oto/services/core/internal/runnerregistry"
otopb "github.com/toki/oto/services/core/oto"
"google.golang.org/protobuf/proto"
)
const (
heartbeatIntervalSeconds = 30
heartbeatWaitSeconds = 45
)
// Server owns the runner-facing proto-socket listener.
type Server struct {
addr string
registry *runnerregistry.Registry
store *cicdstate.Store
mu sync.RWMutex
server *protoSocket.TcpServer
clients map[string]*protoSocket.TcpClient
}
// New creates a runner socket server sharing the core registry and CICD store.
func New(addr string, registry *runnerregistry.Registry, store *cicdstate.Store) *Server {
return &Server{
addr: addr,
registry: registry,
store: store,
clients: make(map[string]*protoSocket.TcpClient),
}
}
// Start binds the proto-socket TCP listener.
func (s *Server) Start(ctx context.Context) error {
host, port, err := splitAddr(s.addr)
if err != nil {
return err
}
server := protoSocket.NewTcpServer(host, port, func(conn net.Conn) *protoSocket.TcpClient {
client := protoSocket.NewTcpClient(conn, heartbeatIntervalSeconds, heartbeatWaitSeconds, parserMap())
s.attachClientHandlers(client)
return client
})
s.mu.Lock()
s.server = server
s.mu.Unlock()
return server.Start(ctx)
}
// Stop closes the listener and connected runner sockets.
func (s *Server) Stop() error {
s.mu.RLock()
server := s.server
s.mu.RUnlock()
if server == nil {
return nil
}
return server.Stop()
}
// DispatchQueuedJobs pushes queued jobs to currently connected idle runners.
func (s *Server) DispatchQueuedJobs() {
for _, runnerID := range s.connectedRunnerIDs() {
if s.runnerHasActiveExecution(runnerID) {
continue
}
client := s.clientFor(runnerID)
if client == nil {
continue
}
runRequest, jobID, execID, err := s.claimNextQueuedJob(runnerID)
if err != nil {
if jobID != "" {
if execID != "" {
_ = s.store.AppendLog(execID, fmt.Sprintf("failed to prepare run request over proto-socket: %v", err))
_ = s.store.TransitionExecution(execID, cicdstate.StateFailed)
}
_ = s.store.TransitionJob(jobID, cicdstate.StateFailed)
}
continue
}
if runRequest == nil {
continue
}
if err := client.Send(runRequest); err != nil {
_ = s.store.AppendLog(execID, fmt.Sprintf("failed to dispatch run request over proto-socket: %v", err))
_ = s.store.TransitionExecution(execID, cicdstate.StateFailed)
_ = s.store.TransitionJob(jobID, cicdstate.StateFailed)
continue
}
}
}
// CancelExecution asks a connected runner to cancel an execution.
func (s *Server) CancelExecution(runnerID, execID, reason string) error {
client := s.clientFor(runnerID)
if client == nil {
return fmt.Errorf("runner socket is not connected")
}
return client.Send(&otopb.CancelRunRequest{
RunnerId: runnerID,
ExecutionId: execID,
Reason: reason,
})
}
func (s *Server) attachClientHandlers(client *protoSocket.TcpClient) {
var runnerIDMu sync.RWMutex
runnerID := ""
setRunnerID := func(id string) {
runnerIDMu.Lock()
runnerID = id
runnerIDMu.Unlock()
}
getRunnerID := func() string {
runnerIDMu.RLock()
defer runnerIDMu.RUnlock()
return runnerID
}
protoSocket.AddRequestListenerTyped[*otopb.RegisterRunnerRequest, *otopb.RegisterRunnerResponse](
&client.Communicator,
func(req *otopb.RegisterRunnerRequest) (*otopb.RegisterRunnerResponse, error) {
res := s.registry.Register(req)
if res.GetAccepted() {
id := res.GetRunnerId()
setRunnerID(id)
s.setClient(id, client)
s.registry.Heartbeat(id, otopb.HeartbeatStatus_HEARTBEAT_STATUS_HEALTHY)
go s.DispatchQueuedJobs()
}
return res, nil
},
)
protoSocket.AddRequestListenerTyped[*otopb.HeartbeatRequest, *otopb.HeartbeatResponse](
&client.Communicator,
func(req *otopb.HeartbeatRequest) (*otopb.HeartbeatResponse, error) {
runnerID := strings.TrimSpace(req.GetRunnerId())
if runnerID == "" {
runnerID = getRunnerID()
}
return s.registry.Heartbeat(runnerID, req.GetStatus()), nil
},
)
protoSocket.AddRequestListenerTyped[*otopb.ExecutionReportRequest, *otopb.ExecutionReportResponse](
&client.Communicator,
func(req *otopb.ExecutionReportRequest) (*otopb.ExecutionReportResponse, error) {
res := s.handleExecutionReport(req)
if res.GetAccepted() {
go s.DispatchQueuedJobs()
}
return res, nil
},
)
protoSocket.AddRequestListenerTyped[*otopb.LogAppendRequest, *otopb.LogAppendResponse](
&client.Communicator,
func(req *otopb.LogAppendRequest) (*otopb.LogAppendResponse, error) {
return s.handleLogAppend(req), nil
},
)
protoSocket.AddRequestListenerTyped[*otopb.ArtifactReportRequest, *otopb.ArtifactReportResponse](
&client.Communicator,
func(req *otopb.ArtifactReportRequest) (*otopb.ArtifactReportResponse, error) {
return s.handleArtifactReport(req), nil
},
)
client.AddDisconnectListener(func(c *protoSocket.TcpClient) {
id := getRunnerID()
if id == "" {
return
}
if s.removeClient(id, c) {
s.registry.Disconnect(id)
}
})
}
func (s *Server) handleExecutionReport(req *otopb.ExecutionReportRequest) *otopb.ExecutionReportResponse {
runnerID := strings.TrimSpace(req.GetRunnerId())
execID := strings.TrimSpace(req.GetExecutionId())
if err := s.ensureRunnerKnown(runnerID); err != nil {
return &otopb.ExecutionReportResponse{RunnerId: runnerID, ExecutionId: execID, ErrorMessage: err.Error()}
}
exec, err := s.store.GetExecution(execID)
if err != nil {
return &otopb.ExecutionReportResponse{RunnerId: runnerID, ExecutionId: execID, ErrorMessage: "execution not found"}
}
if exec.RunnerID != runnerID {
return &otopb.ExecutionReportResponse{RunnerId: runnerID, ExecutionId: execID, ErrorMessage: "execution owner mismatch"}
}
jobID := strings.TrimSpace(req.GetJobId())
if jobID == "" {
jobID = exec.JobID
} else if jobID != exec.JobID {
return &otopb.ExecutionReportResponse{RunnerId: runnerID, JobId: jobID, ExecutionId: execID, ErrorMessage: "job id mismatch for execution"}
}
targetState := cicdstate.StateFailed
if req.GetSuccess() {
targetState = cicdstate.StateSucceeded
}
if err := s.store.TransitionExecution(execID, targetState); err != nil {
return &otopb.ExecutionReportResponse{RunnerId: runnerID, JobId: jobID, ExecutionId: execID, ErrorMessage: err.Error()}
}
if err := s.store.TransitionJob(jobID, targetState); err != nil {
return &otopb.ExecutionReportResponse{RunnerId: runnerID, JobId: jobID, ExecutionId: execID, ErrorMessage: err.Error()}
}
if strings.TrimSpace(req.GetMessage()) != "" {
_ = s.store.AppendLog(execID, req.GetMessage())
}
return &otopb.ExecutionReportResponse{
Accepted: true,
RunnerId: runnerID,
JobId: jobID,
ExecutionId: execID,
State: targetState,
}
}
func (s *Server) handleLogAppend(req *otopb.LogAppendRequest) *otopb.LogAppendResponse {
runnerID := strings.TrimSpace(req.GetRunnerId())
execID := strings.TrimSpace(req.GetExecutionId())
if err := s.ensureRunnerKnown(runnerID); err != nil {
return &otopb.LogAppendResponse{ErrorMessage: err.Error()}
}
if err := s.ensureExecutionOwner(runnerID, execID); err != nil {
return &otopb.LogAppendResponse{ErrorMessage: err.Error()}
}
line := strings.TrimSpace(req.GetLine())
if line == "" {
return &otopb.LogAppendResponse{ErrorMessage: "line is required"}
}
if err := s.store.AppendLog(execID, line); err != nil {
return &otopb.LogAppendResponse{ErrorMessage: "execution not found"}
}
return &otopb.LogAppendResponse{Accepted: true}
}
func (s *Server) handleArtifactReport(req *otopb.ArtifactReportRequest) *otopb.ArtifactReportResponse {
runnerID := strings.TrimSpace(req.GetRunnerId())
execID := strings.TrimSpace(req.GetExecutionId())
if err := s.ensureRunnerKnown(runnerID); err != nil {
return &otopb.ArtifactReportResponse{ErrorMessage: err.Error()}
}
if err := s.ensureExecutionOwner(runnerID, execID); err != nil {
return &otopb.ArtifactReportResponse{ErrorMessage: err.Error()}
}
name := strings.TrimSpace(req.GetName())
path := strings.TrimSpace(req.GetPath())
if name == "" || path == "" {
return &otopb.ArtifactReportResponse{ErrorMessage: "name and path are required"}
}
if err := s.store.AppendArtifact(execID, name, path); err != nil {
return &otopb.ArtifactReportResponse{ErrorMessage: "execution not found"}
}
return &otopb.ArtifactReportResponse{Accepted: true}
}
func (s *Server) claimNextQueuedJob(runnerID string) (*otopb.RunRequest, string, string, error) {
if err := s.ensureRunnerKnown(runnerID); err != nil {
return nil, "", "", err
}
var foundJob *cicdstate.Job
for _, job := range s.store.SnapshotJobs() {
if job.State != cicdstate.StateQueued {
continue
}
if foundJob == nil || job.CreatedAt.Before(foundJob.CreatedAt) {
copyJob := job
foundJob = &copyJob
}
}
if foundJob == nil {
return nil, "", "", nil
}
if foundJob.RunInput == nil {
return nil, foundJob.ID, "", fmt.Errorf("job has no run request")
}
if err := validateRunInput(foundJob.RunInput); err != nil {
return nil, foundJob.ID, "", err
}
execID := s.store.NextExecutionID()
if _, err := s.store.CreateExecution(foundJob.ID, execID); err != nil {
return nil, foundJob.ID, execID, err
}
_ = s.store.SetExecutionRunnerID(execID, runnerID)
if err := s.store.TransitionJob(foundJob.ID, cicdstate.StateRunning); err != nil {
return nil, foundJob.ID, execID, err
}
if err := s.store.TransitionExecution(execID, cicdstate.StateRunning); err != nil {
return nil, foundJob.ID, execID, err
}
return runRequestToProto(foundJob.RunInput, runnerID, foundJob.ID, execID), foundJob.ID, execID, nil
}
func (s *Server) runnerHasActiveExecution(runnerID string) bool {
for _, exec := range s.store.SnapshotExecutions() {
if exec.RunnerID == runnerID && exec.State == cicdstate.StateRunning {
return true
}
}
return false
}
func (s *Server) ensureRunnerKnown(runnerID string) error {
if strings.TrimSpace(runnerID) == "" {
return fmt.Errorf("missing runner id")
}
if s.registry == nil {
return nil
}
if _, ok := s.registry.Snapshot(runnerID); !ok {
return fmt.Errorf("runner not found")
}
return nil
}
func (s *Server) ensureExecutionOwner(runnerID, execID string) error {
exec, err := s.store.GetExecution(execID)
if err != nil {
return fmt.Errorf("execution not found")
}
if exec.RunnerID != runnerID {
return fmt.Errorf("execution owner mismatch")
}
return nil
}
func (s *Server) setClient(runnerID string, client *protoSocket.TcpClient) {
s.mu.Lock()
old := s.clients[runnerID]
s.clients[runnerID] = client
s.mu.Unlock()
// Close outside the lock: Close() fires disconnect listeners synchronously,
// and those listeners call removeClient which also acquires s.mu. Closing
// inside the lock would cause a deadlock on re-entry.
if old != nil && old != client {
_ = old.Close()
}
}
func (s *Server) removeClient(runnerID string, client *protoSocket.TcpClient) bool {
s.mu.Lock()
defer s.mu.Unlock()
if s.clients[runnerID] == client {
delete(s.clients, runnerID)
return true
}
return false
}
func (s *Server) clientFor(runnerID string) *protoSocket.TcpClient {
s.mu.RLock()
defer s.mu.RUnlock()
return s.clients[runnerID]
}
func (s *Server) connectedRunnerIDs() []string {
s.mu.RLock()
defer s.mu.RUnlock()
ids := make([]string, 0, len(s.clients))
for id := range s.clients {
ids = append(ids, id)
}
sort.Strings(ids)
return ids
}
func parserMap() protoSocket.ParserMap {
return protoSocket.ParserMap{
protoSocket.TypeNameOf(&otopb.RegisterRunnerRequest{}): parserFor(func() *otopb.RegisterRunnerRequest { return &otopb.RegisterRunnerRequest{} }),
protoSocket.TypeNameOf(&otopb.HeartbeatRequest{}): parserFor(func() *otopb.HeartbeatRequest { return &otopb.HeartbeatRequest{} }),
protoSocket.TypeNameOf(&otopb.ExecutionReportRequest{}): parserFor(func() *otopb.ExecutionReportRequest { return &otopb.ExecutionReportRequest{} }),
protoSocket.TypeNameOf(&otopb.LogAppendRequest{}): parserFor(func() *otopb.LogAppendRequest { return &otopb.LogAppendRequest{} }),
protoSocket.TypeNameOf(&otopb.ArtifactReportRequest{}): parserFor(func() *otopb.ArtifactReportRequest { return &otopb.ArtifactReportRequest{} }),
}
}
func parserFor[T proto.Message](newMessage func() T) func([]byte) (proto.Message, error) {
return func(b []byte) (proto.Message, error) {
m := newMessage()
return m, proto.Unmarshal(b, m)
}
}
func splitAddr(addr string) (string, int, error) {
host, portText, err := net.SplitHostPort(addr)
if err != nil {
return "", 0, err
}
port, err := strconv.Atoi(portText)
if err != nil {
return "", 0, err
}
return host, port, nil
}
func validateRunInput(in *cicdstate.RunInput) error {
hasYAML := strings.TrimSpace(in.PipelineYAML) != ""
hasPath := strings.TrimSpace(in.PipelineYAMLPath) != ""
if hasYAML == hasPath {
return fmt.Errorf("run request must set exactly one of pipeline_yaml or pipeline_yaml_path")
}
return nil
}
func runRequestToProto(in *cicdstate.RunInput, runnerID, jobID, execID string) *otopb.RunRequest {
if in == nil {
return nil
}
rr := &otopb.RunRequest{
RunnerId: runnerID,
JobId: jobID,
ExecutionId: execID,
PipelineYamlPath: in.PipelineYAMLPath,
PipelineYaml: in.PipelineYAML,
CommandTypes: append([]string(nil), in.CommandTypes...),
}
if len(in.Variables) > 0 {
rr.Variables = make(map[string]string, len(in.Variables))
for k, v := range in.Variables {
rr.Variables[k] = v
}
}
return rr
}

View file

@ -0,0 +1,207 @@
package runnersocket
import (
"net"
"testing"
"time"
protoSocket "git.toki-labs.com/toki/proto-socket/go"
"github.com/toki/oto/services/core/internal/cicdstate"
"github.com/toki/oto/services/core/internal/runnerregistry"
otopb "github.com/toki/oto/services/core/oto"
)
func testServer(registry *runnerregistry.Registry) *Server {
return New("127.0.0.1:0", registry, cicdstate.NewStore())
}
// pipeClient creates a TcpClient backed by one end of a net.Pipe.
// heartbeat is disabled (intervalSec=0) to avoid background timer interference.
// Cleanup closes both ends.
func pipeClient(t *testing.T) (*protoSocket.TcpClient, net.Conn) {
t.Helper()
a, b := net.Pipe()
client := protoSocket.NewTcpClient(a, 0, 0, parserMap())
t.Cleanup(func() { _ = a.Close(); _ = b.Close() })
return client, b
}
// TestServerSetClientReplacesDuplicate verifies that registering a second client
// for the same runner ID closes the old one and stores the new one.
func TestServerReplacesDuplicateRunnerClient(t *testing.T) {
s := testServer(runnerregistry.New())
client1, _ := pipeClient(t)
client2, _ := pipeClient(t)
s.setClient("runner-A", client1)
s.setClient("runner-A", client2)
// Allow Close() to propagate through connCloseOnce.
time.Sleep(20 * time.Millisecond)
if client1.IsAlive() {
t.Error("old client must be closed after replacement, but it is still alive")
}
if got := s.clientFor("runner-A"); got != client2 {
t.Errorf("clientFor must return new client after replacement; got %v", got)
}
}
// TestServerIgnoresStaleClientDisconnect verifies that removeClient ignores
// a client that is not the current entry for the given runner ID.
func TestServerIgnoresStaleClientDisconnect(t *testing.T) {
s := testServer(runnerregistry.New())
client1, _ := pipeClient(t)
client2, _ := pipeClient(t)
s.setClient("runner-B", client1)
// client2 is a different object simulates a stale disconnect listener.
removed := s.removeClient("runner-B", client2)
if removed {
t.Error("removeClient must return false for a client that is not the current one")
}
if got := s.clientFor("runner-B"); got != client1 {
t.Errorf("current client must still be client1 after stale removeClient; got %v", got)
}
}
// TestServerStaleClientDisconnectDoesNotDisconnectRegistry verifies the complete
// stale-disconnect protection chain:
//
// 1. setClient replaces the old client.
// 2. removeClient returns false for the stale old client.
// 3. Because removeClient returns false, registry.Disconnect is NOT called,
// so the runner remains in a healthy (non-disconnected) state.
func TestServerStaleClientDisconnectDoesNotDisconnectRegistry(t *testing.T) {
registry := runnerregistry.New()
s := testServer(registry)
resp := registry.Register(&otopb.RegisterRunnerRequest{
EnrollmentToken: "tok",
RunnerId: "runner-C",
ProtocolVersion: "oto.runner.v1",
Capability: &otopb.RunnerCapability{Name: "oto-runner", Version: "1.0.0"},
})
if !resp.GetAccepted() {
t.Fatalf("registry.Register rejected: %s", resp.GetRejectReason())
}
registry.Heartbeat("runner-C", otopb.HeartbeatStatus_HEARTBEAT_STATUS_HEALTHY)
client1, _ := pipeClient(t)
client2, _ := pipeClient(t)
s.setClient("runner-C", client1)
// Duplicate connection: client2 replaces client1.
s.setClient("runner-C", client2)
// Simulate stale client1's disconnect listener.
removed := s.removeClient("runner-C", client1)
if removed {
t.Error("stale removeClient should return false")
}
// Because removeClient returned false the disconnect listener guard skips
// registry.Disconnect. The runner must NOT be in disconnected state.
runner, ok := registry.Snapshot("runner-C")
if !ok {
t.Fatal("runner-C not found in registry")
}
if runner.Status == runnerregistry.StatusDisconnected {
t.Errorf("runner status must not be %q after stale disconnect; got %q",
runnerregistry.StatusDisconnected, runner.Status)
}
if got := s.clientFor("runner-C"); got != client2 {
t.Errorf("current client must still be client2; got %v", got)
}
}
// TestServerDuplicateReplaceWithListenerDoesNotDeadlock verifies that replacing
// a client that carries a production-style disconnect listener does not deadlock.
//
// Before the fix, setClient held s.mu while calling old.Close(), which fired
// the listener synchronously. The listener called removeClient → tried to
// acquire s.mu again → deadlock. After the fix, old.Close() is called after
// s.mu is released, so the re-entry is safe.
func TestServerDuplicateReplaceWithListenerDoesNotDeadlock(t *testing.T) {
registry := runnerregistry.New()
s := testServer(registry)
resp := registry.Register(&otopb.RegisterRunnerRequest{
EnrollmentToken: "tok",
RunnerId: "runner-E",
ProtocolVersion: "oto.runner.v1",
Capability: &otopb.RunnerCapability{Name: "oto-runner", Version: "1.0.0"},
})
if !resp.GetAccepted() {
t.Fatalf("registry.Register rejected: %s", resp.GetRejectReason())
}
client1, _ := pipeClient(t)
client2, _ := pipeClient(t)
// Mirror the production disconnect listener from attachClientHandlers.
// This is the re-entry path: removeClient acquires s.mu, which would
// deadlock if setClient still held s.mu during old.Close().
client1.AddDisconnectListener(func(c *protoSocket.TcpClient) {
if s.removeClient("runner-E", c) {
s.registry.Disconnect("runner-E")
}
})
s.setClient("runner-E", client1)
// This must complete without hanging. If the lock-order bug is present the
// goroutine stalls here and the test times out via go test -timeout.
s.setClient("runner-E", client2)
// Allow Close() and the disconnect listener to propagate.
time.Sleep(20 * time.Millisecond)
if client1.IsAlive() {
t.Error("old client must be closed after replacement")
}
if got := s.clientFor("runner-E"); got != client2 {
t.Errorf("clientFor must return new client after replacement; got %v", got)
}
// The stale listener must not have disconnected the runner.
runner, ok := registry.Snapshot("runner-E")
if !ok {
t.Fatal("runner-E not found in registry after replacement")
}
if runner.Status == runnerregistry.StatusDisconnected {
t.Errorf("runner must not be disconnected after stale listener fires; got %q", runner.Status)
}
}
// TestServerHeartbeatUsesRegisteredRunnerID verifies that connectedRunnerIDs
// reflects only currently active clients and that clientFor returns nil after
// the current client is removed.
func TestServerHeartbeatUsesRegisteredRunnerID(t *testing.T) {
s := testServer(runnerregistry.New())
client1, _ := pipeClient(t)
if ids := s.connectedRunnerIDs(); len(ids) != 0 {
t.Errorf("expected no connected runners before any setClient; got %v", ids)
}
s.setClient("runner-D", client1)
ids := s.connectedRunnerIDs()
if len(ids) != 1 || ids[0] != "runner-D" {
t.Errorf("expected [runner-D] after setClient; got %v", ids)
}
// Current client removed → runner no longer in connected list.
s.removeClient("runner-D", client1)
if ids := s.connectedRunnerIDs(); len(ids) != 0 {
t.Errorf("expected no connected runners after removeClient; got %v", ids)
}
if got := s.clientFor("runner-D"); got != nil {
t.Errorf("expected nil from clientFor after removeClient; got %v", got)
}
}