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-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로 이동한다.

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 ...` 명령을 실행한다.
- 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 판정 기준이 아니다.
## 기준 출력 예시

View file

@ -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});