archive: move 05+02,03,04_socket_smoke artifacts to archive and update smoke tests
This commit is contained in:
parent
79f85337e4
commit
1d9e89bd7d
5 changed files with 1709 additions and 1611 deletions
|
|
@ -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-2] runner socket smoke 대표성 보강 | [ ] |
|
||||
| [API-3] full local smoke 기록 | [ ] |
|
||||
| [API-1] smoke 문서와 Makefile/test grouping 갱신 | [x] |
|
||||
| [API-2] runner socket smoke 대표성 보강 | [x] |
|
||||
| [API-3] full local smoke 기록 | [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`
|
||||
- [ ] [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의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
- [x] 선행 `02+01_dispatch_state`, `03+01,02_runner_actions`, `04+01_compat_boundary` PASS complete evidence를 확인한다.
|
||||
- [x] [API-1] `agent-test/local/agent-smoke.md`와 Makefile/test grouping을 socket-first 기준으로 갱신한다. 검증: `cd apps/runner && dart analyze`
|
||||
- [x] [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-3] full local smoke 명령을 실행하고 결과를 기록한다. 검증: Go full test와 runner required test list
|
||||
- [x] 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이 있어 유지했다고 확인한다.
|
||||
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
|
||||
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
|
||||
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G07_N.log`로 아카이브한다.
|
||||
- [x] active `PLAN-*-G??.md`를 `plan_cloud_G07_M.log`로 아카이브한다.
|
||||
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
|
||||
- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
|
||||
- [x] PASS이면 active task 디렉터리 `agent-task/m-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이고 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/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`를 남기지 않는다.
|
||||
|
||||
## 계획 대비 변경 사항
|
||||
|
||||
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
|
||||
- `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 중간 검증
|
||||
```bash
|
||||
$ cd apps/runner && dart analyze
|
||||
(output)
|
||||
Analyzing runner...
|
||||
No issues found!
|
||||
```
|
||||
|
||||
### API-2 중간 검증
|
||||
```bash
|
||||
$ 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
|
||||
$ 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
|
||||
(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
|
||||
(output)
|
||||
00:14 +96: All tests passed!
|
||||
```
|
||||
|
||||
### 최종 검증
|
||||
```bash
|
||||
$ 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
|
||||
(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
|
||||
(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?**
|
||||
> 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
|
||||
- 발견된 문제: 없음
|
||||
- 검증 확인:
|
||||
- `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로 이동한다.
|
||||
|
|
@ -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
|
||||
|
||||
- 없음
|
||||
|
||||
## 후속 작업
|
||||
|
||||
- 없음
|
||||
|
|
@ -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 ...` 명령을 실행한다.
|
||||
- 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 이름이 함께 갱신되었는지 확인한다.
|
||||
- `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 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 판정 기준이 아니다.
|
||||
|
||||
## 기준 출력 예시
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,332 @@ const _runnerId = 'oto-smoke-runner';
|
|||
const _runnerAlias = 'oto-smoke-alias';
|
||||
|
||||
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(
|
||||
'OTO Dart runner registers with Go OTO Server via compatibility fallback HTTP, goes online, and disconnects on close',
|
||||
() async {
|
||||
|
|
@ -1353,330 +1679,10 @@ pipeline:
|
|||
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});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue