archive: move 05+02,03,04_socket_smoke artifacts to archive and update smoke tests

This commit is contained in:
toki 2026-06-21 05:12:43 +09:00
parent 79f85337e4
commit 1d9e89bd7d
5 changed files with 1709 additions and 1611 deletions

View file

@ -53,42 +53,46 @@ task=m-runner-proto-socket-transport-hardening/05+02,03,04_socket_smoke, plan=0,
| 항목 | 완료 여부 | | 항목 | 완료 여부 |
|------|---------| |------|---------|
| [API-1] smoke 문서와 Makefile/test grouping 갱신 | [ ] | | [API-1] smoke 문서와 Makefile/test grouping 갱신 | [x] |
| [API-2] runner socket smoke 대표성 보강 | [ ] | | [API-2] runner socket smoke 대표성 보강 | [x] |
| [API-3] full local smoke 기록 | [ ] | | [API-3] full local smoke 기록 | [x] |
## 구현 체크리스트 ## 구현 체크리스트
- [ ] 선행 `02+01_dispatch_state`, `03+01,02_runner_actions`, `04+01_compat_boundary` PASS complete evidence를 확인한다. - [x] 선행 `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` - [x] [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` - [x] [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 - [x] [API-3] full local smoke 명령을 실행하고 결과를 기록한다. 검증: Go full test와 runner required test list
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. - [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트 ## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. > **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. > 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. - [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. - [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G07_N.log`로 아카이브한다. - [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G07_N.log`로 아카이브한다.
- [ ] active `PLAN-*-G??.md`를 `plan_cloud_G07_M.log`로 아카이브한다. - [x] 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하는지 확인한다. - [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` 파일을 남기지 않는다. - [x] 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 경로에서 이 체크리스트를 갱신한다. - [x] 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` 직접 호출을 하지 않는다. - [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-runner-proto-socket-transport-hardening/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. - [x] 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`를 작성하지 않는다. - [ ] 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`를 남기지 않는다. - [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
## 계획 대비 변경 사항 ## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._ - `oto_agent_registration_test.dart` 수정 없음: 이미 `group('OtoServerRegistrationClient (Compatibility Fallback HTTP)', ...)` 구조로 compat-boundary 단계에서 정리되어 있었다.
- `SDD.md` 수정 없음: SDD Evidence Map은 이미 S06 evidence 경로를 올바르게 가리키고 있어 변경 불필요.
- Makefile 수정 없음: `RUNNER_REQUIRED_TESTS`에 `oto_server_connection_smoke_test.dart`가 이미 포함되어 있고 smoke 문서의 agent unit 명령과 일치함을 확인.
## 주요 설계 결정 ## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._ - `oto_server_connection_smoke_test.dart` `main()` 내부를 `group('socket-first transport smoke', ...)`과 `group('compatibility fallback HTTP', ...)`로 분리하고 socket 그룹을 앞에 배치했다. 기존 테스트 코드는 내용 변경 없이 indentation(2→4 spaces) 조정과 그룹 래핑만 수행했다.
- 헬퍼 함수 영역의 섹션 주석을 `// ─── Socket session lifecycle tests`에서 `// ─── Test helpers`로 변경해 실제 내용(helper 함수들)과 일치하도록 했다.
- `agent-test/local/agent-smoke.md`에 `services/core/internal/runnersocket/**` 읽기 조건 추가, socket-first transport smoke group 통과를 필수 검증/판정 기준에 명시했다.
## 사용자 리뷰 요청 ## 사용자 리뷰 요청
@ -111,38 +115,52 @@ _기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직
## 검증 결과 ## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
### API-1 중간 검증 ### API-1 중간 검증
```bash ```bash
$ cd apps/runner && dart analyze $ cd apps/runner && dart analyze
(output) Analyzing runner...
No issues found!
``` ```
### API-2 중간 검증 ### API-2 중간 검증
```bash ```bash
$ cd apps/runner && dart test test/oto_server_connection_smoke_test.dart $ cd apps/runner && dart test test/oto_server_connection_smoke_test.dart
(output) 00:03 +3: socket-first transport smoke OtoServerSocketRegistrationClient socket session close is idempotent after server disconnect
00:04 +4: compatibility fallback HTTP OTO Dart runner registers with Go OTO Server via compatibility fallback HTTP, goes online, and disconnects on close
...
00:10 +12: All tests passed!
``` ```
### API-3 중간 검증 ### API-3 중간 검증 (최종 검증 겸용)
```bash ```bash
$ cd services/core && go test -count=1 ./... $ cd services/core && go test -count=1 ./...
(output) ok github.com/toki/oto/services/core/internal/cicdstate 0.005s
ok github.com/toki/oto/services/core/internal/httpserver 0.078s
ok github.com/toki/oto/services/core/internal/runnerregistry 0.004s
ok github.com/toki/oto/services/core/internal/runnersocket 0.049s
$ cd apps/runner && dart analyze $ cd apps/runner && dart analyze
(output) Analyzing runner...
No issues found!
$ 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 $ 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) 00:14 +96: All tests passed!
``` ```
### 최종 검증 ### 최종 검증
```bash ```bash
$ cd services/core && go test -count=1 ./... $ cd services/core && go test -count=1 ./...
(output) ok github.com/toki/oto/services/core/internal/cicdstate 0.005s
ok github.com/toki/oto/services/core/internal/httpserver 0.078s
ok github.com/toki/oto/services/core/internal/runnerregistry 0.004s
ok github.com/toki/oto/services/core/internal/runnersocket 0.049s
$ cd apps/runner && dart analyze $ cd apps/runner && dart analyze
(output) Analyzing runner...
No issues found!
$ 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 $ 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) 00:14 +96: All tests passed!
``` ```
--- ---
@ -150,3 +168,24 @@ $ cd apps/runner && dart test test/oto_agent_migration_plan_test.dart test/oto_a
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification 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. > If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged. > 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
- 발견된 문제: 없음
- 검증 확인:
- `git diff --check` - PASS; whitespace error 없음.
- `cd apps/runner && dart analyze` - PASS; `No issues found!`.
- `cd services/core && go test -count=1 ./...` - PASS; core packages 통과.
- `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` - PASS; `+96: All tests passed!`.
- `cd apps/runner && dart test -r expanded test/oto_server_connection_smoke_test.dart` - PASS; socket-first transport smoke 3개와 compatibility fallback HTTP group이 분리되어 `+12: All tests passed!`.
- 다음 단계: PASS 절차로 active plan/review를 log로 아카이브하고 `complete.log` 작성 후 task directory를 archive로 이동한다.

View file

@ -0,0 +1,51 @@
# Complete - m-runner-proto-socket-transport-hardening/05+02,03,04_socket_smoke
## 완료 일시
2026-06-21
## 요약
Runner proto-socket transport hardening의 socket smoke 정리 작업을 1회 리뷰 루프로 완료했다. 최종 판정은 PASS다.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_cloud_G07_0.log` | `code_review_cloud_G07_0.log` | PASS | socket-first smoke group과 HTTP compatibility fallback group 분리, agent smoke 기준, full local smoke 검증이 충족됨 |
## 구현/정리 내용
- `agent-test/local/agent-smoke.md`에 runnersocket 변경 시 socket-first transport smoke group 통과 기준을 추가했다.
- `apps/runner/test/oto_server_connection_smoke_test.dart`에서 socket-first transport smoke 3개를 앞쪽 그룹으로 분리하고 HTTP compatibility fallback tests를 별도 그룹으로 유지했다.
- 선행 `dispatch-state`, `runner-actions`, `compat-boundary` split PASS complete evidence를 확인하고 S06 smoke evidence로 종합했다.
## 최종 검증
- `git diff --check` - PASS; whitespace error 없음.
- `cd apps/runner && dart analyze` - PASS; `No issues found!`.
- `cd services/core && go test -count=1 ./...` - PASS; `internal/cicdstate`, `internal/httpserver`, `internal/runnerregistry`, `internal/runnersocket` 통과.
- `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` - PASS; `+96: All tests passed!`.
- `cd apps/runner && dart test -r expanded test/oto_server_connection_smoke_test.dart` - PASS; socket-first transport smoke 3개와 compatibility fallback HTTP tests가 분리되어 `+12: All tests passed!`.
## Roadmap Completion
- Milestone: `agent-roadmap/phase/control-plane-product-surface/milestones/runner-proto-socket-transport-hardening.md`
- Completed task ids:
- `socket-smoke`: PASS; evidence=`agent-task/archive/2026/06/m-runner-proto-socket-transport-hardening/05+02,03,04_socket_smoke/plan_cloud_G07_0.log`, `agent-task/archive/2026/06/m-runner-proto-socket-transport-hardening/05+02,03,04_socket_smoke/code_review_cloud_G07_0.log`; verification=`git diff --check`, `cd apps/runner && dart analyze`, `cd services/core && go test -count=1 ./...`, `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`, `cd apps/runner && dart test -r expanded test/oto_server_connection_smoke_test.dart`
- Not completed task ids: 없음
## Spec Completion
- SDD: `agent-roadmap/sdd/control-plane-product-surface/runner-proto-socket-transport-hardening/SDD.md`
- Completed scenario ids:
- `S06`: PASS; task=`socket-smoke`; evidence=`agent-task/archive/2026/06/m-runner-proto-socket-transport-hardening/05+02,03,04_socket_smoke/plan_cloud_G07_0.log`, `agent-task/archive/2026/06/m-runner-proto-socket-transport-hardening/05+02,03,04_socket_smoke/code_review_cloud_G07_0.log`; verification=`git diff --check`, `cd apps/runner && dart analyze`, `cd services/core && go test -count=1 ./...`, `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`, `cd apps/runner && dart test -r expanded test/oto_server_connection_smoke_test.dart`
- Not completed scenario ids: 없음
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -10,7 +10,7 @@ last_rule_updated_at: 2026-06-12
## 읽기 조건 ## 읽기 조건
- `apps/runner/lib/oto/agent/**`, `apps/runner/lib/cli/commands/command_agent.dart`, `apps/runner/assets/script/shell/oto_agent_bootstrap.sh`, `services/core/cmd/oto-core/main.go`, `services/core/internal/httpserver/**`, 또는 `services/core/internal/runnerregistry/**` 변경 검증이 필요한 경우 - `apps/runner/lib/oto/agent/**`, `apps/runner/lib/cli/commands/command_agent.dart`, `apps/runner/assets/script/shell/oto_agent_bootstrap.sh`, `services/core/cmd/oto-core/main.go`, `services/core/internal/httpserver/**`, `services/core/internal/runnersocket/**`, 또는 `services/core/internal/runnerregistry/**` 변경 검증이 필요한 경우
## 적용 범위 ## 적용 범위
@ -70,6 +70,7 @@ last_rule_updated_at: 2026-06-12
- runner agent 도메인 변경 후 agent 관련 `cd apps/runner && dart test ...` 명령을 실행한다. - runner agent 도메인 변경 후 agent 관련 `cd apps/runner && dart test ...` 명령을 실행한다.
- services/core registration, bootstrap command, release URL validation 변경 후 `cd services/core && go test ./...`를 실행한다. - services/core registration, bootstrap command, release URL validation 변경 후 `cd services/core && go test ./...`를 실행한다.
- OS별 bootstrap matrix 또는 release asset 이름을 바꾸면 `README.md`, `apps/runner/test/oto_agent_bootstrap_script_test.dart`, 이 문서의 asset 이름이 함께 갱신되었는지 확인한다. - OS별 bootstrap matrix 또는 release asset 이름을 바꾸면 `README.md`, `apps/runner/test/oto_agent_bootstrap_script_test.dart`, 이 문서의 asset 이름이 함께 갱신되었는지 확인한다.
- `apps/runner/lib/oto/agent/registration_client.dart` 또는 `services/core/internal/runnersocket/**` 변경 후 `oto_server_connection_smoke_test.dart` socket-first transport smoke group(heartbeat/lifecycle, cancel dispatch, idempotent close)이 통과해야 한다.
## 보조 검증 ## 보조 검증
@ -82,6 +83,7 @@ last_rule_updated_at: 2026-06-12
- `cd apps/runner && dart analyze`가 issue 없이 종료한다. - `cd apps/runner && dart analyze`가 issue 없이 종료한다.
- 지정한 `cd apps/runner && dart test`가 모두 통과한다. - 지정한 `cd apps/runner && dart test`가 모두 통과한다.
- 지정한 `cd services/core && go test ./...`가 모두 통과한다. - 지정한 `cd services/core && go test ./...`가 모두 통과한다.
- `oto_server_connection_smoke_test.dart` socket-first transport smoke group의 heartbeat/lifecycle, cancel dispatch, idempotent close tests가 모두 통과한다. HTTP compatibility fallback HTTP group은 보조 evidence이며 기본 smoke 판정 기준이 아니다.
## 기준 출력 예시 ## 기준 출력 예시

View file

@ -18,6 +18,332 @@ const _runnerId = 'oto-smoke-runner';
const _runnerAlias = 'oto-smoke-alias'; const _runnerAlias = 'oto-smoke-alias';
void main() { void main() {
group('socket-first transport smoke', () {
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 stream completion after close().
final pushSession = session as OtoServerPushJobSession;
final runRequestsDone = Completer<void>();
final cancelRequestsDone = Completer<void>();
final runSub = pushSession.runRequests.listen(
(_) {},
onDone: runRequestsDone.complete,
);
final cancelSub = pushSession.cancelRequests.listen(
(_) {},
onDone: cancelRequestsDone.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 socket streams.
await session.close();
await runRequestsDone.future.timeout(
const Duration(seconds: 2),
onTimeout: () =>
fail('runRequests stream did not close after session.close()'),
);
await cancelRequestsDone.future.timeout(
const Duration(seconds: 2),
onTimeout: () => fail(
'cancelRequests stream did not close after session.close()',
),
);
await runSub.cancel();
await cancelSub.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 receives socket cancel requests from HTTP cancel action',
() async {
final port = await _freePort();
final serverAddr = '$_host:$port';
const jobId = 'smoke-job-socket-cancel';
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'],
heartbeatInterval: const Duration(milliseconds: 200),
);
final session = await client.openSession(agentConfig);
expect(session.result.accepted, isTrue);
final pushSession = session as OtoServerPushJobSession;
final runReceived = Completer<String>();
final runSub = pushSession.runRequests.listen((request) {
if (!runReceived.isCompleted) {
expect(request.runnerId, _runnerId);
expect(request.jobId, jobId);
expect(request.executionId, isNotEmpty);
runReceived.complete(request.executionId);
}
});
var expectedExecutionId = '';
final cancelReceived = Completer<String>();
final cancelSub = pushSession.cancelRequests.listen((request) {
if (!cancelReceived.isCompleted) {
expect(request.runnerId, _runnerId);
expect(request.executionId, expectedExecutionId);
expect(request.reason, 'socket cancel smoke');
cancelReceived.complete(request.executionId);
}
});
final httpClient = http.Client();
final jobs = OtoServerJobClient(
serverUrl: 'http://$serverAddr',
runnerId: _runnerId,
client: httpClient,
);
try {
final createResp = await httpClient.post(
Uri.parse('http://$serverAddr/api/v1/jobs'),
headers: {'content-type': 'application/json'},
body: jsonEncode({
'id': jobId,
'name': 'socket cancel smoke',
'run_request': {
'pipeline_yaml': 'commands:\n - type: Shell',
'command_types': ['Shell'],
},
}),
);
expect(createResp.statusCode, equals(201));
expectedExecutionId = await runReceived.future.timeout(
const Duration(seconds: 3),
onTimeout: () => fail('socket RunRequest was not received'),
);
final cancelRes = await jobs.cancelRun(
executionId: expectedExecutionId,
reason: 'socket cancel smoke',
);
expect(cancelRes.success, isTrue);
await cancelReceived.future.timeout(
const Duration(seconds: 3),
onTimeout: () => fail('socket CancelRunRequest was not received'),
);
final execResp = await httpClient.get(
Uri.parse(
'http://$serverAddr/api/v1/executions/$expectedExecutionId',
),
);
final execJson = jsonDecode(execResp.body) as Map<String, dynamic>;
expect(execJson['state'], equals('canceled'));
} finally {
await runSub.cancel();
await cancelSub.cancel();
httpClient.close();
await session.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)),
);
});
group('compatibility fallback HTTP', () {
test( test(
'OTO Dart runner registers with Go OTO Server via compatibility fallback HTTP, goes online, and disconnects on close', 'OTO Dart runner registers with Go OTO Server via compatibility fallback HTTP, goes online, and disconnects on close',
() async { () async {
@ -224,8 +550,8 @@ void main() {
await fakeBinDir.create(recursive: true); await fakeBinDir.create(recursive: true);
final fakeCurl = File('${fakeBinDir.path}/curl'); final fakeCurl = File('${fakeBinDir.path}/curl');
await fakeCurl.writeAsString('''#!/bin/sh await fakeCurl.writeAsString('''#!/bin/sh
out_file="" out_file=""
while [ \$# -gt 0 ]; do while [ \$# -gt 0 ]; do
if [ "\$1" = "-o" ]; then if [ "\$1" = "-o" ]; then
out_file="\$2" out_file="\$2"
shift 2 shift 2
@ -234,14 +560,14 @@ while [ \$# -gt 0 ]; do
else else
shift 1 shift 1
fi fi
done done
if [ -n "\$out_file" ]; then if [ -n "\$out_file" ]; then
cp "${fakeTarGz.path}" "\$out_file" cp "${fakeTarGz.path}" "\$out_file"
else else
cat "${scriptFile.absolute.path}" cat "${scriptFile.absolute.path}"
fi fi
'''); ''');
await Process.run('chmod', ['+x', fakeCurl.path]); await Process.run('chmod', ['+x', fakeCurl.path]);
// Append custom paths and options to the command string // Append custom paths and options to the command string
@ -573,18 +899,18 @@ fi
'name': 'remote run smoke', 'name': 'remote run smoke',
'run_request': { 'run_request': {
'pipeline_yaml': ''' 'pipeline_yaml': '''
property: property:
workspace: . workspace: .
commands: commands:
- command: Print - command: Print
id: say-hello id: say-hello
param: param:
message: hello from smoke message: hello from smoke
pipeline: pipeline:
id: smoke-pipe id: smoke-pipe
workflow: workflow:
- exe: say-hello - exe: say-hello
''', ''',
'command_types': ['Print'], 'command_types': ['Print'],
}, },
}), }),
@ -725,19 +1051,19 @@ pipeline:
'name': 'variable merge smoke', 'name': 'variable merge smoke',
'run_request': { 'run_request': {
'pipeline_yaml': ''' 'pipeline_yaml': '''
property: property:
workspace: . workspace: .
FLAVOR: original FLAVOR: original
commands: commands:
- command: Print - command: Print
id: print-flavor id: print-flavor
param: param:
message: "<!property.FLAVOR>" message: "<!property.FLAVOR>"
pipeline: pipeline:
id: var-pipe id: var-pipe
workflow: workflow:
- exe: print-flavor - exe: print-flavor
''', ''',
'variables': {'FLAVOR': 'release'}, 'variables': {'FLAVOR': 'release'},
'command_types': ['Print'], 'command_types': ['Print'],
}, },
@ -881,18 +1207,18 @@ pipeline:
'name': 'invalid property smoke', 'name': 'invalid property smoke',
'run_request': { 'run_request': {
'pipeline_yaml': ''' 'pipeline_yaml': '''
property: property:
- bad - bad
commands: commands:
- command: Print - command: Print
id: print-flavor id: print-flavor
param: param:
message: "<!property.FLAVOR>" message: "<!property.FLAVOR>"
pipeline: pipeline:
id: bad-pipe id: bad-pipe
workflow: workflow:
- exe: print-flavor - exe: print-flavor
''', ''',
'variables': {'FLAVOR': 'release'}, 'variables': {'FLAVOR': 'release'},
'command_types': ['Print'], 'command_types': ['Print'],
}, },
@ -1146,21 +1472,21 @@ pipeline:
'name': 'artifact event smoke', 'name': 'artifact event smoke',
'run_request': { 'run_request': {
'pipeline_yaml': ''' 'pipeline_yaml': '''
property: property:
workspace: . workspace: .
artifacts: artifacts:
- name: smoke-report - name: smoke-report
path: dist/report.json path: dist/report.json
commands: commands:
- command: Print - command: Print
id: say-hello id: say-hello
param: param:
message: hello from artifact smoke message: hello from artifact smoke
pipeline: pipeline:
id: artifact-pipe id: artifact-pipe
workflow: workflow:
- exe: say-hello - exe: say-hello
''', ''',
'command_types': ['Print'], 'command_types': ['Print'],
}, },
}), }),
@ -1353,330 +1679,10 @@ 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 stream completion after close().
final pushSession = session as OtoServerPushJobSession;
final runRequestsDone = Completer<void>();
final cancelRequestsDone = Completer<void>();
final runSub = pushSession.runRequests.listen(
(_) {},
onDone: runRequestsDone.complete,
);
final cancelSub = pushSession.cancelRequests.listen(
(_) {},
onDone: cancelRequestsDone.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 socket streams.
await session.close();
await runRequestsDone.future.timeout(
const Duration(seconds: 2),
onTimeout: () =>
fail('runRequests stream did not close after session.close()'),
);
await cancelRequestsDone.future.timeout(
const Duration(seconds: 2),
onTimeout: () => fail(
'cancelRequests stream did not close after session.close()',
),
);
await runSub.cancel();
await cancelSub.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 receives socket cancel requests from HTTP cancel action',
() async {
final port = await _freePort();
final serverAddr = '$_host:$port';
const jobId = 'smoke-job-socket-cancel';
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'],
heartbeatInterval: const Duration(milliseconds: 200),
);
final session = await client.openSession(agentConfig);
expect(session.result.accepted, isTrue);
final pushSession = session as OtoServerPushJobSession;
final runReceived = Completer<String>();
final runSub = pushSession.runRequests.listen((request) {
if (!runReceived.isCompleted) {
expect(request.runnerId, _runnerId);
expect(request.jobId, jobId);
expect(request.executionId, isNotEmpty);
runReceived.complete(request.executionId);
}
}); });
var expectedExecutionId = '';
final cancelReceived = Completer<String>();
final cancelSub = pushSession.cancelRequests.listen((request) {
if (!cancelReceived.isCompleted) {
expect(request.runnerId, _runnerId);
expect(request.executionId, expectedExecutionId);
expect(request.reason, 'socket cancel smoke');
cancelReceived.complete(request.executionId);
}
});
final httpClient = http.Client();
final jobs = OtoServerJobClient(
serverUrl: 'http://$serverAddr',
runnerId: _runnerId,
client: httpClient,
);
try {
final createResp = await httpClient.post(
Uri.parse('http://$serverAddr/api/v1/jobs'),
headers: {'content-type': 'application/json'},
body: jsonEncode({
'id': jobId,
'name': 'socket cancel smoke',
'run_request': {
'pipeline_yaml': 'commands:\n - type: Shell',
'command_types': ['Shell'],
},
}),
);
expect(createResp.statusCode, equals(201));
expectedExecutionId = await runReceived.future.timeout(
const Duration(seconds: 3),
onTimeout: () => fail('socket RunRequest was not received'),
);
final cancelRes = await jobs.cancelRun(
executionId: expectedExecutionId,
reason: 'socket cancel smoke',
);
expect(cancelRes.success, isTrue);
await cancelReceived.future.timeout(
const Duration(seconds: 3),
onTimeout: () => fail('socket CancelRunRequest was not received'),
);
final execResp = await httpClient.get(
Uri.parse(
'http://$serverAddr/api/v1/executions/$expectedExecutionId',
),
);
final execJson = jsonDecode(execResp.body) as Map<String, dynamic>;
expect(execJson['state'], equals('canceled'));
} finally {
await runSub.cancel();
await cancelSub.cancel();
httpClient.close();
await session.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 // Test helpers
typedef _CoreWithSocket = ({Process process, String socketAddr}); typedef _CoreWithSocket = ({Process process, String socketAddr});