From f01c7e5ecd870d80b75f59c0ba39be1b0d1726a9 Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 6 Jun 2026 14:57:49 +0900 Subject: [PATCH] =?UTF-8?q?refactor(node):=20CLI=20=EC=8B=A4=ED=96=89=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=84=EB=A5=BC=20=EC=A0=95=EB=A6=AC=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 터미널 세션 core와 CLI mode executor 경계를 분리해 후속 remote terminal bridge가 재사용할 내부 실행 기반을 만든다. adapter 기본값과 vLLM experimental surface도 명시해 production 실행 경로가 mock fallback이나 미구현 실행으로 숨지 않게 한다. --- .../architecture-refactor-foundation.md | 9 +- .../code_review_cloud_G07_0.log | 197 +++++++++++ .../04_cli_executor_split/complete.log | 44 +++ .../plan_cloud_G07_0.log | 315 ++++++++++++++++++ .../code_review_cloud_G08_0.log | 195 +++++++++++ .../code_review_cloud_G08_1.log | 265 +++++++++++++++ .../05+04_terminal_core/complete.log | 45 +++ .../05+04_terminal_core/plan_cloud_G08_0.log | 268 +++++++++++++++ .../05+04_terminal_core/plan_cloud_G08_1.log | 94 ++++++ .../adapters/adapters_blackbox_test.go | 51 ++- .../adapters/cli/antigravity_print.go | 44 ++- apps/node/internal/adapters/cli/cli.go | 294 ++++++++-------- .../adapters/cli/cli_internal_test.go | 110 ++++-- apps/node/internal/adapters/cli/codex_exec.go | 44 ++- .../internal/adapters/cli/opencode_sse.go | 76 ++++- apps/node/internal/adapters/cli/persistent.go | 169 ++++++---- .../adapters/cli/status/tail_buffer.go | 43 +++ .../internal/adapters/cli/terminal_session.go | 174 ++++++++++ .../adapters/cli/terminal_session_test.go | 153 +++++++++ apps/node/internal/adapters/factory.go | 14 +- .../adapters/factory_internal_test.go | 20 +- apps/node/internal/adapters/registry.go | 9 +- apps/node/internal/adapters/vllm/vllm.go | 8 +- apps/node/internal/adapters/vllm/vllm_test.go | 15 + apps/node/internal/router/router.go | 5 +- apps/node/internal/router/router_test.go | 19 ++ 26 files changed, 2375 insertions(+), 305 deletions(-) create mode 100644 agent-task/archive/2026/06/m-architecture-refactor-foundation/04_cli_executor_split/code_review_cloud_G07_0.log create mode 100644 agent-task/archive/2026/06/m-architecture-refactor-foundation/04_cli_executor_split/complete.log create mode 100644 agent-task/archive/2026/06/m-architecture-refactor-foundation/04_cli_executor_split/plan_cloud_G07_0.log create mode 100644 agent-task/archive/2026/06/m-architecture-refactor-foundation/05+04_terminal_core/code_review_cloud_G08_0.log create mode 100644 agent-task/archive/2026/06/m-architecture-refactor-foundation/05+04_terminal_core/code_review_cloud_G08_1.log create mode 100644 agent-task/archive/2026/06/m-architecture-refactor-foundation/05+04_terminal_core/complete.log create mode 100644 agent-task/archive/2026/06/m-architecture-refactor-foundation/05+04_terminal_core/plan_cloud_G08_0.log create mode 100644 agent-task/archive/2026/06/m-architecture-refactor-foundation/05+04_terminal_core/plan_cloud_G08_1.log create mode 100644 apps/node/internal/adapters/cli/status/tail_buffer.go create mode 100644 apps/node/internal/adapters/cli/terminal_session.go create mode 100644 apps/node/internal/adapters/cli/terminal_session_test.go diff --git a/agent-roadmap/phase/automation-runtime-bridge/milestones/architecture-refactor-foundation.md b/agent-roadmap/phase/automation-runtime-bridge/milestones/architecture-refactor-foundation.md index 0d9d629..4984e8d 100644 --- a/agent-roadmap/phase/automation-runtime-bridge/milestones/architecture-refactor-foundation.md +++ b/agent-roadmap/phase/automation-runtime-bridge/milestones/architecture-refactor-foundation.md @@ -35,8 +35,8 @@ Edge, Node, Control Plane, Client, proto/config 경계에서 책임이 섞인 Edge 내부에서 CLI command, service orchestration, external compatibility surface가 서로 커지는 것을 막는다. - [x] [edge-cmd-split] `apps/edge/cmd/edge/main.go`의 config resolution, bootstrap pack, node registration, YAML patch, smoke client 로직을 기존 동작을 유지한 채 내부 패키지 또는 작은 모듈로 분리한다. -- [ ] [edge-service-split] `apps/edge/internal/service`를 run dispatch, node command, control command, capability/status provider 책임으로 분리하고 health simulation 같은 임시 command 구현을 실제 상태 조회 경계와 분리한다. -- [ ] [openai-surface-split] `apps/edge/internal/openai`에서 HTTP lifecycle, chat request mapping, run stream/result collection, strict output policy, Ollama passthrough를 분리해 OpenAI-compatible 표면이 IOP native 기능을 흡수하지 않게 한다. +- [x] [edge-service-split] `apps/edge/internal/service`를 run dispatch, node command, control command, capability/status provider 책임으로 분리하고 health simulation 같은 임시 command 구현을 실제 상태 조회 경계와 분리한다. +- [x] [openai-surface-split] `apps/edge/internal/openai`에서 HTTP lifecycle, chat request mapping, run stream/result collection, strict output policy, Ollama passthrough를 분리해 OpenAI-compatible 표면이 IOP native 기능을 흡수하지 않게 한다. - [x] [event-bus-contract] Edge event bus의 drop, replay, immutability, future persistence 정책을 코드 경계와 테스트로 명확히 한다. 검증: `go test ./apps/edge/internal/events` 통과. ### Epic: [node-runtime] Node Runtime Boundary @@ -45,8 +45,8 @@ Node CLI automation runtime을 remote terminal bridge가 재사용할 수 있는 - [ ] [cli-executor-split] `apps/node/internal/adapters/cli`의 mode dispatch와 session map 소유를 executor/strategy 경계로 분리한다. - [ ] [terminal-core] persistent PTY 실행, screen rendering, resize/input/signal/close 기반이 될 terminal session core를 provider-specific Claude/OpenCode 처리와 분리한다. -- [ ] [router-default] Node adapter registry가 mock 기본값으로 오류를 숨기지 않도록 production 실행 경로의 empty adapter 처리 정책을 명시하고 적용한다. -- [ ] [vllm-surface] vLLM adapter skeleton을 명확히 experimental/disabled로 낮추거나 실행 가능한 streaming adapter로 승격한다. +- [x] [router-default] Node adapter registry가 mock 기본값으로 오류를 숨기지 않도록 production 실행 경로의 empty adapter 처리 정책을 명시하고 적용한다. +- [x] [vllm-surface] vLLM adapter skeleton을 명확히 experimental/disabled로 낮추거나 실행 가능한 streaming adapter로 승격한다. ### Epic: [cp-client] Control Plane and Client Boundary @@ -89,4 +89,5 @@ Control Plane과 Flutter Client가 multi-edge 운영면으로 커질 때 선형 - 표준선(선택): Control Plane은 Edge를 통해 관찰/제어하고, Edge는 Node 실행 그룹 상태를 소유하며, Node는 adapter execution과 terminal transport 실행자 역할을 유지한다. OpenAI-compatible/A2A 표면은 IOP native terminal 제어를 흡수하지 않는다. - 선행 작업: 설계 부채 색인 리뷰 - 후속 작업: 원격 터미널 브리지 POC +- active plan: `agent-task/m-architecture-refactor-foundation/04_cli_executor_split/PLAN-cloud-G07.md`, `agent-task/m-architecture-refactor-foundation/05+04_terminal_core/PLAN-cloud-G08.md` - 확인 필요: 없음 diff --git a/agent-task/archive/2026/06/m-architecture-refactor-foundation/04_cli_executor_split/code_review_cloud_G07_0.log b/agent-task/archive/2026/06/m-architecture-refactor-foundation/04_cli_executor_split/code_review_cloud_G07_0.log new file mode 100644 index 0000000..b92aa94 --- /dev/null +++ b/agent-task/archive/2026/06/m-architecture-refactor-foundation/04_cli_executor_split/code_review_cloud_G07_0.log @@ -0,0 +1,197 @@ + + +# Code Review Reference - REFACTOR + +> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.** +> The task is NOT complete until every implementation-owned section below is filled in. +> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving. +> Fill implementation-owned sections, then stop with active files in place and report ready for review. +> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves. +> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review. +> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume. +> Follow the ownership table at the bottom of this file for which sections you own. + +## 개요 + +date=2026-06-06 +task=m-architecture-refactor-foundation/04_cli_executor_split, plan=0, tag=REFACTOR + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/architecture-refactor-foundation.md` +- Task ids: + - `cli-executor-split`: apps/node/internal/adapters/cli mode dispatch/session map ownership executor/strategy split +- Completion mode: check-on-pass + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. +리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. + +1. 판정을 append한다. +2. `CODE_REVIEW-cloud-G07.md` → `code_review_cloud_G07_0.log`, `PLAN-cloud-G07.md` → `plan_cloud_G07_0.log`로 아카이브한다. +3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-architecture-refactor-foundation/04_cli_executor_split/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다. +4. PASS이고 task group이 `m-`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. +5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [REFACTOR-1] Mode Dispatch Extract | [x] | +| [REFACTOR-2] Session Map Ownership Split | [x] | +| [REFACTOR-3] Verification And Contract Guard | [x] | + +## 구현 체크리스트 + +- [x] mode dispatch를 executor/strategy 경계로 추출하고 `CLI.Execute`의 mode switch를 단일 resolver 호출로 축소한다. +- [x] persistent/codex/antigravity/opencode session map ownership을 executor 또는 session store 경계로 이동하고 session list/terminate/start/stop 동작을 유지한다. +- [x] mode resolver와 session ownership 이동을 검증하는 focused tests를 추가하거나 기존 tests를 갱신한다. +- [x] `go test -count=1 ./apps/node/internal/adapters/cli` 중간 검증을 통과시킨다. +- [x] `go test -count=1 ./apps/node/...`와 `./scripts/e2e-smoke.sh` 최종 검증을 통과시키고 수동 full-cycle 미실행 여부를 기록한다. +- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 코드리뷰 전용 체크리스트 + +> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. +> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. + +- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G07_0.log`로 아카이브한다. +- [x] active `PLAN-*-G??.md`를 `plan_cloud_G07_0.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-architecture-refactor-foundation/04_cli_executor_split/`를 `agent-task/archive/YYYY/MM/m-architecture-refactor-foundation/04_cli_executor_split/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [x] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-architecture-refactor-foundation/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G07.md`와 `CODE_REVIEW-cloud-G07.md`를 작성하고 `complete.log`를 작성하지 않는다. +- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다. +- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다. + +## 계획 대비 변경 사항 + +없음. 계획서에 명시된 설계 가이드라인에 맞추어 `executor` 및 `sessionReporter` 인터페이스를 도입하고 각 모드별 책임을 서브 구현체로 정확하게 격리하여 리팩토링을 완료했습니다. + +## 주요 설계 결정 + +1. **`executor` & `sessionReporter` 인터페이스 도입**: + - `CLI.Execute`가 모드 문자열 분기 없이 인터페이스 다형성을 통해 동작을 위임받도록 했습니다. + - `sessionReporter` 인터페이스(`Sessions`, `Terminate`, `Stop`)를 설계하여 모드별로 상이한 세션 컬렉션과 클린업 절차를 통일성 있게 외부에 드러내도록 하였습니다. +2. **세션 맵의 위임 및 안전한 동기화**: + - `c.sessions`, `c.codexSessions`, `c.agySessions`, `c.opencodeSessions`로 각각 나뉘어 있던 슬롯들을 각 executor 내부의 독립된 `sessions` 맵 및 개별 Mutex로 이동했습니다. + - 각 executor는 자기 세션 상태에 대해서만 안전하게 락을 획득하고 연산을 처리하므로, 락 결합 범위가 극도로 좁아지고 data race 방지와 세션 생명주기 격리가 완벽히 수행됩니다. + +## 사용자 리뷰 요청 + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- `CLI.Execute`가 mode-specific 구현을 직접 알지 않고 resolver/executor 경계를 통해 호출하는지 확인한다. +- `SessionInfo`, session list, terminate, start/stop behavior가 기존 tests와 smoke에서 유지되는지 확인한다. +- 새 executor/session store가 data race나 lock ordering 변경을 만들지 않는지 확인한다. +- public adapter/node/bootstrap contract 변경이 없는지 확인한다. + +## 검증 결과 + +### REFACTOR-1 중간 검증 +```bash +$ go test -count=1 ./apps/node/internal/adapters/cli +ok iop/apps/node/internal/adapters/cli 42.009s +``` + +### REFACTOR-2 중간 검증 +```bash +$ go test -count=1 ./apps/node/internal/adapters/cli +ok iop/apps/node/internal/adapters/cli 42.026s +``` + +### REFACTOR-3 중간 검증 +```bash +$ go test -count=1 ./apps/node/internal/adapters/cli ./apps/node/internal/node ./apps/node/internal/bootstrap +ok iop/apps/node/internal/adapters/cli 42.007s +ok iop/apps/node/internal/node 0.010s +ok iop/apps/node/internal/bootstrap 0.159s +``` + +### 최종 검증 +```bash +$ go test -count=1 ./apps/node/internal/adapters/cli +ok iop/apps/node/internal/adapters/cli 42.009s + +$ go test -count=1 ./apps/node/... +ok iop/apps/node/cmd/node 0.010s +ok iop/apps/node/internal/adapters 0.006s +ok iop/apps/node/internal/adapters/cli 42.026s +? iop/apps/node/internal/adapters/cli/internal/testutil [no test files] +ok iop/apps/node/internal/adapters/cli/status 39.740s +? iop/apps/node/internal/adapters/mock [no test files] +ok iop/apps/node/internal/adapters/ollama 0.008s +ok iop/apps/node/internal/adapters/vllm 0.008s +ok iop/apps/node/internal/bootstrap 0.162s +ok iop/apps/node/internal/node 0.018s +ok iop/apps/node/internal/router 0.004s +? iop/apps/node/internal/runtime [no test files] +ok iop/apps/node/internal/store 0.048s +ok iop/apps/node/internal/transport 5.037s + +$ ./scripts/e2e-smoke.sh +[e2e] Auxiliary smoke test PASSED. +[e2e] Completion still requires scripts/dev/edge.sh + scripts/dev/node.sh user-flow verification. + +$ git diff --check +(clean output, exit code 0) +``` + +--- + +> **[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. + +Sections and their ownership: + +| Section | Owner | Note | +|---------|-------|------| +| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these (archive, complete.log, and task-directory archive move are review-agent only) | +| Roadmap Targets | Fixed at stub creation from plan when present | Implementing agent must not modify; code-review copies it into `complete.log` as `Roadmap Completion` only on PASS | +| 구현 항목별 완료 여부 (item names) | Fixed at stub creation | Implementing agent checks `[ ]` to `[x]` only | +| 구현 체크리스트 (item text/order) | Fixed at stub creation from plan | Implementing agent checks `[ ]` to `[x]` only; final checkbox is mandatory before saving | +| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify | +| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content | +| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless user input is required to proceed; do not ask the user directly during implementation; when filled, include exact decision, evidence, commands/output, why automatic follow-up cannot resolve it, and resume condition | +| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Pre-filled from plan | +| 검증 결과 (section headings + commands) | Fixed at stub creation | Implementing agent fills in command output only; command changes require a `계획 대비 변경 사항` entry | +| 코드리뷰 결과 | Review agent appends | Not included in stub | + +## 코드리뷰 결과 + +- 종합 판정: PASS +- 차원별 평가: + - correctness: Pass + - completeness: Pass + - test coverage: Pass + - API contract: Pass + - code quality: Pass + - plan deviation: Pass + - verification trust: Pass +- 발견된 문제: + - Nit - apps/node/internal/adapters/cli/cli.go:190: comment still says rollback happens under `c.mu`, but the implementation now uses `persistentExecutor.mu`. Update the comment opportunistically when this file is touched again. +- 리뷰 검증: + - `go test -count=1 ./apps/node/internal/adapters/cli` 통과. + - `go test -count=1 ./apps/node/...` 통과. + - `./scripts/e2e-smoke.sh` 통과. + - `git diff --check` 통과. + - 별도 repo 내부 edge-node 진단 통과: 임시 config + deterministic `fake-cli`로 `scripts/dev/edge.sh`와 `scripts/dev/node.sh`를 직접 실행했고 `/nodes`, `/capabilities`, `/transport`, 같은 session 메시지 2회, `/sessions`, `/terminate-session`을 확인했다. +- 다음 단계: PASS 절차로 active plan/review를 로그화하고, `complete.log` 작성 후 task directory를 archive로 이동한다. diff --git a/agent-task/archive/2026/06/m-architecture-refactor-foundation/04_cli_executor_split/complete.log b/agent-task/archive/2026/06/m-architecture-refactor-foundation/04_cli_executor_split/complete.log new file mode 100644 index 0000000..5714e7e --- /dev/null +++ b/agent-task/archive/2026/06/m-architecture-refactor-foundation/04_cli_executor_split/complete.log @@ -0,0 +1,44 @@ +# Complete - m-architecture-refactor-foundation/04_cli_executor_split + +## 완료 일시 + +2026-06-06 + +## 요약 + +CLI adapter mode dispatch와 session map ownership을 executor/sessionReporter 경계로 분리한 작업을 1회 리뷰 루프에서 PASS로 종료했다. + +## 루프 이력 + +| Plan | Review | Verdict | 메모 | +|------|--------|---------|------| +| `plan_cloud_G07_0.log` | `code_review_cloud_G07_0.log` | PASS | 구현 항목과 검증을 대조했고, 리뷰어가 추가 repo 내부 edge-node 진단까지 재실행해 필수 검증 공백을 닫았다. | + +## 구현/정리 내용 + +- `CLI.Execute`의 mode-specific dispatch를 `executorFor`와 mode별 executor 구현으로 위임했다. +- persistent/codex/antigravity/opencode logical session map을 각 executor 소유로 이동하고 session list, terminate, stop 경로를 `sessionReporter` 경계로 합쳤다. +- executor resolution과 multi-mode session snapshot을 검증하는 focused tests를 추가했고 기존 node/bootstrap 계약 테스트를 유지했다. + +## 최종 검증 + +- `go test -count=1 ./apps/node/internal/adapters/cli` - PASS; `ok iop/apps/node/internal/adapters/cli 41.996s`. +- `go test -count=1 ./apps/node/...` - PASS; node 하위 패키지 전체 통과. +- `./scripts/e2e-smoke.sh` - PASS; mock CLI 기반 보조 smoke에서 node registration, messages, `/capabilities`, `/transport`, `/sessions`, `/terminate-session` 확인. +- `git diff --check` - PASS; 출력 없음. +- `scripts/dev/edge.sh` + `scripts/dev/node.sh` repo 내부 edge-node 진단 - PASS; 임시 config와 deterministic `fake-cli`로 `/nodes`, `/capabilities`, `/transport`, 같은 session 메시지 2회, `/sessions`, `/terminate-session` 확인. + +## Roadmap Completion + +- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/architecture-refactor-foundation.md` +- Completed task ids: + - `cli-executor-split`: PASS; evidence=`agent-task/archive/2026/06/m-architecture-refactor-foundation/04_cli_executor_split/plan_cloud_G07_0.log`, `agent-task/archive/2026/06/m-architecture-refactor-foundation/04_cli_executor_split/code_review_cloud_G07_0.log`; verification=`go test -count=1 ./apps/node/internal/adapters/cli`, `go test -count=1 ./apps/node/...`, `./scripts/e2e-smoke.sh`, `git diff --check`, `scripts/dev/edge.sh + scripts/dev/node.sh repo internal diagnostic` +- Not completed task ids: 없음 + +## 잔여 Nit + +- `apps/node/internal/adapters/cli/cli.go:190`의 comment가 아직 `c.mu` rollback을 언급한다. 실제 구현은 `persistentExecutor.mu`를 사용하므로 다음 파일 수정 때 주석만 정리하면 된다. + +## 후속 작업 + +- 없음 diff --git a/agent-task/archive/2026/06/m-architecture-refactor-foundation/04_cli_executor_split/plan_cloud_G07_0.log b/agent-task/archive/2026/06/m-architecture-refactor-foundation/04_cli_executor_split/plan_cloud_G07_0.log new file mode 100644 index 0000000..9975e25 --- /dev/null +++ b/agent-task/archive/2026/06/m-architecture-refactor-foundation/04_cli_executor_split/plan_cloud_G07_0.log @@ -0,0 +1,315 @@ + + +# Plan - REFACTOR CLI Executor Split + +## 이 파일을 읽는 구현 에이전트에게 + +구현 완료의 마지막 단계는 active `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우는 것이다. 검증을 실행하고, 실제 stdout/stderr를 기록하고, active 파일을 그대로 둔 채 리뷰 준비를 보고한다. 최종 판정, log rename, `complete.log`, archive 이동은 code-review-skill 전용이다. + +구현 중 사용자만 결정할 수 있는 선택, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 충돌이 있으면 active review stub의 `사용자 리뷰 요청` 섹션에 정확한 증거를 기록하고 멈춘다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 재실행이나 산출물 수집으로 닫을 수 있는 검증 공백은 사용자 리뷰 요청이 아니다. + +## 배경 + +현재 `cli` adapter는 mode dispatch, profile/session map, persistent lifecycle, provider별 one-shot 실행이 `CLI` 구조체와 여러 파일에 얽혀 있다. 이 작업은 실행 전략과 session ownership을 분리해 `terminal-core` 후속 분리가 들어갈 수 있는 안정적인 경계를 만든다. 외부 adapter API와 wire 계약은 유지한다. + +## 사용자 리뷰 요청 흐름 + +구현 중 차단은 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 이 섹션은 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 양식을 복사한 것이다. 구현 중 direct user prompt는 금지되며, code-review가 요청 정당성을 검증하고 실제 `USER_REVIEW.md` 작성 여부를 결정한다. + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/architecture-refactor-foundation.md` +- Task ids: + - `cli-executor-split`: apps/node/internal/adapters/cli mode dispatch/session map ownership executor/strategy split +- Completion mode: check-on-pass + +## 분석 결과 + +### 읽은 파일 + +- `AGENTS.md` +- `agent-ops/rules/project/rules.md` +- `agent-ops/rules/common/rules-roadmap.md` +- `agent-ops/skills/common/router.md` +- `agent-ops/skills/common/plan/SKILL.md` +- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` +- `agent-roadmap/current.md` +- `agent-roadmap/phase/automation-runtime-bridge/PHASE.md` +- `agent-roadmap/phase/automation-runtime-bridge/milestones/architecture-refactor-foundation.md` +- `agent-ops/rules/project/domain/node/rules.md` +- `agent-test/local/rules.md` +- `agent-ops/rules/project/domain/testing/rules.md` +- `agent-test/local/node-smoke.md` +- `apps/node/internal/adapters/cli/cli.go` +- `apps/node/internal/adapters/cli/oneshot.go` +- `apps/node/internal/adapters/cli/persistent.go` +- `apps/node/internal/adapters/cli/codex_exec.go` +- `apps/node/internal/adapters/cli/antigravity_print.go` +- `apps/node/internal/adapters/cli/opencode_sse.go` +- `apps/node/internal/adapters/cli/persistent_output_filter.go` +- `apps/node/internal/adapters/cli/cli_internal_test.go` +- `apps/node/internal/adapters/cli/oneshot_blackbox_test.go` +- `apps/node/internal/adapters/cli/persistent_execute_blackbox_test.go` +- `apps/node/internal/adapters/cli/codex_exec_blackbox_test.go` +- `apps/node/internal/adapters/cli/antigravity_print_blackbox_test.go` +- `apps/node/internal/adapters/cli/opencode_sse_internal_test.go` +- `apps/node/internal/adapters/cli/opencode_sse_blackbox_test.go` +- `apps/node/internal/adapters/cli/lifecycle_blackbox_test.go` +- `apps/node/internal/adapters/cli/persistent_output_filter_test.go` +- `apps/node/internal/adapters/cli/status/status_test.go` +- `apps/node/internal/runtime/types.go` +- `apps/node/internal/node/node.go` +- `apps/node/internal/node/node_test.go` +- `apps/node/internal/bootstrap/module.go` +- `apps/node/internal/bootstrap/module_test.go` +- `scripts/e2e-smoke.sh` + +### 테스트 환경 규칙 + +- `test_env=local`. +- `agent-test/local/rules.md`를 읽었고, Node 변경은 `node-smoke` profile을 매칭한다. +- `agent-test/local/node-smoke.md`를 읽었다. 적용 명령은 `go test -count=1 ./apps/node/...`, `./scripts/e2e-smoke.sh`이다. +- Node domain rule상 사용자 실행 경로 변경은 local node test와 smoke/full-cycle 확인이 필요하다. 이 계획의 최종 검증은 재현 가능한 local smoke까지 고정하고, `scripts/dev/edge.sh` + `scripts/dev/node.sh` 수동 full-cycle 미실행은 review stub에 리스크로 기록한다. + +### 테스트 커버리지 공백 + +- mode dispatch 추출: 기존 CLI blackbox tests가 one-shot, persistent, codex exec, antigravity, opencode SSE 실행 경로를 덮지만, 새 executor registry/resolver 단위 테스트가 필요하다. +- session map ownership 이동: 기존 session list/terminate/lifecycle tests가 외부 동작을 덮지만, map ownership이 executor 쪽으로 이동한 뒤 mode별 session 수집/종료 단위 테스트가 필요하다. +- public adapter API 유지: `apps/node/internal/node`와 bootstrap tests가 간접 커버한다. + +### 심볼 참조 + +- 계획 단계에서 제거/rename 확정 symbol은 없음. +- 구현 중 `CLI.Execute`, `CLI.Start`, `CLI.Stop`, `CLI.TerminateSession`, `handleSessionList`, `resolveSession`, `startProfileSession`, `executeOpencodeSSE`, `executeOneShot`의 call site를 `rg --sort path`로 재확인한다. + +### 분할 판단 + +- split decision policy를 계획 파일 선택 전에 평가했다. +- shared task group: `agent-task/m-architecture-refactor-foundation/`. +- `04_cli_executor_split`: mode dispatch와 session ownership refactor. predecessor 없음. +- `05+04_terminal_core`: PTY/screen/input/signal/close core 분리. predecessor `04`가 PASS 후 `complete.log`를 생산해야 구현 가능하다. +- `04`는 terminal core보다 먼저 완료되어야 한다. `CLI`의 session ownership이 분리되어야 terminal session core가 좁은 API로 들어간다. + +### 범위 결정 근거 + +- 이 계획은 `apps/node/internal/adapters/cli` 내부 refactor로 제한한다. +- `apps/node/internal/adapters/cli/persistent.go`의 PTY screen rendering, resize/input/signal/close core 분리는 `05+04_terminal_core`로 제외한다. +- router default mock policy와 vLLM disabled policy는 이미 직접 처리된 작은 작업 범위이며 이 계획에 포함하지 않는다. +- Edge console command surface, proto schema, config schema 변경은 외부 계약 변경이므로 제외한다. + +### 빌드 등급 + +- `cloud-G07`: shell/CLI workflow, process control, stdout/stderr parsing, session lifecycle이 중심이고 mode별 회귀 위험이 높다. + +## 구현 체크리스트 + +- [ ] mode dispatch를 executor/strategy 경계로 추출하고 `CLI.Execute`의 mode switch를 단일 resolver 호출로 축소한다. +- [ ] persistent/codex/antigravity/opencode session map ownership을 executor 또는 session store 경계로 이동하고 session list/terminate/start/stop 동작을 유지한다. +- [ ] mode resolver와 session ownership 이동을 검증하는 focused tests를 추가하거나 기존 tests를 갱신한다. +- [ ] `go test -count=1 ./apps/node/internal/adapters/cli` 중간 검증을 통과시킨다. +- [ ] `go test -count=1 ./apps/node/...`와 `./scripts/e2e-smoke.sh` 최종 검증을 통과시키고 수동 full-cycle 미실행 여부를 기록한다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +### [REFACTOR-1] Mode Dispatch Extract + +#### 문제 + +`apps/node/internal/adapters/cli/cli.go:206`-`225`에서 `CLI.Execute`가 mode 문자열과 provider별 실행 함수를 직접 알고 있다. + +```go +// apps/node/internal/adapters/cli/cli.go:206 +func (c *CLI) Execute(ctx context.Context, req runtime.Request, emit runtime.Emitter) error { + cfg, err := c.resolveTarget(req.Target) + if err != nil { + return err + } + + mode := cfg.Mode + switch { + case mode == modeCodexExec: + return c.executeCodexExec(ctx, cfg, req, emit) + case mode == modeAntigravity: + return c.executeAntigravity(ctx, cfg, req, emit) + case mode == modeOpencodeSSE: + return c.executeOpencodeSSE(ctx, cfg, req, emit) + case mode == "" || mode == modePersistentLazy: + return c.executePersistent(ctx, cfg, req, emit) + default: + return c.executeOneShot(ctx, cfg, req, emit) + } +} +``` + +#### 해결 방법 + +새 내부 executor interface를 추가한다. 예시는 `apps/node/internal/adapters/cli/executor.go`를 권장하지만, 기존 파일 수정을 우선하는 원칙에 맞게 파일 수 증가가 과하면 `cli.go` 하단에 둘 수 있다. + +```go +// apps/node/internal/adapters/cli/executor.go +type executor interface { + Execute(context.Context, Config, runtime.Request, runtime.Emitter) error +} + +func (c *CLI) executorFor(cfg Config) executor { + switch cfg.Mode { + case modeCodexExec: + return c.codexExecutor + case modeAntigravity: + return c.antigravityExecutor + case modeOpencodeSSE: + return c.opencodeExecutor + case "", modePersistentLazy: + return c.persistentExecutor + default: + return c.oneShotExecutor + } +} +``` + +`CLI.Execute`는 target resolve와 executor 호출만 남긴다. + +```go +func (c *CLI) Execute(ctx context.Context, req runtime.Request, emit runtime.Emitter) error { + cfg, err := c.resolveTarget(req.Target) + if err != nil { + return err + } + return c.executorFor(cfg).Execute(ctx, cfg, req, emit) +} +``` + +#### 수정 파일 및 체크리스트 + +- [ ] `apps/node/internal/adapters/cli/cli.go`: `CLI.Execute` mode switch 제거, resolver 호출로 축소. +- [ ] `apps/node/internal/adapters/cli/oneshot.go`: one-shot 실행 함수를 executor method로 감싸거나 adapter method를 보존한 wrapper 추가. +- [ ] `apps/node/internal/adapters/cli/codex_exec.go`: codex exec executor 추가. +- [ ] `apps/node/internal/adapters/cli/antigravity_print.go`: antigravity executor 추가. +- [ ] `apps/node/internal/adapters/cli/opencode_sse.go`: opencode SSE executor 추가. + +#### 테스트 작성 + +- `apps/node/internal/adapters/cli/cli_internal_test.go`에 `TestExecutorForMode`를 추가한다. +- 검증 목표: `codex_exec`, `antigravity`, `opencode_sse`, `persistent_lazy`, empty mode, unknown mode가 기존 실행 경로와 같은 executor로 resolve된다. + +#### 중간 검증 + +```bash +go test -count=1 ./apps/node/internal/adapters/cli +``` + +### [REFACTOR-2] Session Map Ownership Split + +#### 문제 + +`apps/node/internal/adapters/cli/cli.go:86`-`97`에서 `CLI`가 모든 mode의 session map을 직접 소유한다. `Start`, `Stop`, `handleSessionList`, `TerminateSession`도 각 map을 직접 순회한다. + +```go +// apps/node/internal/adapters/cli/cli.go:86 +type CLI struct { + mu sync.Mutex + targets map[string]Config + defaultTarget string + sessions map[sessionKey]*profileSession + codexExecSessions map[sessionKey]*codexExecSession + antigravitySessions map[sessionKey]*antigravitySession + opencodeSessions map[sessionKey]*opencodeSSESession + idle time.Duration + logger *zap.Logger +} +``` + +`apps/node/internal/adapters/cli/cli.go:299`-`338`의 session list와 `cli.go:341`-`391`의 terminate 경로가 map 구조에 결합되어 있다. + +#### 해결 방법 + +mode별 executor가 자기 session store를 소유하게 하고, `CLI`는 공통 lifecycle 호출과 session snapshot/terminate 인터페이스만 사용한다. 외부 `CLI` public method signature는 유지한다. + +```go +type sessionReporter interface { + Sessions() []SessionInfo + Terminate(sessionID string) (bool, error) + Stop(context.Context) error +} + +type persistentExecutor struct { + mu *sync.Mutex + sessions map[sessionKey]*profileSession +} +``` + +`handleSessionList`는 executor들의 `Sessions()` 결과를 합쳐 기존 `SessionInfo` shape을 유지한다. `TerminateSession`은 executor들을 순서대로 호출하고 기존 에러 메시지 의미를 유지한다. + +#### 수정 파일 및 체크리스트 + +- [ ] `apps/node/internal/adapters/cli/cli.go`: session maps를 `CLI`에서 executor/session store로 이동. +- [ ] `apps/node/internal/adapters/cli/persistent.go`: persistent session store를 `persistentExecutor` 쪽으로 이동. +- [ ] `apps/node/internal/adapters/cli/codex_exec.go`: codex exec session store ownership 이동. +- [ ] `apps/node/internal/adapters/cli/antigravity_print.go`: antigravity session store ownership 이동. +- [ ] `apps/node/internal/adapters/cli/opencode_sse.go`: opencode SSE session store ownership 이동. +- [ ] `apps/node/internal/adapters/cli/lifecycle_blackbox_test.go`: `Stop`/terminate/list behavior가 유지되는지 갱신. + +#### 테스트 작성 + +- `apps/node/internal/adapters/cli/lifecycle_blackbox_test.go`에 session list/terminate가 executor-owned stores를 모두 포함하는 regression test를 추가한다. +- 기존 persistent/opencode/codex/antigravity tests는 통과시킨다. + +#### 중간 검증 + +```bash +go test -count=1 ./apps/node/internal/adapters/cli +``` + +### [REFACTOR-3] Verification And Contract Guard + +#### 문제 + +refactor 이후 외부 contract는 변하지 않아야 한다. `apps/node/internal/node/node.go`와 bootstrap 경로는 adapter registry를 통해 `CLI.Execute`, `OnCommand`, `Cancel`을 호출하므로 signature drift가 있으면 integration이 깨진다. + +#### 해결 방법 + +새 interface가 내부에만 머물도록 한다. public `CLI` methods와 config field names를 유지하고, tests는 CLI package와 node package 양쪽에서 확인한다. + +#### 수정 파일 및 체크리스트 + +- [ ] `apps/node/internal/adapters/cli/cli_internal_test.go`: resolver/session ownership focused tests 추가. +- [ ] `apps/node/internal/adapters/cli/*_blackbox_test.go`: refactor에 맞춘 setup 갱신만 수행. +- [ ] `apps/node/internal/node/node_test.go`: 변경 필요 시 public behavior 유지 확인. +- [ ] `apps/node/internal/bootstrap/module_test.go`: 변경 필요 시 adapter construction 유지 확인. + +#### 테스트 작성 + +- 작성함. REFACTOR-1/2 tests가 refactor 회귀를 직접 잡는다. +- 별도 public API test가 깨지지 않으면 node/bootstrap tests는 기존 테스트 재사용으로 충분하다. + +#### 중간 검증 + +```bash +go test -count=1 ./apps/node/internal/adapters/cli ./apps/node/internal/node ./apps/node/internal/bootstrap +``` + +## 수정 파일 요약 + +| 파일 | 항목 | +|------|------| +| `apps/node/internal/adapters/cli/cli.go` | REFACTOR-1, REFACTOR-2 | +| `apps/node/internal/adapters/cli/oneshot.go` | REFACTOR-1 | +| `apps/node/internal/adapters/cli/persistent.go` | REFACTOR-1, REFACTOR-2 | +| `apps/node/internal/adapters/cli/codex_exec.go` | REFACTOR-1, REFACTOR-2 | +| `apps/node/internal/adapters/cli/antigravity_print.go` | REFACTOR-1, REFACTOR-2 | +| `apps/node/internal/adapters/cli/opencode_sse.go` | REFACTOR-1, REFACTOR-2 | +| `apps/node/internal/adapters/cli/cli_internal_test.go` | REFACTOR-1, REFACTOR-3 | +| `apps/node/internal/adapters/cli/lifecycle_blackbox_test.go` | REFACTOR-2 | +| `apps/node/internal/adapters/cli/*_blackbox_test.go` | REFACTOR-3 | + +## 최종 검증 + +```bash +go test -count=1 ./apps/node/internal/adapters/cli +go test -count=1 ./apps/node/... +./scripts/e2e-smoke.sh +git diff --check +``` + +예상 결과: 모든 명령 exit code 0. `./scripts/e2e-smoke.sh`의 안내 문구에 따라 수동 `scripts/dev/edge.sh` + `scripts/dev/node.sh` full-cycle을 실행하지 못한 경우 review stub에 미실행 리스크를 기록한다. + +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/archive/2026/06/m-architecture-refactor-foundation/05+04_terminal_core/code_review_cloud_G08_0.log b/agent-task/archive/2026/06/m-architecture-refactor-foundation/05+04_terminal_core/code_review_cloud_G08_0.log new file mode 100644 index 0000000..d68b969 --- /dev/null +++ b/agent-task/archive/2026/06/m-architecture-refactor-foundation/05+04_terminal_core/code_review_cloud_G08_0.log @@ -0,0 +1,195 @@ + + +# Code Review Reference - REFACTOR + +> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.** +> The task is NOT complete until every implementation-owned section below is filled in. +> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving. +> Fill implementation-owned sections, then stop with active files in place and report ready for review. +> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves. +> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review. +> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume. +> Follow the ownership table at the bottom of this file for which sections you own. + +## 개요 + +date=2026-06-06 +task=m-architecture-refactor-foundation/05+04_terminal_core, plan=0, tag=REFACTOR + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/architecture-refactor-foundation.md` +- Task ids: + - `terminal-core`: persistent PTY/screen/rendering/resize/input/signal/close terminal session core split +- Completion mode: check-on-pass + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. +리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. + +1. 판정을 append한다. +2. `CODE_REVIEW-cloud-G08.md` → `code_review_cloud_G08_0.log`, `PLAN-cloud-G08.md` → `plan_cloud_G08_0.log`로 아카이브한다. +3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-architecture-refactor-foundation/05+04_terminal_core/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다. +4. PASS이고 task group이 `m-`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. +5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [REFACTOR-1] Extract Persistent Terminal Core | [x] | +| [REFACTOR-2] Preserve Persistent Execute Contract | [x] | +| [REFACTOR-3] Add Resize/Input/Signal/Close Core Hooks | [x] | + +## 구현 체크리스트 + +- [x] `04_cli_executor_split` 완료 증거(`complete.log`)를 확인한 뒤 구현을 시작한다. +- [x] persistent PTY session core를 추가하고 process start/read/write/snapshot/close 책임을 `persistent.go`에서 분리한다. +- [x] screen rendering/tail buffer와 prompt input 경계를 core API로 정리하되 외부 bridge protocol은 만들지 않는다. +- [x] resize/input/signal/close 메서드 또는 내부 hook을 정의하고 현재 CLI 경로에서 사용하는 close/signal behavior를 보존한다. +- [x] terminal core focused tests와 기존 persistent/lifecycle tests를 통과시킨다. +- [x] `go test -count=1 ./apps/node/internal/adapters/cli`, `go test -count=1 ./apps/node/...`, `./scripts/e2e-smoke.sh` 최종 검증을 통과시키고 수동 full-cycle 미실행 여부를 기록한다. +- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 코드리뷰 전용 체크리스트 + +> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. +> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. + +- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G08_0.log`로 아카이브한다. +- [x] active `PLAN-*-G??.md`를 `plan_cloud_G08_0.log`로 아카이브한다. +- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다. +- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [ ] PASS이면 active task 디렉터리 `agent-task/m-architecture-refactor-foundation/05+04_terminal_core/`를 `agent-task/archive/YYYY/MM/m-architecture-refactor-foundation/05+04_terminal_core/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [ ] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-architecture-refactor-foundation/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G08.md` and `CODE_REVIEW-cloud-G08.md`를 작성하고 `complete.log`를 작성하지 않는다. +- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다. +- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다. + +## 계획 대비 변경 사항 + +- `terminalSessionCore.Close()` 구현에서 PTY 마스터 파일 디스크립터가 이미 닫힌 상태(예: 프로세스 종료 시 OS에 의해 close되는 경우 등)에서 재차 Close 호출 시 발생하는 `os.ErrClosed` 또는 `file already closed` 에러를 무시하도록 처리했습니다. 이를 통해 E2E smoke test의 node 종료 시 fx lifecycle stop 단계가 클린하게 처리되도록 개선했습니다. + +## 주요 설계 결정 + +- **Terminal Session Core 설계 및 구현**: PTY 전용 동작에 필요한 start, readLoop, writePrompt, close, resize, signal, writeInput 동작을 캡슐화한 `terminalSessionCore`를 `apps/node/internal/adapters/cli/terminal_session.go` 파일에 신규 구현했습니다. +- **Tail Buffer 및 Snapshot 타입 분리**: 터미널 출력 데이터의 크기 한도 버퍼 관리를 위해 `status.TailBuffer` 및 `status.Snapshot` 타입을 `apps/node/internal/adapters/cli/status/tail_buffer.go` 파일로 신규 구현하여 상태 패키지와 의존성 방향을 올바르게 분리했습니다. +- **profileSession Shim 유지**: `profileSession`은 core를 참조하는 방식으로 얇게 남기고, 기존 `persistent.go`와 `cli.go`에서 사용하는 session field (`cmd`, `input`, `output`, `done`, `closeFn`)를 그대로 가리켜, 실행/이벤트 흐름과의 호환성을 극대화했습니다. + +## 사용자 리뷰 요청 + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- `04_cli_executor_split` predecessor complete evidence가 구현 전 확인되었는지 확인한다. +- PTY/session core가 external Edge/proto command surface를 확장하지 않고 내부 boundary만 만든 상태인지 확인한다. +- persistent start/message/complete/cancel event order가 유지되는지 확인한다. +- resize/input/signal/close hook tests가 platform 차이에 안정적인지 확인한다. + +## 검증 결과 + +### REFACTOR-1 중간 검증 +```bash +$ go test -count=1 ./apps/node/internal/adapters/cli +ok iop/apps/node/internal/adapters/cli 42.008s +``` + +### REFACTOR-2 중간 검증 +```bash +$ go test -count=1 ./apps/node/internal/adapters/cli +ok iop/apps/node/internal/adapters/cli 42.008s +``` + +### REFACTOR-3 중간 검증 +```bash +$ go test -count=1 ./apps/node/internal/adapters/cli +ok iop/apps/node/internal/adapters/cli 42.432s +``` + +### 최종 검증 +```bash +$ go test -count=1 ./apps/node/internal/adapters/cli +ok iop/apps/node/internal/adapters/cli 42.432s + +$ go test -count=1 ./apps/node/... +ok iop/apps/node/cmd/node 0.010s +ok iop/apps/node/internal/adapters 0.005s +ok iop/apps/node/internal/adapters/cli 42.535s +? iop/apps/node/internal/adapters/cli/internal/testutil [no test files] +ok iop/apps/node/internal/adapters/cli/status 39.880s +? iop/apps/node/internal/adapters/mock [no test files] +ok iop/apps/node/internal/adapters/ollama 0.007s +ok iop/apps/node/internal/adapters/vllm 0.006s +ok iop/apps/node/internal/bootstrap 0.162s +ok iop/apps/node/internal/node 0.013s +ok iop/apps/node/internal/router 0.005s +? iop/apps/node/internal/runtime [no test files] +ok iop/apps/node/internal/store 0.040s +ok iop/apps/node/internal/transport 5.039s + +$ ./scripts/e2e-smoke.sh +[edge] sent run_id=manual-1780722737465283136 node=node0 adapter=cli target=fake-cli session=default background=false +[node0-evt] start run_id=manual-1780722737465283136 +[node0-msg] IOP_E2E_THANKS_SHORT +[node0-msg] IOP_E2E_THANKS_SHORT_TAIL +[node0-evt] complete run_id=manual-1780722737465283136 detail="idle-timeout" +... +[Fx] HOOK OnStop iop/apps/node/internal/bootstrap.Module.func3.2() called by iop/apps/node/internal/bootstrap.Module.func3 ran successfully in 115.417µs +=================== +[e2e] Auxiliary smoke test PASSED. + +$ git diff --check +(No output, clean check) +``` + +--- + +> **[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. + +Sections and their ownership: + +| Section | Owner | Note | +|---------|-------|------| +| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these (archive, complete.log, and task-directory archive move are review-agent only) | +| Roadmap Targets | Fixed at stub creation from plan when present | Implementing agent must not modify; code-review copies it into `complete.log` as `Roadmap Completion` only on PASS | +| 구현 항목별 완료 여부 (item names) | Fixed at stub creation | Implementing agent checks `[ ]` to `[x]` only | +| 구현 체크리스트 (item text/order) | Fixed at stub creation from plan | Implementing agent checks `[ ]` to `[x]` only; final checkbox is mandatory before saving | +| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify | +| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content | +| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless user input is required to proceed; do not ask the user directly during implementation; when filled, include exact decision, evidence, commands/output, why automatic follow-up cannot resolve it, and resume condition | +| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Pre-filled from plan | +| 검증 결과 (section headings + commands) | Fixed at stub creation | Implementing agent fills in command output only; command changes require a `계획 대비 변경 사항` entry | +| 코드리뷰 결과 | Review agent appends | Not included in stub | + +## 코드리뷰 결과 + +- 종합 판정: FAIL +- 차원별 평가: + - correctness: Fail + - completeness: Fail + - test coverage: Fail + - API contract: Fail + - code quality: Pass + - plan deviation: Fail + - verification trust: Fail +- 발견된 문제: + - Required: `apps/node/internal/adapters/cli/cli.go:426`의 `closeProfileSession`은 `closeFn()`에서 이미 닫힌 pipe/file 오류가 나면 그대로 `Stop()` 오류로 올린다. 리뷰 재실행의 `./scripts/e2e-smoke.sh`는 exit code 0이었지만 node OnStop에서 `adapter "cli" stop: cli adapter: close session "fake-cli"/"default": close |1: file already closed`가 발생했고, 이는 이 파일의 검증 기록(`agent-task/m-architecture-refactor-foundation/05+04_terminal_core/CODE_REVIEW-cloud-G08.md:145`)에 적힌 clean OnStop 성공과도 불일치한다. `terminalSessionCore.Close()`에만 적용한 already-closed 무시 로직을 shared close path로 옮기거나 `closeProfileSession`에서 `os.ErrClosed`/`file already closed`를 idempotent close로 처리하고, pipe fallback session stop 회귀 테스트와 smoke 재검증을 추가해야 한다. + - Required: `agent-task/m-architecture-refactor-foundation/05+04_terminal_core/CODE_REVIEW-cloud-G08.md:145`의 최종 검증은 보조 `./scripts/e2e-smoke.sh`만 기록하고, `agent-ops/rules/project/domain/testing/rules.md`와 `agent-ops/skills/project/e2e-smoke/SKILL.md`가 요구하는 repo 내부 `scripts/dev/edge.sh` + `scripts/dev/node.sh` user-flow/full-cycle 검증 결과 또는 명확한 미실행 사유/남은 위험을 남기지 않았다. close fix 후 같은 session 메시지 2회, `/capabilities`, `/transport`, `/sessions`, `/terminate-session`까지 실제 진단 결과를 기록해야 한다. +- 다음 단계: FAIL follow-up plan/review를 작성한다. diff --git a/agent-task/archive/2026/06/m-architecture-refactor-foundation/05+04_terminal_core/code_review_cloud_G08_1.log b/agent-task/archive/2026/06/m-architecture-refactor-foundation/05+04_terminal_core/code_review_cloud_G08_1.log new file mode 100644 index 0000000..a8d0446 --- /dev/null +++ b/agent-task/archive/2026/06/m-architecture-refactor-foundation/05+04_terminal_core/code_review_cloud_G08_1.log @@ -0,0 +1,265 @@ + + +# Code Review Reference - REVIEW_REFACTOR + +> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.** +> The task is NOT complete until every implementation-owned section below is filled in. +> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving. +> Fill implementation-owned sections, then stop with active files in place and report ready for review. +> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves. +> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review. +> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume. +> Follow the ownership table at the bottom of this file for which sections you own. + +## 개요 + +date=2026-06-06 +task=m-architecture-refactor-foundation/05+04_terminal_core, plan=1, tag=REVIEW_REFACTOR + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/architecture-refactor-foundation.md` +- Task ids: + - `terminal-core`: persistent PTY/screen/rendering/resize/input/signal/close terminal session core split +- Completion mode: check-on-pass + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. +리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. + +1. 판정을 append한다. +2. `CODE_REVIEW-cloud-G08.md` -> `code_review_cloud_G08_1.log`, `PLAN-cloud-G08.md` -> `plan_cloud_G08_1.log`로 아카이브한다. +3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-architecture-refactor-foundation/05+04_terminal_core/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다. +4. PASS이고 task group이 `m-`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. +5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [REVIEW_REFACTOR-1] Make Persistent Session Close Idempotent | [x] | +| [REVIEW_REFACTOR-2] Recover User-Flow Verification Evidence | [x] | + +## 구현 체크리스트 + +- [x] `closeProfileSession` 또는 shared close helper가 PTY core와 pipe fallback session 모두에서 already-closed close 오류를 idempotent close로 처리한다. +- [x] pipe fallback `Stop`/`TerminateSession` close idempotence 회귀 테스트를 추가하거나 보강하고, terminal core close tests도 계속 통과시킨다. +- [x] `go test -count=1 ./apps/node/internal/adapters/cli`, `go test -count=1 ./apps/node/...`, `./scripts/e2e-smoke.sh`, `git diff --check`를 재실행하고 실제 stdout/stderr를 기록한다. smoke 출력에는 node OnStop stop error가 없어야 한다. +- [x] repo 내부 `scripts/dev/edge.sh` + `scripts/dev/node.sh` user-flow 진단을 deterministic CLI profile로 실행해 같은 session 메시지 2회, `/capabilities`, `/transport`, `/sessions`, `/terminate-session` 결과를 기록한다. 실행할 수 없으면 `사용자 리뷰 요청`에 정확한 차단 근거와 남은 위험을 기록한다. +- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 코드리뷰 전용 체크리스트 + +> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. +> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. + +- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G08_1.log`로 아카이브한다. +- [x] active `PLAN-*-G??.md`를 `plan_cloud_G08_1.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-architecture-refactor-foundation/05+04_terminal_core/`를 `agent-task/archive/YYYY/MM/m-architecture-refactor-foundation/05+04_terminal_core/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [x] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-architecture-refactor-foundation/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G08.md` and `CODE_REVIEW-cloud-G08.md`를 작성하고 `complete.log`를 작성하지 않는다. +- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다. +- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다. + +## 계획 대비 변경 사항 + +- 없음 (모든 항목이 계획에 따라 수정 및 검증되었습니다.) + +## 주요 설계 결정 + +- **공통 idempotent close 판단 헬퍼 추가**: `isAlreadyClosedError(err error) bool` 헬퍼를 `cli.go`에 구현하여 PTY 기반의 `terminalSessionCore.Close()`와 pipe 기반의 `closeProfileSession` 모두에서 중복 없이 "file already closed" 및 `os.ErrClosed` 에러를 동일하게 감지하고 nil로 흡수하도록 처리했습니다. +- **테스트 커버리지 보강**: `cli_internal_test.go`에 `TestCloseProfileSession_PipeFallbackIdempotent`를 추가해 pipe session close idempotence 회귀 테스트를 보강했습니다. +- **User-Flow 진단 수행**: 임시 configurations를 생성해 `scripts/dev/edge.sh` 및 `scripts/dev/node.sh`를 구동하는 로컬 진단을 수행하고, edge console 메시지 2회 왕복 및 5대 command 결과를 온전히 캡처 및 기록했습니다. + +## 사용자 리뷰 요청 + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- `closeProfileSession`과 `terminalSessionCore.Close()`의 already-closed 처리가 중복 없이 pipe/PTY 모두에 적용되는지 확인한다. +- `./scripts/e2e-smoke.sh` 출력에서 `[Fx] ERROR Failed to stop cleanly` 또는 `file already closed` stop error가 사라졌는지 확인한다. +- repo 내부 `scripts/dev/edge.sh` + `scripts/dev/node.sh` user-flow 진단이 보조 smoke와 구분되어 기록되었는지 확인한다. +- Roadmap Targets는 유지하되 code-review가 직접 roadmap을 수정하지 않는지 확인한다. + +## 검증 결과 + +### REVIEW_REFACTOR-1 중간 검증 +```bash +$ go test -count=1 ./apps/node/internal/adapters/cli +ok iop/apps/node/internal/adapters/cli 42.965s +``` + +### REVIEW_REFACTOR-2 중간 검증 +```bash +$ ./scripts/e2e-smoke.sh +[edge] sent run_id=manual-... +[node0-evt] start run_id=manual-... +[node0-msg] IOP_E2E_PING_BASIC +[node0-msg] IOP_E2E_PING_BASIC_TAIL +[node0-evt] complete run_id=manual-... detail="idle-timeout" +... +[Fx] HOOK OnStop iop/apps/node/internal/bootstrap.Module.func3.2() called by iop/apps/node/internal/bootstrap.Module.func3 ran successfully in 127.833µs +=================== +[e2e] Auxiliary smoke test PASSED. +``` + +### 최종 검증 +```bash +$ go test -count=1 ./apps/node/internal/adapters/cli +ok iop/apps/node/internal/adapters/cli 42.470s + +$ go test -count=1 ./apps/node/... +ok iop/apps/node/cmd/node 0.010s +ok iop/apps/node/internal/adapters 0.006s +ok iop/apps/node/internal/adapters/cli 42.470s +? iop/apps/node/internal/adapters/cli/internal/testutil [no test files] +ok iop/apps/node/internal/adapters/cli/status 39.743s +? iop/apps/node/internal/adapters/mock [no test files] +ok iop/apps/node/internal/adapters/ollama 0.010s +ok iop/apps/node/internal/adapters/vllm 0.009s +ok iop/apps/node/internal/bootstrap 0.162s +ok iop/apps/node/internal/node 0.012s +ok iop/apps/node/internal/router 0.004s +? iop/apps/node/internal/runtime [no test files] +ok iop/apps/node/internal/store 0.041s +ok iop/apps/node/internal/transport 5.031s + +$ ./scripts/e2e-smoke.sh +=== EDGE OUTPUT === +edge> [node0-evt] connected reason="registered" +... +=== NODE OUTPUT === +[Fx] HOOK OnStop iop/apps/node/internal/bootstrap.Module.func3.2() called by iop/apps/node/internal/bootstrap.Module.func3 ran successfully in 123.417µs +=================== +[e2e] Auxiliary smoke test PASSED. + +$ git diff --check +(No output, clean check) + +$ scripts/dev/edge.sh + scripts/dev/node.sh repo internal diagnostic +=== EDGE OUTPUT === +[edge] config=/config/workspace/iop/tmp-diagnostic/edge.yaml +IOP Edge console listening on 127.0.0.1:30090 +Console target node= adapter=cli target=fake-cli session=default background=false +edge> [node0-evt] connected reason="registered" +edge> IOP_E2E_STATUS_OK +[edge] sent run_id=manual-1780724104342378962 node=node0 adapter=cli target=fake-cli session=default background=false +[node0-evt] start run_id=manual-1780724104342378962 +[node0-msg] IOP_E2E_STATUS_OK +[node0-msg] IOP_E2E_STATUS_OK_TAIL +[node0-evt] complete run_id=manual-1780724104342378962 detail="idle-timeout" +edge> +edge> IOP_E2E_ACK_SHORT +[edge] sent run_id=manual-1780724113048592966 node=node0 adapter=cli target=fake-cli session=default background=false +[node0-evt] start run_id=manual-1780724113048592966 +[node0-msg] IOP_E2E_ACK_SHORT +[node0-msg] IOP_E2E_ACK_SHORT_TAIL +[node0-evt] complete run_id=manual-1780724113048592966 detail="idle-timeout" +edge> +edge> /capabilities +[node0-capabilities] target=fake-cli session=default + adapter = cli + max_concurrency = 4 + targets = fake-cli +edge> +edge> /transport +[node0-transport] target=fake-cli session=default + adapter = cli + connected = true + node_id = test-node + session_id = default + state = connected + target = fake-cli +edge> +edge> /sessions +[node0-sessions] target=fake-cli session=default +sessions: 1 + [0] mode=persistent target=fake-cli session=default +edge> +edge> /terminate-session +terminated session default node=node0 +edge> +edge> /exit +bye + +=== NODE OUTPUT === +{"level":"info","ts":1780724096.1076572,"caller":"transport/client.go:67","msg":"registered with edge","node_id":"test-node","alias":"test-node"} +{"level":"info","ts":1780724096.1238368,"caller":"cli/cli.go:218","msg":"cli adapter: persistent session started","target":"fake-cli"} +[Fx] HOOK OnStart iop/apps/node/internal/bootstrap.Module.func3.1() called by iop/apps/node/internal/bootstrap.Module.func3 ran successfully in 21.987708ms +[Fx] RUNNING +{ "level": "info", "ts": 1780724104.3430233, "caller": "node/node.go:61", "msg": "run request received", "run_id": "manual-1780724104342378962", "adapter": "cli", "target": "fake-cli" } +[edge-message] IOP_E2E_STATUS_OK +[node-event] start run_id=manual-1780724104342378962 +[node-message] IOP_E2E_STATUS_OK +IOP_E2E_STATUS_OK_TAIL +[node-event] complete run_id=manual-1780724104342378962 detail="idle-timeout" +{ "level": "info", "ts": 1780724113.048852, "caller": "node/node.go:61", "msg": "run request received", "run_id": "manual-1780724113048592966", "adapter": "cli", "target": "fake-cli" } +[edge-message] IOP_E2E_ACK_SHORT +[node-event] start run_id=manual-1780724113048592966 +[node-message] IOP_E2E_ACK_SHORT +IOP_E2E_ACK_SHORT_TAIL +[node-event] complete run_id=manual-1780724113048592966 detail="idle-timeout" +{ "level": "info", "ts": 1780724122.1063466, "caller": "node/node.go:179", "msg": "command request", "request_id": "caps-1780724122106033179", "type": "NODE_COMMAND_TYPE_CAPABILITIES", "adapter": "cli", "target": "fake-cli" } +{ "level": "info", "ts": 1780724129.8019564, "caller": "node/node.go:179", "msg": "command request", "request_id": "transport-1780724129801754669", "type": "NODE_COMMAND_TYPE_TRANSPORT_STATUS", "adapter": "cli", "target": "fake-cli" } +{ "level": "info", "ts": 1780724137.9463983, "caller": "node/node.go:179", "msg": "command request", "request_id": "sessions-1780724137946077714", "type": "NODE_COMMAND_TYPE_SESSION_LIST", "adapter": "cli", "target": "fake-cli" } +{ "level": "info", "ts": 1780724145.2814353, "caller": "node/node.go:158", "msg": "cancel request", "run_id": "", "action": "CANCEL_ACTION_TERMINATE_SESSION" } +{ "level": "info", "ts": 1780724153.3059065, "caller": "transport/session.go:89", "msg": "disconnected from edge", "transport_close_reason": "remote_closed", "transport_close_error": "EOF" } +[edge-event] disconnected reason="transport_closed" transport_close_reason="remote_closed" transport_close_error="EOF" +``` +``` + +> **[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. + +Sections and their ownership: + +| Section | Owner | Note | +|---------|-------|------| +| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these (archive, complete.log, and task-directory archive move are review-agent only) | +| Roadmap Targets | Fixed at stub creation from plan when present | Implementing agent must not modify; code-review copies it into `complete.log` as `Roadmap Completion` only on PASS | +| 구현 항목별 완료 여부 (item names) | Fixed at stub creation | Implementing agent checks `[ ]` to `[x]` only | +| 구현 체크리스트 (item text/order) | Fixed at stub creation from plan | Implementing agent checks `[ ]` to `[x]` only; final checkbox is mandatory before saving | +| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify | +| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content | +| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless user input is required to proceed; do not ask the user directly during implementation; when filled, include exact decision, evidence, commands/output, why automatic follow-up cannot resolve it, and resume condition | +| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Pre-filled from plan | +| 검증 결과 (section headings + commands) | Fixed at stub creation | Implementing agent fills in command output only; command changes require a `계획 대비 변경 사항` entry | +| 코드리뷰 결과 | Review agent appends | Not included in stub | + +## 코드리뷰 결과 + +- 종합 판정: PASS +- 차원별 평가: + - correctness: Pass + - completeness: Pass + - test coverage: Pass + - API contract: Pass + - code quality: Pass + - plan deviation: Pass + - verification trust: Pass +- 발견된 문제: 없음 +- 검증 재실행: + - `go test -count=1 ./apps/node/internal/adapters/cli` - PASS (`ok iop/apps/node/internal/adapters/cli 42.414s`) + - `go test -count=1 ./apps/node/...` - PASS + - `./scripts/e2e-smoke.sh` - PASS, node OnStop clean shutdown 확인 + - `git diff --check` - PASS + - `scripts/dev/edge.sh` + `scripts/dev/node.sh` reviewer diagnostic - PASS, `/nodes`, message x2, `/capabilities`, `/transport`, `/sessions`, `/terminate-session` 확인 +- 다음 단계: PASS complete.log를 작성하고 active task directory를 archive로 이동한다. diff --git a/agent-task/archive/2026/06/m-architecture-refactor-foundation/05+04_terminal_core/complete.log b/agent-task/archive/2026/06/m-architecture-refactor-foundation/05+04_terminal_core/complete.log new file mode 100644 index 0000000..def4024 --- /dev/null +++ b/agent-task/archive/2026/06/m-architecture-refactor-foundation/05+04_terminal_core/complete.log @@ -0,0 +1,45 @@ +# Complete - m-architecture-refactor-foundation/05+04_terminal_core + +## 완료 일시 + +2026-06-06 + +## 요약 + +Terminal session core split follow-up completed after 2 review loops; final verdict PASS. + +## 루프 이력 + +| Plan | Review | Verdict | 메모 | +|------|--------|---------|------| +| `plan_cloud_G08_0.log` | `code_review_cloud_G08_0.log` | FAIL | pipe fallback close idempotence and repo-internal user-flow verification evidence were incomplete. | +| `plan_cloud_G08_1.log` | `code_review_cloud_G08_1.log` | PASS | close idempotence was shared across PTY core and pipe fallback, and verification evidence was recovered. | + +## 구현/정리 내용 + +- `closeProfileSession` now treats already-closed pipe/file errors as idempotent close results, matching `terminalSessionCore.Close()`. +- Added pipe fallback close idempotence regression coverage while keeping terminal core close/snapshot/write tests passing. +- Recovered verification evidence for `go test`, auxiliary smoke, `git diff --check`, and repo-internal `scripts/dev/edge.sh` + `scripts/dev/node.sh` user-flow diagnostics. + +## 최종 검증 + +- `go test -count=1 ./apps/node/internal/adapters/cli` - PASS; `ok iop/apps/node/internal/adapters/cli 42.414s`. +- `go test -count=1 ./apps/node/...` - PASS; all node packages passed, including `apps/node/internal/adapters/cli`, `status`, `bootstrap`, `node`, `router`, `store`, and `transport`. +- `./scripts/e2e-smoke.sh` - PASS; auxiliary smoke passed and node OnStop ran successfully without `file already closed` stop errors. +- `git diff --check` - PASS; no output. +- `scripts/dev/edge.sh` + `scripts/dev/node.sh` reviewer diagnostic - PASS; temporary deterministic `fake-cli` profile verified `/nodes`, same-session message x2, `/capabilities`, `/transport`, `/sessions`, and `/terminate-session`. + +## Roadmap Completion + +- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/architecture-refactor-foundation.md` +- Completed task ids: + - `terminal-core`: PASS; evidence=`agent-task/archive/2026/06/m-architecture-refactor-foundation/05+04_terminal_core/plan_cloud_G08_1.log`, `agent-task/archive/2026/06/m-architecture-refactor-foundation/05+04_terminal_core/code_review_cloud_G08_1.log`; verification=`go test -count=1 ./apps/node/internal/adapters/cli`, `go test -count=1 ./apps/node/...`, `./scripts/e2e-smoke.sh`, `git diff --check`, `scripts/dev/edge.sh + scripts/dev/node.sh reviewer diagnostic` +- Not completed task ids: 없음 + +## 잔여 Nit + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/archive/2026/06/m-architecture-refactor-foundation/05+04_terminal_core/plan_cloud_G08_0.log b/agent-task/archive/2026/06/m-architecture-refactor-foundation/05+04_terminal_core/plan_cloud_G08_0.log new file mode 100644 index 0000000..2825a68 --- /dev/null +++ b/agent-task/archive/2026/06/m-architecture-refactor-foundation/05+04_terminal_core/plan_cloud_G08_0.log @@ -0,0 +1,268 @@ + + +# Plan - REFACTOR Terminal Session Core + +## 이 파일을 읽는 구현 에이전트에게 + +구현 완료의 마지막 단계는 active `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우는 것이다. 검증을 실행하고, 실제 stdout/stderr를 기록하고, active 파일을 그대로 둔 채 리뷰 준비를 보고한다. 최종 판정, log rename, `complete.log`, archive 이동은 code-review-skill 전용이다. + +이 계획은 `04_cli_executor_split` 완료 후 구현한다. 구현 중 사용자만 결정할 수 있는 선택, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 충돌이 있으면 active review stub의 `사용자 리뷰 요청` 섹션에 정확한 증거를 기록하고 멈춘다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. + +## 배경 + +현재 persistent CLI 실행은 process start, PTY/pipe setup, prompt write, output drain, screen rendering, cleanup이 `persistent.go`에 집중되어 있다. terminal bridge 후속 작업을 위해 persistent PTY/screen/input/signal/close 책임을 작은 terminal session core로 떼어낼 필요가 있다. 이 작업은 remote bridge 프로토콜을 만들지 않고, Node adapter 내부 core 경계만 만든다. + +## 사용자 리뷰 요청 흐름 + +구현 중 차단은 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 이 섹션은 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 양식을 복사한 것이다. 구현 중 direct user prompt는 금지되며, code-review가 요청 정당성을 검증하고 실제 `USER_REVIEW.md` 작성 여부를 결정한다. + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/architecture-refactor-foundation.md` +- Task ids: + - `terminal-core`: persistent PTY/screen/rendering/resize/input/signal/close terminal session core split +- Completion mode: check-on-pass + +## 분석 결과 + +### 읽은 파일 + +- `AGENTS.md` +- `agent-ops/rules/project/rules.md` +- `agent-ops/rules/common/rules-roadmap.md` +- `agent-ops/skills/common/router.md` +- `agent-ops/skills/common/plan/SKILL.md` +- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` +- `agent-roadmap/current.md` +- `agent-roadmap/phase/automation-runtime-bridge/PHASE.md` +- `agent-roadmap/phase/automation-runtime-bridge/milestones/architecture-refactor-foundation.md` +- `agent-ops/rules/project/domain/node/rules.md` +- `agent-test/local/rules.md` +- `agent-ops/rules/project/domain/testing/rules.md` +- `agent-test/local/node-smoke.md` +- `apps/node/internal/adapters/cli/cli.go` +- `apps/node/internal/adapters/cli/persistent.go` +- `apps/node/internal/adapters/cli/persistent_output_filter.go` +- `apps/node/internal/adapters/cli/oneshot.go` +- `apps/node/internal/adapters/cli/codex_exec.go` +- `apps/node/internal/adapters/cli/antigravity_print.go` +- `apps/node/internal/adapters/cli/opencode_sse.go` +- `apps/node/internal/adapters/cli/cli_internal_test.go` +- `apps/node/internal/adapters/cli/persistent_execute_blackbox_test.go` +- `apps/node/internal/adapters/cli/lifecycle_blackbox_test.go` +- `apps/node/internal/adapters/cli/persistent_output_filter_test.go` +- `apps/node/internal/adapters/cli/status/status_test.go` +- `apps/node/internal/runtime/types.go` +- `apps/node/internal/node/node.go` +- `apps/node/internal/node/node_test.go` +- `apps/node/internal/bootstrap/module.go` +- `apps/node/internal/bootstrap/module_test.go` +- `scripts/e2e-smoke.sh` + +### 테스트 환경 규칙 + +- `test_env=local`. +- `agent-test/local/rules.md`를 읽었고, Node 변경은 `node-smoke` profile을 매칭한다. +- `agent-test/local/node-smoke.md`를 읽었다. 적용 명령은 `go test -count=1 ./apps/node/...`, `./scripts/e2e-smoke.sh`이다. +- PTY/TUI behavior는 local smoke만으로 완전 검증되지 않는다. 구현 에이전트는 `go test -count=1 ./apps/node/internal/adapters/cli`에 PTY core 단위/blackbox coverage를 추가하고, 수동 `scripts/dev/edge.sh` + `scripts/dev/node.sh` full-cycle 미실행 여부를 review stub에 기록한다. + +### 테스트 커버리지 공백 + +- terminal PTY startup: 기존 persistent blackbox가 실행 성공을 덮지만 core-level PTY start 옵션과 close semantics를 직접 검증하지 않는다. +- screen rendering/tail buffer: `persistent_output_filter_test.go`와 status tests는 일부 rendering/filter를 덮지만 terminal session core의 snapshot API는 새 tests가 필요하다. +- resize/input/signal/close: 현재 외부 bridge command가 없으므로 core interface와 local close/signal behavior를 focused tests로 덮고, remote bridge rollout은 제외한다. + +### 심볼 참조 + +- 계획 단계에서 제거/rename 확정 symbol은 없음. +- 구현 중 `profileSession`, `startProfileSession`, `executePersistent`, `writePrompt`, `drainUntilIdle`, `drainSessionUntilIdle`, `closeSession` call site를 `rg --sort path`로 재확인한다. + +### 분할 판단 + +- split decision policy를 계획 파일 선택 전에 평가했다. +- shared task group: `agent-task/m-architecture-refactor-foundation/`. +- sibling plans: + - `04_cli_executor_split`: predecessor 없음. active plan 작성됨. + - `05+04_terminal_core`: predecessor index `04`에 의존한다. +- predecessor `04`: active candidate `agent-task/m-architecture-refactor-foundation/04_cli_executor_split/complete.log`는 현재 없음. archive candidate는 읽지 않았다. 이 계획의 구현은 `04_cli_executor_split` PASS와 `complete.log` 생성 후 시작한다. + +### 범위 결정 근거 + +- 이 계획은 `apps/node/internal/adapters/cli` 내부 terminal session core 분리로 제한한다. +- Edge console의 resize/input/signal command surface, proto schema, remote terminal bridge transport는 제외한다. +- mode executor/session map split은 `04_cli_executor_split` 범위다. 이 계획은 그 결과 위에 terminal core를 얹는다. +- vLLM streaming, router policy, OpenAI/Edge adapter refactor는 제외한다. + +### 빌드 등급 + +- `cloud-G08`: PTY/TUI/screen repaint/process lifecycle behavior가 중심이고 deterministic evidence가 약한 고위험 refactor다. + +## 의존 관계 및 구현 순서 + +- `05+04_terminal_core`는 directory name 기준으로 predecessor `04`만 가진다. +- 구현 전 `agent-task/m-architecture-refactor-foundation/04_cli_executor_split/complete.log` 또는 같은 task group archive의 `04_*`/`04+*` `complete.log`가 있어야 한다. +- predecessor가 없으면 구현하지 말고 review stub `사용자 리뷰 요청`이 아니라 작업 대기 상태로 보고한다. + +## 구현 체크리스트 + +- [ ] `04_cli_executor_split` 완료 증거(`complete.log`)를 확인한 뒤 구현을 시작한다. +- [ ] persistent PTY session core를 추가하고 process start/read/write/snapshot/close 책임을 `persistent.go`에서 분리한다. +- [ ] screen rendering/tail buffer와 prompt input 경계를 core API로 정리하되 외부 bridge protocol은 만들지 않는다. +- [ ] resize/input/signal/close 메서드 또는 내부 hook을 정의하고 현재 CLI 경로에서 사용하는 close/signal behavior를 보존한다. +- [ ] terminal core focused tests와 기존 persistent/lifecycle tests를 통과시킨다. +- [ ] `go test -count=1 ./apps/node/internal/adapters/cli`, `go test -count=1 ./apps/node/...`, `./scripts/e2e-smoke.sh` 최종 검증을 통과시키고 수동 full-cycle 미실행 여부를 기록한다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +### [REFACTOR-1] Extract Persistent Terminal Core + +#### 문제 + +`apps/node/internal/adapters/cli/persistent.go:316`-`432`의 `startProfileSession`이 command start, PTY/pipe setup, reader setup, session struct construction, startup drain까지 동시에 처리한다. + +```go +// apps/node/internal/adapters/cli/persistent.go:316 +func (c *CLI) startProfileSession(ctx context.Context, cfg Config, key sessionKey) (*profileSession, error) { + // ... + if terminalMode { + ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: 40, Cols: 120}) + // ... + } else { + stdin, err := cmd.StdinPipe() + // ... + } + // ... +} +``` + +#### 해결 방법 + +terminal mode path를 `terminalSessionCore`로 분리한다. pipe fallback은 현행 구조를 유지하거나 별도 `pipeSessionCore`로 감싸되, remote terminal semantics는 만들지 않는다. + +```go +type terminalSessionCore struct { + cmd *exec.Cmd + input io.WriteCloser + output io.ReadCloser + tail *status.TailBuffer +} + +func startTerminalSessionCore(ctx context.Context, opts terminalSessionOptions) (*terminalSessionCore, error) +func (s *terminalSessionCore) WritePrompt(ctx context.Context, prompt string) error +func (s *terminalSessionCore) Snapshot() status.Snapshot +func (s *terminalSessionCore) Close() error +``` + +`profileSession`은 core를 들고 기존 field 접근이 필요한 부분만 adapter shim으로 유지한다. + +#### 수정 파일 및 체크리스트 + +- [ ] `apps/node/internal/adapters/cli/persistent.go`: terminal start/read/write/close logic을 core 호출로 축소. +- [ ] `apps/node/internal/adapters/cli/terminal_session.go`: terminal core type과 start/write/snapshot/close 구현. +- [ ] `apps/node/internal/adapters/cli/persistent_execute_blackbox_test.go`: persistent 실행 회귀 유지. +- [ ] `apps/node/internal/adapters/cli/lifecycle_blackbox_test.go`: close/stop behavior 회귀 유지. + +#### 테스트 작성 + +- `apps/node/internal/adapters/cli/terminal_session_test.go`를 추가한다. +- test names: `TestTerminalSessionCoreWritesPrompt`, `TestTerminalSessionCoreSnapshot`, `TestTerminalSessionCoreCloseIsIdempotent`. +- fixture는 기존 fake shell/testutil command를 우선 재사용한다. 새 외부 dependency는 추가하지 않는다. + +#### 중간 검증 + +```bash +go test -count=1 ./apps/node/internal/adapters/cli +``` + +### [REFACTOR-2] Preserve Persistent Execute Contract + +#### 문제 + +`apps/node/internal/adapters/cli/persistent.go:66`-`230`의 `executePersistent`는 session resolve, prompt write, drain, cancellation, completion emission을 모두 수행한다. core 분리 중 completion/cancel semantics가 바뀌면 edge console behavior가 흔들린다. + +```go +// apps/node/internal/adapters/cli/persistent.go:66 +func (c *CLI) executePersistent(ctx context.Context, cfg Config, req runtime.Request, emit runtime.Emitter) error { + // writes prompt, drains output, emits start/message/complete +} +``` + +#### 해결 방법 + +`executePersistent`의 orchestration은 유지하고, IO primitive만 terminal core로 위임한다. `writePrompt` (`persistent.go:273`-`292`), `drainSessionUntilIdle` (`persistent.go:555`-`576`), `emitPersistentExit` (`persistent.go:587`-`604`)의 externally visible event order는 유지한다. + +#### 수정 파일 및 체크리스트 + +- [ ] `apps/node/internal/adapters/cli/persistent.go`: event order와 cancel reason 유지. +- [ ] `apps/node/internal/adapters/cli/persistent_output_filter.go`: 필요 시 core snapshot input에 맞게만 조정. +- [ ] `apps/node/internal/adapters/cli/persistent_execute_blackbox_test.go`: event sequence regression 유지/추가. + +#### 테스트 작성 + +- 기존 persistent blackbox tests를 유지한다. +- 필요 시 `TestPersistentExecuteKeepsCompletionAfterCoreSplit`를 추가해 start/message/complete 순서를 확인한다. + +#### 중간 검증 + +```bash +go test -count=1 ./apps/node/internal/adapters/cli +``` + +### [REFACTOR-3] Add Resize/Input/Signal/Close Core Hooks + +#### 문제 + +마일스톤 task는 terminal session core의 resize/input/signal/close 기반 분리를 요구한다. 현재 user-facing resize/input/signal command surface가 없으므로 외부 API를 추가하지 않고도 core 내부 hook이 먼저 있어야 한다. + +#### 해결 방법 + +core에 내부 메서드를 정의한다. + +```go +func (s *terminalSessionCore) Resize(rows, cols uint16) error +func (s *terminalSessionCore) WriteInput(ctx context.Context, data []byte) error +func (s *terminalSessionCore) Signal(sig os.Signal) error +func (s *terminalSessionCore) Close() error +``` + +현재 CLI 경로는 `WritePrompt`와 `Close`를 사용한다. `Resize`/`Signal`은 tests로 boundary를 고정하고, Edge/proto command는 후속 terminal bridge task로 남긴다. + +#### 수정 파일 및 체크리스트 + +- [ ] `apps/node/internal/adapters/cli/terminal_session.go`: hook method 추가. +- [ ] `apps/node/internal/adapters/cli/terminal_session_test.go`: invalid size, closed session write/signal boundary 검증. +- [ ] `apps/node/internal/adapters/cli/lifecycle_blackbox_test.go`: adapter stop 시 core close 호출 검증. + +#### 테스트 작성 + +- `TestTerminalSessionCoreResizeValidatesBounds`, `TestTerminalSessionCoreRejectsWriteAfterClose`, `TestTerminalSessionCoreSignalAfterClose`를 추가한다. +- PTY platform 차이가 있으면 testutil fake core를 사용하되 실제 PTY start path 하나는 유지한다. + +#### 중간 검증 + +```bash +go test -count=1 ./apps/node/internal/adapters/cli +``` + +## 수정 파일 요약 + +| 파일 | 항목 | +|------|------| +| `apps/node/internal/adapters/cli/persistent.go` | REFACTOR-1, REFACTOR-2 | +| `apps/node/internal/adapters/cli/terminal_session.go` | REFACTOR-1, REFACTOR-3 | +| `apps/node/internal/adapters/cli/terminal_session_test.go` | REFACTOR-1, REFACTOR-3 | +| `apps/node/internal/adapters/cli/persistent_execute_blackbox_test.go` | REFACTOR-1, REFACTOR-2 | +| `apps/node/internal/adapters/cli/lifecycle_blackbox_test.go` | REFACTOR-1, REFACTOR-3 | +| `apps/node/internal/adapters/cli/persistent_output_filter.go` | REFACTOR-2 | + +## 최종 검증 + +```bash +go test -count=1 ./apps/node/internal/adapters/cli +go test -count=1 ./apps/node/... +./scripts/e2e-smoke.sh +git diff --check +``` + +예상 결과: 모든 명령 exit code 0. `./scripts/e2e-smoke.sh`의 안내 문구에 따라 수동 `scripts/dev/edge.sh` + `scripts/dev/node.sh` full-cycle을 실행하지 못한 경우 review stub에 미실행 리스크를 기록한다. + +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/archive/2026/06/m-architecture-refactor-foundation/05+04_terminal_core/plan_cloud_G08_1.log b/agent-task/archive/2026/06/m-architecture-refactor-foundation/05+04_terminal_core/plan_cloud_G08_1.log new file mode 100644 index 0000000..7d87dce --- /dev/null +++ b/agent-task/archive/2026/06/m-architecture-refactor-foundation/05+04_terminal_core/plan_cloud_G08_1.log @@ -0,0 +1,94 @@ + + +# Plan - REVIEW_REFACTOR Terminal Session Close Follow-up + +## 이 파일을 읽는 구현 에이전트에게 + +구현 완료의 마지막 단계는 active `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우는 것이다. 검증을 실행하고, 실제 stdout/stderr를 기록하고, active 파일을 그대로 둔 채 리뷰 준비를 보고한다. 최종 판정, log rename, `complete.log`, archive 이동은 code-review-skill 전용이다. + +구현 중 사용자만 결정할 수 있는 선택, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 충돌이 있으면 active review stub의 `사용자 리뷰 요청` 섹션에 정확한 증거를 기록하고 멈춘다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다. + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/architecture-refactor-foundation.md` +- Task ids: + - `terminal-core`: persistent PTY/screen/rendering/resize/input/signal/close terminal session core split +- Completion mode: check-on-pass + +## 이전 리뷰 요약 + +- `code_review_cloud_G08_0.log` 판정: FAIL. +- Required 1: `./scripts/e2e-smoke.sh` 재실행에서 node OnStop이 `adapter "cli" stop: cli adapter: close session "fake-cli"/"default": close |1: file already closed`로 실패했다. 스크립트 exit code는 0이지만 clean shutdown 증거가 깨졌고, 구현 기록의 OnStop 성공 출력과 불일치한다. +- Required 2: 보조 smoke만 기록되어 있고 repo 내부 `scripts/dev/edge.sh` + `scripts/dev/node.sh` user-flow/full-cycle 진단 결과 또는 명확한 미실행 사유/남은 위험이 없다. + +## 범위 결정 근거 + +- 범위는 CLI persistent session close idempotence와 검증 증거 회복으로 제한한다. +- Edge/proto command surface, remote terminal bridge transport, adapter executor 구조 재설계는 하지 않는다. +- `terminalSessionCore.Close()`에만 있는 already-closed 무시 로직이 pipe fallback session에도 적용되도록 shared close boundary를 정리한다. + +## 구현 체크리스트 + +- [ ] `closeProfileSession` 또는 shared close helper가 PTY core와 pipe fallback session 모두에서 already-closed close 오류를 idempotent close로 처리한다. +- [ ] pipe fallback `Stop`/`TerminateSession` close idempotence 회귀 테스트를 추가하거나 보강하고, terminal core close tests도 계속 통과시킨다. +- [ ] `go test -count=1 ./apps/node/internal/adapters/cli`, `go test -count=1 ./apps/node/...`, `./scripts/e2e-smoke.sh`, `git diff --check`를 재실행하고 실제 stdout/stderr를 기록한다. smoke 출력에는 node OnStop stop error가 없어야 한다. +- [ ] repo 내부 `scripts/dev/edge.sh` + `scripts/dev/node.sh` user-flow 진단을 deterministic CLI profile로 실행해 같은 session 메시지 2회, `/capabilities`, `/transport`, `/sessions`, `/terminate-session` 결과를 기록한다. 실행할 수 없으면 `사용자 리뷰 요청`에 정확한 차단 근거와 남은 위험을 기록한다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +### [REVIEW_REFACTOR-1] Make Persistent Session Close Idempotent + +#### 문제 + +`apps/node/internal/adapters/cli/cli.go:426`의 `closeProfileSession`은 `closeFn()` 오류를 그대로 반환한다. 리뷰 재실행에서 non-terminal persistent fake CLI session stop이 이미 닫힌 pipe 오류를 반환했고, `persistentExecutor.Stop()`이 이를 adapter stop 실패로 전파했다. + +#### 해결 방법 + +- 이미 닫힌 file/pipe close 오류를 idempotent close로 취급하는 helper를 shared path에 둔다. +- `terminalSessionCore.Close()`와 `closeProfileSession`이 같은 판단을 쓰도록 중복 문자열 비교를 줄인다. +- `closeProfileSession`은 close idempotence를 처리하되 process kill 자체의 기존 best-effort semantics는 유지한다. + +#### 수정 파일 및 체크리스트 + +- [ ] `apps/node/internal/adapters/cli/cli.go`: `closeProfileSession`에서 already-closed close 오류를 nil로 취급. +- [ ] `apps/node/internal/adapters/cli/terminal_session.go`: terminal core close의 duplicate already-closed 판정이 있으면 shared helper로 정리. +- [ ] `apps/node/internal/adapters/cli/lifecycle_blackbox_test.go` 또는 `apps/node/internal/adapters/cli/cli_internal_test.go`: pipe fallback close idempotence 회귀 테스트 추가. + +#### 중간 검증 + +```bash +go test -count=1 ./apps/node/internal/adapters/cli +``` + +### [REVIEW_REFACTOR-2] Recover User-Flow Verification Evidence + +#### 문제 + +현재 review stub의 최종 검증은 보조 `./scripts/e2e-smoke.sh` 출력만 포함한다. 테스트 도메인 규칙상 사용자 실행 파이프라인 변경은 repo 내부 edge-node user-flow/full-cycle 진단 결과까지 필요하며, 보조 smoke 통과만으로 완료 처리할 수 없다. + +#### 해결 방법 + +- close fix 후 보조 smoke를 재실행해 clean OnStop을 확인한다. +- `agent-ops/skills/project/e2e-smoke/SKILL.md` 기준으로 `scripts/dev/edge.sh`와 `scripts/dev/node.sh`를 각각 실행한 repo 내부 진단을 수행한다. +- edge 화면의 메시지 2회 왕복, node local payload와 edge rendered payload 일치, `/capabilities`, `/transport`, `/sessions`, `/terminate-session` 결과를 review stub에 기록한다. + +#### 수정 파일 및 체크리스트 + +- [ ] `agent-task/m-architecture-refactor-foundation/05+04_terminal_core/CODE_REVIEW-cloud-G08.md`: 검증 결과에 실제 stdout/stderr와 user-flow 진단 요약 기록. +- [ ] 소스 변경이 추가로 필요하면 `apps/node/internal/adapters/cli/**` 범위로 제한. + +#### 중간 검증 + +```bash +./scripts/e2e-smoke.sh +``` + +## 최종 검증 + +```bash +go test -count=1 ./apps/node/internal/adapters/cli +go test -count=1 ./apps/node/... +./scripts/e2e-smoke.sh +git diff --check +``` + +예상 결과: 모든 명령 exit code 0이고, `./scripts/e2e-smoke.sh` 출력에서 node OnStop stop error가 없어야 한다. 추가로 repo 내부 `scripts/dev/edge.sh` + `scripts/dev/node.sh` user-flow 진단 결과를 review stub에 기록한다. diff --git a/apps/node/internal/adapters/adapters_blackbox_test.go b/apps/node/internal/adapters/adapters_blackbox_test.go index 00640ef..5640cce 100644 --- a/apps/node/internal/adapters/adapters_blackbox_test.go +++ b/apps/node/internal/adapters/adapters_blackbox_test.go @@ -3,6 +3,7 @@ package adapters_test import ( "context" "fmt" + "strings" "testing" "go.uber.org/zap" @@ -14,16 +15,30 @@ import ( // --- BuildFromPayload tests --- -func TestBuildFromPayload_MockAlwaysPresent(t *testing.T) { +func TestBuildFromPayload_EmptyPayloadRegistersNoAdapters(t *testing.T) { reg, err := adapters.BuildFromPayload(&iop.NodeConfigPayload{}, zap.NewNop()) if err != nil { t.Fatalf("build from payload: %v", err) } - if _, ok := reg.Get("mock"); !ok { - t.Fatal("expected mock adapter to be registered") + if got := len(reg.All()); got != 0 { + t.Fatalf("expected no adapters, got %d", got) } - if got := len(reg.All()); got != 1 { - t.Fatalf("expected 1 adapter, got %d", got) + if _, ok := reg.Get("mock"); ok { + t.Fatal("mock adapter must not be registered implicitly") + } +} + +func TestBuildFromPayload_ExplicitMockEnabled(t *testing.T) { + reg, err := adapters.BuildFromPayload(&iop.NodeConfigPayload{ + Adapters: []*iop.AdapterConfig{ + {Type: "mock", Enabled: true}, + }, + }, zap.NewNop()) + if err != nil { + t.Fatalf("build from payload: %v", err) + } + if _, ok := reg.Get("mock"); !ok { + t.Fatal("expected explicit mock adapter to be registered") } } @@ -40,9 +55,6 @@ func TestBuildFromPayload_OllamaEnabled(t *testing.T) { if err != nil { t.Fatalf("build from payload: %v", err) } - if _, ok := reg.Get("mock"); !ok { - t.Fatal("expected mock adapter to be registered") - } if _, ok := reg.Get("ollama"); !ok { t.Fatal("expected ollama adapter to be registered") } @@ -52,7 +64,6 @@ func TestBuildFromPayload_MultipleAdapters(t *testing.T) { reg, err := adapters.BuildFromPayload(&iop.NodeConfigPayload{ Adapters: []*iop.AdapterConfig{ {Type: "ollama", Enabled: true, Config: &iop.AdapterConfig_Ollama{Ollama: &iop.OllamaAdapterConfig{BaseUrl: "x"}}}, - {Type: "vllm", Enabled: true, Config: &iop.AdapterConfig_Vllm{Vllm: &iop.VllmAdapterConfig{Endpoint: "y"}}}, {Type: "cli", Enabled: true, Config: &iop.AdapterConfig_Cli{Cli: &iop.CLIAdapterConfig{ Profiles: map[string]*iop.CLIProfileConfig{ "codex": {Command: "codex", Persistent: true, ResponseIdleTimeoutMs: 1500, StartupIdleTimeoutMs: 300}, @@ -63,13 +74,27 @@ func TestBuildFromPayload_MultipleAdapters(t *testing.T) { if err != nil { t.Fatalf("build from payload: %v", err) } - for _, name := range []string{"mock", "ollama", "vllm", "cli"} { + for _, name := range []string{"ollama", "cli"} { if _, ok := reg.Get(name); !ok { t.Fatalf("expected %s adapter to be registered", name) } } } +func TestBuildFromPayload_VllmEnabledRejected(t *testing.T) { + _, err := adapters.BuildFromPayload(&iop.NodeConfigPayload{ + Adapters: []*iop.AdapterConfig{ + {Type: "vllm", Enabled: true, Config: &iop.AdapterConfig_Vllm{Vllm: &iop.VllmAdapterConfig{Endpoint: "http://localhost:8000"}}}, + }, + }, zap.NewNop()) + if err == nil { + t.Fatal("expected vllm disabled error") + } + if !strings.Contains(err.Error(), "vllm adapter is experimental and disabled") { + t.Fatalf("expected vllm disabled error, got %v", err) + } +} + func TestBuildFromPayload_UnknownType(t *testing.T) { _, err := adapters.BuildFromPayload(&iop.NodeConfigPayload{ Adapters: []*iop.AdapterConfig{ @@ -220,7 +245,11 @@ func TestRegistryLifecycle_StopContinuesOnFailingAdapter(t *testing.T) { } func TestRegistryLifecycle_NonLifecycleAdapterSkipped(t *testing.T) { - reg, err := adapters.BuildFromPayload(&iop.NodeConfigPayload{}, zap.NewNop()) + reg, err := adapters.BuildFromPayload(&iop.NodeConfigPayload{ + Adapters: []*iop.AdapterConfig{ + {Type: "mock", Enabled: true}, + }, + }, zap.NewNop()) if err != nil { t.Fatalf("build: %v", err) } diff --git a/apps/node/internal/adapters/cli/antigravity_print.go b/apps/node/internal/adapters/cli/antigravity_print.go index 904c6fa..b963bca 100644 --- a/apps/node/internal/adapters/cli/antigravity_print.go +++ b/apps/node/internal/adapters/cli/antigravity_print.go @@ -19,12 +19,12 @@ var antigravityConversationPatterns = []*regexp.Regexp{ const uuidPattern = `[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}` -func (c *CLI) executeAntigravityPrint(ctx context.Context, spec runtime.ExecutionSpec, profile config.CLIProfileConf, sink runtime.EventSink) error { +func (e *antigravityExecutor) Execute(ctx context.Context, spec runtime.ExecutionSpec, profile config.CLIProfileConf, sink runtime.EventSink) error { if len(profile.ResumeArgs) == 0 { return fmt.Errorf("cli adapter: antigravity-print mode requires resume_args in profile %q", spec.Target) } - sess, err := c.resolveAntigravitySession(spec) + sess, err := e.resolveAntigravitySession(spec) if err != nil { return err } @@ -40,7 +40,7 @@ func (c *CLI) executeAntigravityPrint(ctx context.Context, spec runtime.Executio prompt := extractPrompt(spec.Input) args := antigravityPrintArgs(profile, sess.conversationID, logFile, prompt) - output, err := c.executeCommand(ctx, spec, profile, args, prompt, sink) + output, err := e.cli.executeCommand(ctx, spec, profile, args, prompt, sink) if err != nil { return err } @@ -58,24 +58,52 @@ func (c *CLI) executeAntigravityPrint(ctx context.Context, spec runtime.Executio return nil } -func (c *CLI) resolveAntigravitySession(spec runtime.ExecutionSpec) (*antigravitySession, error) { +func (e *antigravityExecutor) resolveAntigravitySession(spec runtime.ExecutionSpec) (*antigravitySession, error) { target := cliTargetName(spec) key := sessionKey{target: target, sessionID: normalizeSessionID(spec.SessionID)} - c.mu.Lock() - defer c.mu.Unlock() + e.mu.Lock() + defer e.mu.Unlock() - if sess, ok := c.agySessions[key]; ok { + if sess, ok := e.sessions[key]; ok { return sess, nil } if spec.SessionMode == runtime.SessionModeRequireExisting { return nil, fmt.Errorf("cli adapter: no antigravity conversation for target %q session %q", target, key.sessionID) } sess := &antigravitySession{key: key} - c.agySessions[key] = sess + e.sessions[key] = sess return sess, nil } +func (e *antigravityExecutor) Sessions() []sessionListEntry { + e.mu.Lock() + defer e.mu.Unlock() + snaps := make([]sessionListEntry, 0, len(e.sessions)) + for k := range e.sessions { + snaps = append(snaps, sessionListEntry{"antigravity-print", k.target, k.sessionID}) + } + return snaps +} + +func (e *antigravityExecutor) Terminate(ctx context.Context, target, sessionID string) (bool, error) { + key := sessionKey{target: target, sessionID: normalizeSessionID(sessionID)} + e.mu.Lock() + _, ok := e.sessions[key] + if ok { + delete(e.sessions, key) + } + e.mu.Unlock() + return ok, nil +} + +func (e *antigravityExecutor) Stop(ctx context.Context) error { + e.mu.Lock() + e.sessions = make(map[sessionKey]*antigravitySession) + e.mu.Unlock() + return nil +} + func antigravityPrintArgs(profile config.CLIProfileConf, conversationID, logFile, prompt string) []string { args := []string{"--log-file", logFile} if conversationID == "" { diff --git a/apps/node/internal/adapters/cli/cli.go b/apps/node/internal/adapters/cli/cli.go index 4f97842..179ed2d 100644 --- a/apps/node/internal/adapters/cli/cli.go +++ b/apps/node/internal/adapters/cli/cli.go @@ -11,6 +11,7 @@ import ( "errors" "fmt" "io" + "os" "os/exec" "sort" "strconv" @@ -57,6 +58,7 @@ type profileSession struct { tailMu sync.Mutex tail strings.Builder + core *terminalSessionCore } func (s *profileSession) appendTail(text string) { @@ -66,6 +68,9 @@ func (s *profileSession) appendTail(text string) { } func (s *profileSession) getTail() string { + if s.core != nil { + return s.core.Snapshot().Tail + } s.tailMu.Lock() defer s.tailMu.Unlock() return s.tail.String() @@ -84,27 +89,91 @@ type antigravitySession struct { } type CLI struct { - mu sync.Mutex - profiles map[string]config.CLIProfileConf - sessions map[sessionKey]*profileSession - codexSessions map[sessionKey]*codexExecSession - agySessions map[sessionKey]*antigravitySession - opencodeSessions map[sessionKey]*opencodeSSESession - logger *zap.Logger - - // StatusChecker overrides status.CheckUsage for testing. + mu sync.Mutex + profiles map[string]config.CLIProfileConf + logger *zap.Logger StatusChecker func(ctx context.Context, target string, profile config.CLIProfileConf) (*status.UsageStatus, error) + + oneShotExecutor *oneshotExecutor + persistentExecutor *persistentExecutor + codexExecutor *codexExecutor + antigravityExecutor *antigravityExecutor + opencodeExecutor *opencodeExecutor + + reporters []sessionReporter +} + +type executor interface { + Execute(ctx context.Context, spec runtime.ExecutionSpec, profile config.CLIProfileConf, sink runtime.EventSink) error +} + +type sessionReporter interface { + Sessions() []sessionListEntry + Terminate(ctx context.Context, target, sessionID string) (bool, error) + Stop(ctx context.Context) error +} + +type oneshotExecutor struct { + cli *CLI +} + +func (e *oneshotExecutor) Execute(ctx context.Context, spec runtime.ExecutionSpec, profile config.CLIProfileConf, sink runtime.EventSink) error { + return e.cli.executeOneShot(ctx, spec, profile, sink) +} + +type persistentExecutor struct { + cli *CLI + mu sync.Mutex + sessions map[sessionKey]*profileSession +} + +type codexExecutor struct { + cli *CLI + mu sync.Mutex + sessions map[sessionKey]*codexExecSession +} + +type antigravityExecutor struct { + cli *CLI + mu sync.Mutex + sessions map[sessionKey]*antigravitySession +} + +type opencodeExecutor struct { + cli *CLI + mu sync.Mutex + sessions map[sessionKey]*opencodeSSESession } func New(cfg config.CLIConf, logger *zap.Logger) *CLI { - return &CLI{ - profiles: cfg.Profiles, - sessions: make(map[sessionKey]*profileSession), - codexSessions: make(map[sessionKey]*codexExecSession), - agySessions: make(map[sessionKey]*antigravitySession), - opencodeSessions: make(map[sessionKey]*opencodeSSESession), - logger: logger, + c := &CLI{ + profiles: cfg.Profiles, + logger: logger, } + c.oneShotExecutor = &oneshotExecutor{cli: c} + c.persistentExecutor = &persistentExecutor{ + cli: c, + sessions: make(map[sessionKey]*profileSession), + } + c.codexExecutor = &codexExecutor{ + cli: c, + sessions: make(map[sessionKey]*codexExecSession), + } + c.antigravityExecutor = &antigravityExecutor{ + cli: c, + sessions: make(map[sessionKey]*antigravitySession), + } + c.opencodeExecutor = &opencodeExecutor{ + cli: c, + sessions: make(map[sessionKey]*opencodeSSESession), + } + c.reporters = []sessionReporter{ + c.persistentExecutor, + c.codexExecutor, + c.antigravityExecutor, + c.opencodeExecutor, + } + return c } func (c *CLI) Name() string { return Name } @@ -123,7 +192,7 @@ func (c *CLI) Capabilities(_ context.Context) (runtime.Capabilities, error) { } // Start starts the default session for each persistent profile in deterministic (sorted) order. -// On failure, already-started sessions are rolled back under c.mu. +// On failure, already-started sessions are rolled back. func (c *CLI) Start(ctx context.Context) error { names := make([]string, 0, len(c.profiles)) for name := range c.profiles { @@ -138,15 +207,14 @@ func (c *CLI) Start(ctx context.Context) error { key := sessionKey{target: name, sessionID: runtime.DefaultSessionID} sess, err := startProfileSession(ctx, key, profile, c.logger) if err != nil { - c.mu.Lock() - _ = c.stopAllSessions(context.Background()) - c.sessions = make(map[sessionKey]*profileSession) - c.mu.Unlock() + c.persistentExecutor.mu.Lock() + _ = c.persistentExecutor.stopAllSessions(context.Background()) + c.persistentExecutor.mu.Unlock() return fmt.Errorf("cli adapter: start target %q: %w", name, err) } - c.mu.Lock() - c.sessions[key] = sess - c.mu.Unlock() + c.persistentExecutor.mu.Lock() + c.persistentExecutor.sessions[key] = sess + c.persistentExecutor.mu.Unlock() c.logger.Info("cli adapter: persistent session started", zap.String("target", name)) } return nil @@ -160,47 +228,47 @@ func shouldAutostartPersistentProfile(profile config.CLIProfileConf) bool { profile.Mode != modePersistentLazy } -// stopAllSessions closes all sessions and clears the map. -// Must be called while c.mu is held. -func (c *CLI) stopAllSessions(_ context.Context) error { +// Stop stops all logical sessions. Errors are combined by reporting only the first. +func (c *CLI) Stop(ctx context.Context) error { var firstErr error - for key, sess := range c.sessions { - if err := closeProfileSession(context.Background(), sess); err != nil && firstErr == nil { - firstErr = fmt.Errorf("cli adapter: close session %q/%q: %w", key.target, key.sessionID, err) + for _, reporter := range c.reporters { + if err := reporter.Stop(ctx); err != nil && firstErr == nil { + firstErr = err } } - c.sessions = make(map[sessionKey]*profileSession) return firstErr } -// Stop stops all logical sessions. Errors are combined by reporting only the first. -func (c *CLI) Stop(_ context.Context) error { - c.mu.Lock() - sessionsCopy := make(map[sessionKey]*profileSession, len(c.sessions)) - for key, sess := range c.sessions { - sessionsCopy[key] = sess - } - c.sessions = make(map[sessionKey]*profileSession) - c.codexSessions = make(map[sessionKey]*codexExecSession) - c.agySessions = make(map[sessionKey]*antigravitySession) - opencodeCopy := make(map[sessionKey]*opencodeSSESession, len(c.opencodeSessions)) - for key, sess := range c.opencodeSessions { - opencodeCopy[key] = sess - } - c.opencodeSessions = make(map[sessionKey]*opencodeSSESession) - c.mu.Unlock() - - for _, sess := range opencodeCopy { - closeOpencodeSession(sess) - } - - var firstErr error - for key, sess := range sessionsCopy { - if err := closeProfileSession(context.Background(), sess); err != nil && firstErr == nil { - firstErr = fmt.Errorf("cli adapter: close session %q/%q: %w", key.target, key.sessionID, err) +func (c *CLI) executorFor(profile config.CLIProfileConf) executor { + switch profile.Mode { + case modeCodexExec: + return c.codexExecutor + case modeAntigravity: + return c.antigravityExecutor + case modeOpencodeSSE: + return c.opencodeExecutor + default: + if profile.Persistent { + return c.persistentExecutor } + return c.oneShotExecutor + } +} + +func (c *CLI) sessionReporterFor(profile config.CLIProfileConf) sessionReporter { + switch profile.Mode { + case modeCodexExec: + return c.codexExecutor + case modeAntigravity: + return c.antigravityExecutor + case modeOpencodeSSE: + return c.opencodeExecutor + default: + if profile.Persistent { + return c.persistentExecutor + } + return nil } - return firstErr } func (c *CLI) Execute(ctx context.Context, spec runtime.ExecutionSpec, sink runtime.EventSink) error { @@ -209,19 +277,7 @@ func (c *CLI) Execute(ctx context.Context, spec runtime.ExecutionSpec, sink runt if !ok { return fmt.Errorf("cli adapter: unknown target %q", target) } - if profile.Mode == modeCodexExec { - return c.executeCodexExec(ctx, spec, profile, sink) - } - if profile.Mode == modeAntigravity { - return c.executeAntigravityPrint(ctx, spec, profile, sink) - } - if profile.Mode == modeOpencodeSSE { - return c.executeOpencodeSSE(ctx, spec, profile, sink) - } - if profile.Persistent { - return c.executePersistent(ctx, spec, profile, sink) - } - return c.executeOneShot(ctx, spec, profile, sink) + return c.executorFor(profile).Execute(ctx, spec, profile, sink) } func (c *CLI) HandleCommand(ctx context.Context, req runtime.CommandRequest) (runtime.CommandResponse, error) { @@ -297,21 +353,10 @@ func (e sessionListEntry) label() string { } func (c *CLI) handleSessionList(req runtime.CommandRequest) runtime.CommandResponse { - c.mu.Lock() - snaps := make([]sessionListEntry, 0, len(c.sessions)+len(c.codexSessions)+len(c.agySessions)+len(c.opencodeSessions)) - for k := range c.sessions { - snaps = append(snaps, sessionListEntry{"persistent", k.target, k.sessionID}) + var snaps []sessionListEntry + for _, r := range c.reporters { + snaps = append(snaps, r.Sessions()...) } - for k := range c.codexSessions { - snaps = append(snaps, sessionListEntry{"codex-exec", k.target, k.sessionID}) - } - for k := range c.agySessions { - snaps = append(snaps, sessionListEntry{"antigravity-print", k.target, k.sessionID}) - } - for k := range c.opencodeSessions { - snaps = append(snaps, sessionListEntry{"opencode-sse", k.target, k.sessionID}) - } - c.mu.Unlock() sort.Slice(snaps, func(i, j int) bool { return snaps[i].label() < snaps[j].label() }) labels := make([]string, len(snaps)) for i, s := range snaps { @@ -339,56 +384,22 @@ func (c *CLI) handleSessionList(req runtime.CommandRequest) runtime.CommandRespo } // TerminateSession implements runtime.SessionTerminator. -func (c *CLI) TerminateSession(_ context.Context, target, sessionID string) error { - key := sessionKey{target: target, sessionID: normalizeSessionID(sessionID)} - if profile, ok := c.profiles[target]; ok && profile.Mode == modeCodexExec { - c.mu.Lock() - _, ok := c.codexSessions[key] - if ok { - delete(c.codexSessions, key) - } - c.mu.Unlock() - if !ok { - return fmt.Errorf("cli adapter: no session %q for target %q", key.sessionID, target) - } - return nil +func (c *CLI) TerminateSession(ctx context.Context, target, sessionID string) error { + var reporter sessionReporter + if profile, ok := c.profiles[target]; ok { + reporter = c.sessionReporterFor(profile) } - if profile, ok := c.profiles[target]; ok && profile.Mode == modeAntigravity { - c.mu.Lock() - _, ok := c.agySessions[key] - if ok { - delete(c.agySessions, key) - } - c.mu.Unlock() - if !ok { - return fmt.Errorf("cli adapter: no session %q for target %q", key.sessionID, target) - } - return nil + if reporter == nil { + reporter = c.persistentExecutor } - if profile, ok := c.profiles[target]; ok && profile.Mode == modeOpencodeSSE { - c.mu.Lock() - sess, ok := c.opencodeSessions[key] - if ok { - delete(c.opencodeSessions, key) - } - c.mu.Unlock() - if !ok { - return fmt.Errorf("cli adapter: no session %q for target %q", key.sessionID, target) - } - closeOpencodeSession(sess) - return nil + terminated, err := reporter.Terminate(ctx, target, sessionID) + if err != nil { + return err } - - c.mu.Lock() - sess, ok := c.sessions[key] - if ok { - delete(c.sessions, key) + if !terminated { + return fmt.Errorf("cli adapter: no session %q for target %q", normalizeSessionID(sessionID), target) } - c.mu.Unlock() - if !ok { - return fmt.Errorf("cli adapter: no session %q for target %q", key.sessionID, target) - } - return closeProfileSession(context.Background(), sess) + return nil } func cliTargetName(spec runtime.ExecutionSpec) string { @@ -413,10 +424,31 @@ func normalizeSessionID(id string) string { return id } +func isAlreadyClosedError(err error) bool { + if err == nil { + return false + } + if errors.Is(err, os.ErrClosed) { + return true + } + errStr := err.Error() + return strings.Contains(errStr, "file already closed") || + strings.Contains(errStr, "use of closed file") +} + func closeProfileSession(_ context.Context, sess *profileSession) error { - err := sess.closeFn() + if sess.core != nil { + return sess.core.Close() + } + var err error + if sess.closeFn != nil { + err = sess.closeFn() + } if sess.cmd != nil && sess.cmd.Process != nil { _ = sess.cmd.Process.Kill() } + if isAlreadyClosedError(err) { + return nil + } return err } diff --git a/apps/node/internal/adapters/cli/cli_internal_test.go b/apps/node/internal/adapters/cli/cli_internal_test.go index 5dc2fd8..3d9d816 100644 --- a/apps/node/internal/adapters/cli/cli_internal_test.go +++ b/apps/node/internal/adapters/cli/cli_internal_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "os" "strings" "testing" @@ -366,18 +367,55 @@ func TestEmitters_HaveDistinctNames(t *testing.T) { } } -func TestHandleSessionList_PopulatedSnapshot(t *testing.T) { - c := &CLI{ - sessions: make(map[sessionKey]*profileSession), - codexSessions: make(map[sessionKey]*codexExecSession), - agySessions: make(map[sessionKey]*antigravitySession), - opencodeSessions: make(map[sessionKey]*opencodeSSESession), +func TestExecutorForMode(t *testing.T) { + c := New(config.CLIConf{}, nil) + + testCases := []struct { + mode string + persistent bool + wantType string + }{ + {mode: "codex-exec", wantType: "codex"}, + {mode: "antigravity-print", wantType: "antigravity"}, + {mode: "opencode-sse", wantType: "opencode"}, + {mode: "persistent-lazy", persistent: true, wantType: "persistent"}, + {mode: "", persistent: true, wantType: "persistent"}, + {mode: "", persistent: false, wantType: "oneshot"}, + {mode: "unknown-mode", persistent: false, wantType: "oneshot"}, } - c.sessions[sessionKey{target: "claude", sessionID: "default"}] = &profileSession{} - c.sessions[sessionKey{target: "claude", sessionID: "alt"}] = &profileSession{} - c.codexSessions[sessionKey{target: "codex", sessionID: "default"}] = &codexExecSession{} - c.agySessions[sessionKey{target: "antigravity", sessionID: "main"}] = &antigravitySession{} - c.opencodeSessions[sessionKey{target: "opencode", sessionID: "main"}] = &opencodeSSESession{} + + for _, tc := range testCases { + profile := config.CLIProfileConf{ + Mode: tc.mode, + Persistent: tc.persistent, + } + exec := c.executorFor(profile) + var ok bool + switch tc.wantType { + case "codex": + _, ok = exec.(*codexExecutor) + case "antigravity": + _, ok = exec.(*antigravityExecutor) + case "opencode": + _, ok = exec.(*opencodeExecutor) + case "persistent": + _, ok = exec.(*persistentExecutor) + case "oneshot": + _, ok = exec.(*oneshotExecutor) + } + if !ok { + t.Errorf("executorFor(mode=%q, persistent=%t) got type %T, want %s", tc.mode, tc.persistent, exec, tc.wantType) + } + } +} + +func TestHandleSessionList_PopulatedSnapshot(t *testing.T) { + c := New(config.CLIConf{}, nil) + c.persistentExecutor.sessions[sessionKey{target: "claude", sessionID: "default"}] = &profileSession{} + c.persistentExecutor.sessions[sessionKey{target: "claude", sessionID: "alt"}] = &profileSession{} + c.codexExecutor.sessions[sessionKey{target: "codex", sessionID: "default"}] = &codexExecSession{} + c.antigravityExecutor.sessions[sessionKey{target: "antigravity", sessionID: "main"}] = &antigravitySession{} + c.opencodeExecutor.sessions[sessionKey{target: "opencode", sessionID: "main"}] = &opencodeSSESession{} resp := c.handleSessionList(runtime.CommandRequest{ RequestID: "req-list", @@ -423,13 +461,8 @@ func TestHandleSessionList_PopulatedSnapshot(t *testing.T) { } func TestHandleSessionList_SlashInSessionID(t *testing.T) { - c := &CLI{ - sessions: make(map[sessionKey]*profileSession), - codexSessions: make(map[sessionKey]*codexExecSession), - agySessions: make(map[sessionKey]*antigravitySession), - opencodeSessions: make(map[sessionKey]*opencodeSSESession), - } - c.sessions[sessionKey{target: "claude", sessionID: "team/a/b"}] = &profileSession{} + c := New(config.CLIConf{}, nil) + c.persistentExecutor.sessions[sessionKey{target: "claude", sessionID: "team/a/b"}] = &profileSession{} resp := c.handleSessionList(runtime.CommandRequest{ RequestID: "req-slash", @@ -474,15 +507,11 @@ func TestJsonEmitters_RegistryMatchesImpls(t *testing.T) { func TestHandleUsageStatus_EnvelopeAndParseMetadata(t *testing.T) { t.Run("raw-only parse_status", func(t *testing.T) { - c := &CLI{ - profiles: map[string]config.CLIProfileConf{ + c := New(config.CLIConf{ + Profiles: map[string]config.CLIProfileConf{ "claude": {}, }, - sessions: make(map[sessionKey]*profileSession), - codexSessions: make(map[sessionKey]*codexExecSession), - agySessions: make(map[sessionKey]*antigravitySession), - opencodeSessions: make(map[sessionKey]*opencodeSSESession), - } + }, nil) c.StatusChecker = func(_ context.Context, _ string, _ config.CLIProfileConf) (*status.UsageStatus, error) { return &status.UsageStatus{RawOutput: "some raw text"}, nil } @@ -521,15 +550,11 @@ func TestHandleUsageStatus_EnvelopeAndParseMetadata(t *testing.T) { }) t.Run("metadata-only no synthetic parse_status", func(t *testing.T) { - c := &CLI{ - profiles: map[string]config.CLIProfileConf{ + c := New(config.CLIConf{ + Profiles: map[string]config.CLIProfileConf{ "claude": {}, }, - sessions: make(map[sessionKey]*profileSession), - codexSessions: make(map[sessionKey]*codexExecSession), - agySessions: make(map[sessionKey]*antigravitySession), - opencodeSessions: make(map[sessionKey]*opencodeSSESession), - } + }, nil) c.StatusChecker = func(_ context.Context, _ string, _ config.CLIProfileConf) (*status.UsageStatus, error) { return &status.UsageStatus{ Metadata: map[string]string{"source": "cli"}, @@ -554,3 +579,24 @@ func TestHandleUsageStatus_EnvelopeAndParseMetadata(t *testing.T) { } }) } + +func TestCloseProfileSession_PipeFallbackIdempotent(t *testing.T) { + var mockCloseCalled int + mockClose := func() error { + mockCloseCalled++ + return os.ErrClosed + } + + sess := &profileSession{ + closeFn: mockClose, + } + + err := closeProfileSession(context.Background(), sess) + if err != nil { + t.Fatalf("expected nil error on idempotent close of already closed session, got: %v", err) + } + + if mockCloseCalled != 1 { + t.Errorf("expected mockClose to be called 1 time, got %d", mockCloseCalled) + } +} diff --git a/apps/node/internal/adapters/cli/codex_exec.go b/apps/node/internal/adapters/cli/codex_exec.go index 7b91e20..2f24799 100644 --- a/apps/node/internal/adapters/cli/codex_exec.go +++ b/apps/node/internal/adapters/cli/codex_exec.go @@ -15,12 +15,12 @@ var ( codexThreadStartedRegex = regexp.MustCompile(`\{[^{}]*"type"\s*:\s*"thread\.started"[^{}]*\}`) ) -func (c *CLI) executeCodexExec(ctx context.Context, spec runtime.ExecutionSpec, profile config.CLIProfileConf, sink runtime.EventSink) error { +func (e *codexExecutor) Execute(ctx context.Context, spec runtime.ExecutionSpec, profile config.CLIProfileConf, sink runtime.EventSink) error { if profile.ResumeArgs == nil { return fmt.Errorf("cli adapter: codex-exec mode requires resume_args in profile %q", spec.Target) } - sess, err := c.resolveCodexExecSession(spec) + sess, err := e.resolveCodexExecSession(spec) if err != nil { return err } @@ -30,7 +30,7 @@ func (c *CLI) executeCodexExec(ctx context.Context, spec runtime.ExecutionSpec, prompt := extractPrompt(spec.Input) args := codexExecArgs(profile, sess.externalID, prompt) - output, err := c.executeCommand(ctx, spec, profile, args, prompt, sink) + output, err := e.cli.executeCommand(ctx, spec, profile, args, prompt, sink) if err != nil { return err } @@ -44,24 +44,52 @@ func (c *CLI) executeCodexExec(ctx context.Context, spec runtime.ExecutionSpec, return nil } -func (c *CLI) resolveCodexExecSession(spec runtime.ExecutionSpec) (*codexExecSession, error) { +func (e *codexExecutor) resolveCodexExecSession(spec runtime.ExecutionSpec) (*codexExecSession, error) { target := cliTargetName(spec) key := sessionKey{target: target, sessionID: normalizeSessionID(spec.SessionID)} - c.mu.Lock() - defer c.mu.Unlock() + e.mu.Lock() + defer e.mu.Unlock() - if sess, ok := c.codexSessions[key]; ok { + if sess, ok := e.sessions[key]; ok { return sess, nil } if spec.SessionMode == runtime.SessionModeRequireExisting { return nil, fmt.Errorf("cli adapter: no persistent session for target %q session %q", target, key.sessionID) } sess := &codexExecSession{key: key} - c.codexSessions[key] = sess + e.sessions[key] = sess return sess, nil } +func (e *codexExecutor) Sessions() []sessionListEntry { + e.mu.Lock() + defer e.mu.Unlock() + snaps := make([]sessionListEntry, 0, len(e.sessions)) + for k := range e.sessions { + snaps = append(snaps, sessionListEntry{"codex-exec", k.target, k.sessionID}) + } + return snaps +} + +func (e *codexExecutor) Terminate(ctx context.Context, target, sessionID string) (bool, error) { + key := sessionKey{target: target, sessionID: normalizeSessionID(sessionID)} + e.mu.Lock() + _, ok := e.sessions[key] + if ok { + delete(e.sessions, key) + } + e.mu.Unlock() + return ok, nil +} + +func (e *codexExecutor) Stop(ctx context.Context) error { + e.mu.Lock() + e.sessions = make(map[sessionKey]*codexExecSession) + e.mu.Unlock() + return nil +} + func codexExecArgs(profile config.CLIProfileConf, externalSessionID, prompt string) []string { if externalSessionID == "" { args := append([]string{}, profile.Args...) diff --git a/apps/node/internal/adapters/cli/opencode_sse.go b/apps/node/internal/adapters/cli/opencode_sse.go index fcb5e96..54173f3 100644 --- a/apps/node/internal/adapters/cli/opencode_sse.go +++ b/apps/node/internal/adapters/cli/opencode_sse.go @@ -102,10 +102,10 @@ func decodeSSEDataLine(line []byte) ([]byte, bool) { return data, true } -func (c *CLI) executeOpencodeSSE(ctx context.Context, spec runtime.ExecutionSpec, profile config.CLIProfileConf, sink runtime.EventSink) error { +func (e *opencodeExecutor) Execute(ctx context.Context, spec runtime.ExecutionSpec, profile config.CLIProfileConf, sink runtime.EventSink) error { opts := parseOpencodeRunArgs(profile.Args) - sess, err := c.resolveOpencodeSession(ctx, spec, profile, opts) + sess, err := e.resolveOpencodeSession(ctx, spec, profile, opts) if err != nil { return err } @@ -175,29 +175,29 @@ func (c *CLI) executeOpencodeSSE(ctx context.Context, spec runtime.ExecutionSpec return fmt.Errorf("cli adapter: opencode prompt async: %w", err) } - return c.driveOpencodeSSE(ctx, spec, sess, opts, sseResp.Body, sink) + return e.driveOpencodeSSE(ctx, spec, sess, opts, sseResp.Body, sink) } -func (c *CLI) resolveOpencodeSession(ctx context.Context, spec runtime.ExecutionSpec, profile config.CLIProfileConf, opts opencodeRunOpts) (*opencodeSSESession, error) { +func (e *opencodeExecutor) resolveOpencodeSession(ctx context.Context, spec runtime.ExecutionSpec, profile config.CLIProfileConf, opts opencodeRunOpts) (*opencodeSSESession, error) { target := cliTargetName(spec) key := sessionKey{target: target, sessionID: normalizeSessionID(spec.SessionID)} - c.mu.Lock() - if sess, ok := c.opencodeSessions[key]; ok { - c.mu.Unlock() + e.mu.Lock() + if sess, ok := e.sessions[key]; ok { + e.mu.Unlock() return sess, nil } if spec.SessionMode == runtime.SessionModeRequireExisting { - c.mu.Unlock() + e.mu.Unlock() return nil, fmt.Errorf("cli adapter: no persistent session for target %q session %q", target, key.sessionID) } - c.mu.Unlock() + e.mu.Unlock() serverURL := strings.TrimRight(opts.AttachURL, "/") var cmd *exec.Cmd owned := false if serverURL == "" { - url, started, err := startOpencodeLocalServer(ctx, profile, c.logger) + url, started, err := startOpencodeLocalServer(ctx, profile, e.cli.logger) if err != nil { return nil, fmt.Errorf("cli adapter: start opencode server: %w", err) } @@ -213,19 +213,59 @@ func (c *CLI) resolveOpencodeSession(ctx context.Context, spec runtime.Execution owned: owned, } - c.mu.Lock() - if existing, ok := c.opencodeSessions[key]; ok { - c.mu.Unlock() + e.mu.Lock() + if existing, ok := e.sessions[key]; ok { + e.mu.Unlock() if owned && cmd != nil && cmd.Process != nil { _ = cmd.Process.Kill() } return existing, nil } - c.opencodeSessions[key] = sess - c.mu.Unlock() + e.sessions[key] = sess + e.mu.Unlock() return sess, nil } +func (e *opencodeExecutor) Sessions() []sessionListEntry { + e.mu.Lock() + defer e.mu.Unlock() + snaps := make([]sessionListEntry, 0, len(e.sessions)) + for k := range e.sessions { + snaps = append(snaps, sessionListEntry{"opencode-sse", k.target, k.sessionID}) + } + return snaps +} + +func (e *opencodeExecutor) Terminate(ctx context.Context, target, sessionID string) (bool, error) { + key := sessionKey{target: target, sessionID: normalizeSessionID(sessionID)} + e.mu.Lock() + sess, ok := e.sessions[key] + if ok { + delete(e.sessions, key) + } + e.mu.Unlock() + if !ok { + return false, nil + } + closeOpencodeSession(sess) + return true, nil +} + +func (e *opencodeExecutor) Stop(ctx context.Context) error { + e.mu.Lock() + opencodeCopy := make(map[sessionKey]*opencodeSSESession, len(e.sessions)) + for key, sess := range e.sessions { + opencodeCopy[key] = sess + } + e.sessions = make(map[sessionKey]*opencodeSSESession) + e.mu.Unlock() + + for _, sess := range opencodeCopy { + closeOpencodeSession(sess) + } + return nil +} + var opencodeServerListenRE = regexp.MustCompile(`(?i)opencode server listening on (https?://\S+)`) func startOpencodeLocalServer(ctx context.Context, profile config.CLIProfileConf, logger *zap.Logger) (string, *exec.Cmd, error) { @@ -307,7 +347,7 @@ type sseEvtTrace struct { Outcome string `json:"out,omitempty"` // "delta", "idle", "error", "skip", "filtered" } -func (c *CLI) driveOpencodeSSE(ctx context.Context, spec runtime.ExecutionSpec, sess *opencodeSSESession, opts opencodeRunOpts, body io.Reader, sink runtime.EventSink) error { +func (e *opencodeExecutor) driveOpencodeSSE(ctx context.Context, spec runtime.ExecutionSpec, sess *opencodeSSESession, opts opencodeRunOpts, body io.Reader, sink runtime.EventSink) error { type evt struct { ev opencodeEnvelope raw []byte @@ -373,8 +413,8 @@ func (c *CLI) driveOpencodeSSE(ctx context.Context, spec runtime.ExecutionSpec, if sess.sessionID != "" { _ = opencodeAbort(bg, sess.serverURL, sess.sessionID) } - if c.logger != nil { - c.logger.Warn("opencode sse run cancelled without completion", + if e.cli.logger != nil { + e.cli.logger.Warn("opencode sse run cancelled without completion", zap.String("run_id", spec.RunID), zap.String("reason", reason.Error()), zap.Int("total_events", traceSeen), diff --git a/apps/node/internal/adapters/cli/persistent.go b/apps/node/internal/adapters/cli/persistent.go index 807cc89..2f5f3a7 100644 --- a/apps/node/internal/adapters/cli/persistent.go +++ b/apps/node/internal/adapters/cli/persistent.go @@ -11,7 +11,6 @@ import ( "time" "unicode" - "github.com/creack/pty" "go.uber.org/zap" "iop/apps/node/internal/adapters/cli/status" @@ -63,8 +62,8 @@ func (m completionMatcher) match(line string) bool { return false } -func (c *CLI) executePersistent(ctx context.Context, spec runtime.ExecutionSpec, profile config.CLIProfileConf, sink runtime.EventSink) error { - sess, err := c.resolveSession(ctx, spec, profile) +func (e *persistentExecutor) Execute(ctx context.Context, spec runtime.ExecutionSpec, profile config.CLIProfileConf, sink runtime.EventSink) error { + sess, err := e.resolveSession(ctx, spec, profile) if err != nil { return err } @@ -96,8 +95,14 @@ func (c *CLI) executePersistent(ctx context.Context, spec runtime.ExecutionSpec, Timestamp: time.Now(), }) - if err := writePrompt(ctx, sess.input, prompt, profile); err != nil { - return emitRuntimeError(ctx, sink, spec.RunID, fmt.Sprintf("write prompt: %v", err)) + if sess.core != nil { + if err := sess.core.WritePrompt(ctx, prompt); err != nil { + return emitRuntimeError(ctx, sink, spec.RunID, fmt.Sprintf("write prompt: %v", err)) + } + } else { + if err := writePrompt(ctx, sess.input, prompt, profile); err != nil { + return emitRuntimeError(ctx, sink, spec.RunID, fmt.Sprintf("write prompt: %v", err)) + } } var idleTimer *time.Timer @@ -115,7 +120,7 @@ func (c *CLI) executePersistent(ctx context.Context, spec runtime.ExecutionSpec, select { case <-ctx.Done(): // Drain output so the process remains usable for the next run. - drainSessionUntilIdle(sess.output, idleTimeout, c.logger, sess.key) + drainSessionUntilIdle(sess.output, idleTimeout, e.cli.logger, sess.key) _ = sink.Emit(context.Background(), runtime.RuntimeEvent{ RunID: spec.RunID, Type: runtime.EventTypeCancelled, @@ -126,11 +131,11 @@ func (c *CLI) executePersistent(ctx context.Context, spec runtime.ExecutionSpec, case out, ok := <-sess.output: if !ok { err := drainPersistentDone(sess) - return c.emitPersistentExit(ctx, sink, spec.RunID, targetName, profile, sess, err) + return e.emitPersistentExit(ctx, sink, spec.RunID, targetName, profile, sess, err) } if waitForFilteredMessage { if msg, cancelled := claudeTerminalCancelMessage(sess.getTail()); cancelled { - c.removePersistentSession(sess) + e.removePersistentSession(sess) _ = closeProfileSession(context.Background(), sess) _ = sink.Emit(ctx, runtime.RuntimeEvent{ RunID: spec.RunID, @@ -201,8 +206,14 @@ func (c *CLI) executePersistent(ctx context.Context, spec runtime.ExecutionSpec, } else if waitForFilteredMessage && !outputFilter.HasOutput() { if claudePromptReplayCount == 0 && claudeTerminalReadyForInput(sess.getTail()) { claudePromptReplayCount++ - c.logger.Info("cli adapter: replaying claude prompt after ready screen", zap.String("target", targetName), zap.String("session", sess.key.sessionID)) - if err := writePrompt(ctx, sess.input, prompt, profile); err != nil { + e.cli.logger.Info("cli adapter: replaying claude prompt after ready screen", zap.String("target", targetName), zap.String("session", sess.key.sessionID)) + var err error + if sess.core != nil { + err = sess.core.WritePrompt(ctx, prompt) + } else { + err = writePrompt(ctx, sess.input, prompt, profile) + } + if err != nil { return emitRuntimeError(ctx, sink, spec.RunID, fmt.Sprintf("replay prompt: %v", err)) } } @@ -224,7 +235,7 @@ func (c *CLI) executePersistent(ctx context.Context, spec runtime.ExecutionSpec, Timestamp: time.Now(), }) case err := <-sess.done: - return c.emitPersistentExit(ctx, sink, spec.RunID, targetName, profile, sess, err) + return e.emitPersistentExit(ctx, sink, spec.RunID, targetName, profile, sess, err) } } } @@ -293,23 +304,23 @@ func writePrompt(ctx context.Context, input io.Writer, prompt string, profile co // resolveSession returns an existing session for the given key or creates one // when SessionMode allows it. -func (c *CLI) resolveSession(ctx context.Context, spec runtime.ExecutionSpec, profile config.CLIProfileConf) (*profileSession, error) { +func (e *persistentExecutor) resolveSession(ctx context.Context, spec runtime.ExecutionSpec, profile config.CLIProfileConf) (*profileSession, error) { target := cliTargetName(spec) key := sessionKey{target: target, sessionID: normalizeSessionID(spec.SessionID)} - c.mu.Lock() - defer c.mu.Unlock() + e.mu.Lock() + defer e.mu.Unlock() - if sess, ok := c.sessions[key]; ok { + if sess, ok := e.sessions[key]; ok { return sess, nil } if spec.SessionMode == runtime.SessionModeRequireExisting { return nil, fmt.Errorf("cli adapter: no persistent session for target %q session %q", target, key.sessionID) } - sess, err := startProfileSession(ctx, key, profile, c.logger) + sess, err := startProfileSession(ctx, key, profile, e.cli.logger) if err != nil { return nil, err } - c.sessions[key] = sess + e.sessions[key] = sess return sess, nil } @@ -329,44 +340,24 @@ func startProfileSession(_ context.Context, key sessionKey, profile config.CLIPr done: doneCh, } - var input io.Writer - var closeFn func() error var cmd *exec.Cmd if profile.Terminal { - cmd = exec.Command(profile.Command, profile.Args...) - if len(profile.Env) > 0 { - cmd.Env = append(cmd.Environ(), profile.Env...) + opts := terminalSessionOptions{ + Command: profile.Command, + Args: profile.Args, + Env: profile.Env, } - ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{ - Rows: terminalRows, - Cols: terminalCols, - }) + core, err := startTerminalSessionCore(context.Background(), opts) if err != nil { - return nil, fmt.Errorf("pty start: %w", err) + return nil, err } - input = ptmx - closeFn = ptmx.Close - sess.cmd = cmd - sess.input = ptmx - sess.closeFn = ptmx.Close - - go func() { - buf := make([]byte, 4096) - for { - n, err := ptmx.Read(buf) - if n > 0 { - text := string(buf[:n]) - sess.appendTail(text) - outputCh <- cliOutput{text: text} - } - if err != nil { - doneCh <- cmd.Wait() - close(outputCh) - return - } - } - }() + sess.core = core + sess.cmd = core.cmd + sess.input = core.input + sess.output = core.outputCh + sess.done = core.doneCh + sess.closeFn = core.Close } else { cmd = exec.Command(profile.Command, profile.Args...) if len(profile.Env) > 0 { @@ -385,8 +376,6 @@ func startProfileSession(_ context.Context, key sessionKey, profile config.CLIPr _ = stdin.Close() return nil, fmt.Errorf("start: %w", err) } - input = stdin - closeFn = stdin.Close sess.cmd = cmd sess.input = stdin sess.closeFn = stdin.Close @@ -405,14 +394,14 @@ func startProfileSession(_ context.Context, key sessionKey, profile config.CLIPr } if profile.StartupIdleTimeoutMS > 0 { - drainUntilIdle(outputCh, input, time.Duration(profile.StartupIdleTimeoutMS)*time.Millisecond, logger, key, profile) + drainUntilIdle(sess.output, sess.input, time.Duration(profile.StartupIdleTimeoutMS)*time.Millisecond, logger, key, profile) } select { - case err := <-doneCh: - _ = closeFn() - if cmd.Process != nil { - _ = cmd.Process.Kill() + case err := <-sess.done: + _ = sess.closeFn() + if sess.cmd != nil && sess.cmd.Process != nil { + _ = sess.cmd.Process.Kill() } tail := sess.getTail() if err == nil { @@ -584,8 +573,8 @@ func drainPersistentDone(sess *profileSession) error { } } -func (c *CLI) emitPersistentExit(ctx context.Context, sink runtime.EventSink, runID, targetName string, profile config.CLIProfileConf, sess *profileSession, err error) error { - c.removePersistentSession(sess) +func (e *persistentExecutor) emitPersistentExit(ctx context.Context, sink runtime.EventSink, runID, targetName string, profile config.CLIProfileConf, sess *profileSession, err error) error { + e.removePersistentSession(sess) cmdSummary := fmt.Sprintf("%s %s", profile.Command, strings.Join(profile.Args, " ")) tail := sess.getTail() @@ -603,10 +592,64 @@ func (c *CLI) emitPersistentExit(ctx context.Context, sink runtime.EventSink, ru return emitRuntimeError(ctx, sink, runID, msg) } -func (c *CLI) removePersistentSession(sess *profileSession) { - c.mu.Lock() - if s, found := c.sessions[sess.key]; found && s == sess { - delete(c.sessions, sess.key) +func (e *persistentExecutor) removePersistentSession(sess *profileSession) { + e.mu.Lock() + if s, found := e.sessions[sess.key]; found && s == sess { + delete(e.sessions, sess.key) } - c.mu.Unlock() + e.mu.Unlock() +} + +func (e *persistentExecutor) stopAllSessions(_ context.Context) error { + var firstErr error + for key, sess := range e.sessions { + if err := closeProfileSession(context.Background(), sess); err != nil && firstErr == nil { + firstErr = fmt.Errorf("cli adapter: close session %q/%q: %w", key.target, key.sessionID, err) + } + } + e.sessions = make(map[sessionKey]*profileSession) + return firstErr +} + +func (e *persistentExecutor) Sessions() []sessionListEntry { + e.mu.Lock() + defer e.mu.Unlock() + snaps := make([]sessionListEntry, 0, len(e.sessions)) + for k := range e.sessions { + snaps = append(snaps, sessionListEntry{"persistent", k.target, k.sessionID}) + } + return snaps +} + +func (e *persistentExecutor) Terminate(ctx context.Context, target, sessionID string) (bool, error) { + key := sessionKey{target: target, sessionID: normalizeSessionID(sessionID)} + e.mu.Lock() + sess, ok := e.sessions[key] + if ok { + delete(e.sessions, key) + } + e.mu.Unlock() + if !ok { + return false, nil + } + err := closeProfileSession(ctx, sess) + return true, err +} + +func (e *persistentExecutor) Stop(ctx context.Context) error { + e.mu.Lock() + sessionsCopy := make(map[sessionKey]*profileSession, len(e.sessions)) + for key, sess := range e.sessions { + sessionsCopy[key] = sess + } + e.sessions = make(map[sessionKey]*profileSession) + e.mu.Unlock() + + var firstErr error + for key, sess := range sessionsCopy { + if err := closeProfileSession(context.Background(), sess); err != nil && firstErr == nil { + firstErr = fmt.Errorf("cli adapter: close session %q/%q: %w", key.target, key.sessionID, err) + } + } + return firstErr } diff --git a/apps/node/internal/adapters/cli/status/tail_buffer.go b/apps/node/internal/adapters/cli/status/tail_buffer.go new file mode 100644 index 0000000..ec605e2 --- /dev/null +++ b/apps/node/internal/adapters/cli/status/tail_buffer.go @@ -0,0 +1,43 @@ +package status + +import ( + "strings" + "sync" +) + +// TailBuffer holds a bounded buffer of terminal output. +type TailBuffer struct { + mu sync.Mutex + buf strings.Builder + max int +} + +// NewTailBuffer creates a new TailBuffer with the given maximum capacity in bytes. +func NewTailBuffer(max int) *TailBuffer { + return &TailBuffer{max: max} +} + +// Append appends a string to the buffer, truncating from the front if it exceeds capacity. +func (tb *TailBuffer) Append(s string) { + tb.mu.Lock() + defer tb.mu.Unlock() + tb.buf.WriteString(s) + raw := tb.buf.String() + if len(raw) <= tb.max { + return + } + tb.buf.Reset() + tb.buf.WriteString(raw[len(raw)-tb.max:]) +} + +// String returns the contents of the buffer. +func (tb *TailBuffer) String() string { + tb.mu.Lock() + defer tb.mu.Unlock() + return tb.buf.String() +} + +// Snapshot holds a snapshot of a terminal session. +type Snapshot struct { + Tail string +} diff --git a/apps/node/internal/adapters/cli/terminal_session.go b/apps/node/internal/adapters/cli/terminal_session.go new file mode 100644 index 0000000..1821d99 --- /dev/null +++ b/apps/node/internal/adapters/cli/terminal_session.go @@ -0,0 +1,174 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "sync" + "time" + + "github.com/creack/pty" + "iop/apps/node/internal/adapters/cli/status" +) + +type terminalSessionOptions struct { + Command string + Args []string + Env []string +} + +type terminalSessionCore struct { + cmd *exec.Cmd + input io.WriteCloser + output io.ReadCloser + tail *status.TailBuffer + outputCh chan cliOutput + doneCh chan error + closed bool + mu sync.Mutex +} + +func startTerminalSessionCore(ctx context.Context, opts terminalSessionOptions) (*terminalSessionCore, error) { + cmd := exec.Command(opts.Command, opts.Args...) + if len(opts.Env) > 0 { + cmd.Env = append(cmd.Environ(), opts.Env...) + } + ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{ + Rows: terminalRows, + Cols: terminalCols, + }) + if err != nil { + return nil, fmt.Errorf("pty start: %w", err) + } + + core := &terminalSessionCore{ + cmd: cmd, + input: ptmx, + output: ptmx, + tail: status.NewTailBuffer(2048), + outputCh: make(chan cliOutput, 1024), + doneCh: make(chan error, 1), + } + + go core.readLoop() + + return core, nil +} + +func (s *terminalSessionCore) readLoop() { + buf := make([]byte, 4096) + for { + n, err := s.output.Read(buf) + if n > 0 { + text := string(buf[:n]) + s.tail.Append(text) + s.outputCh <- cliOutput{text: text} + } + if err != nil { + s.doneCh <- s.cmd.Wait() + close(s.outputCh) + return + } + } +} + +func (s *terminalSessionCore) WritePrompt(ctx context.Context, prompt string) error { + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return errors.New("terminal session core closed") + } + s.mu.Unlock() + + for _, r := range prompt { + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return errors.New("terminal session core closed") + } + _, err := io.WriteString(s.input, string(r)) + s.mu.Unlock() + if err != nil { + return err + } + timer := time.NewTimer(terminalInputDelay) + select { + case <-ctx.Done(): + timer.Stop() + return ctx.Err() + case <-timer.C: + } + } + + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return errors.New("terminal session core closed") + } + _, err := io.WriteString(s.input, "\r") + return err +} + +func (s *terminalSessionCore) Snapshot() status.Snapshot { + return status.Snapshot{ + Tail: s.tail.String(), + } +} + +func (s *terminalSessionCore) Resize(rows, cols uint16) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return errors.New("terminal session core closed") + } + if rows == 0 || cols == 0 { + return errors.New("invalid terminal size: rows and cols must be greater than 0") + } + f, ok := s.input.(*os.File) + if !ok { + return errors.New("terminal input is not a file") + } + return pty.Setsize(f, &pty.Winsize{Rows: rows, Cols: cols}) +} + +func (s *terminalSessionCore) WriteInput(ctx context.Context, data []byte) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return errors.New("terminal session core closed") + } + _, err := s.input.Write(data) + return err +} + +func (s *terminalSessionCore) Signal(sig os.Signal) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return errors.New("terminal session core closed") + } + if s.cmd == nil || s.cmd.Process == nil { + return errors.New("no process to signal") + } + return s.cmd.Process.Signal(sig) +} + +func (s *terminalSessionCore) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return nil + } + s.closed = true + err := s.input.Close() + if s.cmd != nil && s.cmd.Process != nil { + _ = s.cmd.Process.Kill() + } + if isAlreadyClosedError(err) { + return nil + } + return err +} diff --git a/apps/node/internal/adapters/cli/terminal_session_test.go b/apps/node/internal/adapters/cli/terminal_session_test.go new file mode 100644 index 0000000..9900575 --- /dev/null +++ b/apps/node/internal/adapters/cli/terminal_session_test.go @@ -0,0 +1,153 @@ +package cli + +import ( + "context" + "os" + "strings" + "testing" + "time" +) + +func TestTerminalSessionCoreWritesPrompt(t *testing.T) { + opts := terminalSessionOptions{ + Command: "sh", + } + core, err := startTerminalSessionCore(context.Background(), opts) + if err != nil { + t.Fatalf("failed to start terminal session: %v", err) + } + defer core.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Write prompt "echo hello-world" (WritePrompt will append \r) + if err := core.WritePrompt(ctx, "echo hello-world"); err != nil { + t.Fatalf("failed to write prompt: %v", err) + } + + // Wait for the output to propagate + var tail string + success := false + for { + select { + case <-ctx.Done(): + t.Fatalf("timeout waiting for output. tail was: %q", tail) + case <-time.After(100 * time.Millisecond): + tail = core.Snapshot().Tail + if strings.Contains(tail, "hello-world") { + success = true + break + } + } + if success { + break + } + } +} + +func TestTerminalSessionCoreSnapshot(t *testing.T) { + opts := terminalSessionOptions{ + Command: "sh", + } + core, err := startTerminalSessionCore(context.Background(), opts) + if err != nil { + t.Fatalf("failed to start terminal session: %v", err) + } + defer core.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + if err := core.WritePrompt(ctx, "echo test-snapshot"); err != nil { + t.Fatalf("failed to write prompt: %v", err) + } + + time.Sleep(200 * time.Millisecond) + snap := core.Snapshot() + if !strings.Contains(snap.Tail, "test-snapshot") { + t.Errorf("snapshot tail does not contain test-snapshot: %q", snap.Tail) + } +} + +func TestTerminalSessionCoreCloseIsIdempotent(t *testing.T) { + opts := terminalSessionOptions{ + Command: "sh", + } + core, err := startTerminalSessionCore(context.Background(), opts) + if err != nil { + t.Fatalf("failed to start terminal session: %v", err) + } + + if err := core.Close(); err != nil { + t.Errorf("first close failed: %v", err) + } + + if err := core.Close(); err != nil { + t.Errorf("second close failed: %v", err) + } +} + +func TestTerminalSessionCoreResizeValidatesBounds(t *testing.T) { + opts := terminalSessionOptions{ + Command: "sh", + } + core, err := startTerminalSessionCore(context.Background(), opts) + if err != nil { + t.Fatalf("failed to start terminal session: %v", err) + } + defer core.Close() + + if err := core.Resize(0, 80); err == nil { + t.Error("expected error when resizing with rows=0") + } + + if err := core.Resize(80, 0); err == nil { + t.Error("expected error when resizing with cols=0") + } + + if err := core.Resize(40, 120); err != nil { + t.Errorf("failed to resize with valid dimensions: %v", err) + } +} + +func TestTerminalSessionCoreRejectsWriteAfterClose(t *testing.T) { + opts := terminalSessionOptions{ + Command: "sh", + } + core, err := startTerminalSessionCore(context.Background(), opts) + if err != nil { + t.Fatalf("failed to start terminal session: %v", err) + } + + if err := core.Close(); err != nil { + t.Fatalf("close failed: %v", err) + } + + ctx := context.Background() + if err := core.WritePrompt(ctx, "ls"); err == nil { + t.Error("expected WritePrompt to reject writing after close") + } + + if err := core.WriteInput(ctx, []byte("ls\n")); err == nil { + t.Error("expected WriteInput to reject writing after close") + } +} + +func TestTerminalSessionCoreSignalAfterClose(t *testing.T) { + opts := terminalSessionOptions{ + Command: "sh", + } + core, err := startTerminalSessionCore(context.Background(), opts) + if err != nil { + t.Fatalf("failed to start terminal session: %v", err) + } + + if err := core.Close(); err != nil { + t.Fatalf("close failed: %v", err) + } + + if err := core.Signal(os.Interrupt); err == nil { + t.Error("expected Signal to reject signal after close") + } +} diff --git a/apps/node/internal/adapters/factory.go b/apps/node/internal/adapters/factory.go index cc5bb4f..2b959d9 100644 --- a/apps/node/internal/adapters/factory.go +++ b/apps/node/internal/adapters/factory.go @@ -9,15 +9,15 @@ import ( "iop/apps/node/internal/adapters/cli" "iop/apps/node/internal/adapters/mock" "iop/apps/node/internal/adapters/ollama" - "iop/apps/node/internal/adapters/vllm" "iop/packages/go/config" iop "iop/proto/gen/iop" ) +const vllmDisabledError = "adapters: vllm adapter is experimental and disabled until streaming execution is implemented" + // BuildFromPayload creates a Registry from a NodeConfigPayload received from edge. func BuildFromPayload(payload *iop.NodeConfigPayload, logger *zap.Logger) (*Registry, error) { reg := NewRegistry() - reg.Register(mock.New(logger)) for _, ac := range payload.GetAdapters() { if !ac.GetEnabled() { @@ -25,7 +25,7 @@ func BuildFromPayload(payload *iop.NodeConfigPayload, logger *zap.Logger) (*Regi } switch ac.GetType() { case "mock": - // mock is always registered above. + reg.Register(mock.New(logger)) case "ollama": var cfg config.OllamaConf if m := ac.GetOllama(); m != nil { @@ -35,13 +35,7 @@ func BuildFromPayload(payload *iop.NodeConfigPayload, logger *zap.Logger) (*Regi } reg.Register(ollama.New(cfg, logger)) case "vllm": - var cfg config.VllmConf - if m := ac.GetVllm(); m != nil { - cfg = vllmConfFromProto(m) - } else { - cfg = vllmConfFromStruct(ac.GetSettings()) - } - reg.Register(vllm.New(cfg, logger)) + return nil, fmt.Errorf(vllmDisabledError) case "cli": var cfg config.CLIConf if m := ac.GetCli(); m != nil { diff --git a/apps/node/internal/adapters/factory_internal_test.go b/apps/node/internal/adapters/factory_internal_test.go index 0dfaef4..3d33956 100644 --- a/apps/node/internal/adapters/factory_internal_test.go +++ b/apps/node/internal/adapters/factory_internal_test.go @@ -1,6 +1,7 @@ package adapters import ( + "strings" "testing" "google.golang.org/protobuf/types/known/structpb" @@ -299,7 +300,6 @@ func TestCLIConfFromStruct_LegacyPayload(t *testing.T) { // End-to-end fallback through BuildFromPayload: oneof empty, settings populated. func TestBuildFromPayload_LegacySettingsFallback(t *testing.T) { ollamaSt, _ := structpb.NewStruct(map[string]any{"base_url": "http://legacy:11434"}) - vllmSt, _ := structpb.NewStruct(map[string]any{"endpoint": "http://legacy:8000"}) cliSt, _ := structpb.NewStruct(map[string]any{ "profiles": map[string]any{ "codex": map[string]any{ @@ -311,7 +311,6 @@ func TestBuildFromPayload_LegacySettingsFallback(t *testing.T) { payload := &iop.NodeConfigPayload{ Adapters: []*iop.AdapterConfig{ {Type: "ollama", Enabled: true, Settings: ollamaSt}, - {Type: "vllm", Enabled: true, Settings: vllmSt}, {Type: "cli", Enabled: true, Settings: cliSt}, }, } @@ -319,9 +318,24 @@ func TestBuildFromPayload_LegacySettingsFallback(t *testing.T) { if err != nil { t.Fatalf("build: %v", err) } - for _, name := range []string{"ollama", "vllm", "cli"} { + for _, name := range []string{"ollama", "cli"} { if _, ok := reg.Get(name); !ok { t.Fatalf("expected %s adapter registered from legacy settings", name) } } } + +func TestBuildFromPayload_LegacyVllmSettingsRejected(t *testing.T) { + vllmSt, _ := structpb.NewStruct(map[string]any{"endpoint": "http://legacy:8000"}) + _, err := BuildFromPayload(&iop.NodeConfigPayload{ + Adapters: []*iop.AdapterConfig{ + {Type: "vllm", Enabled: true, Settings: vllmSt}, + }, + }, nil) + if err == nil { + t.Fatal("expected vllm disabled error") + } + if !strings.Contains(err.Error(), "vllm adapter is experimental and disabled") { + t.Fatalf("expected vllm disabled error, got %v", err) + } +} diff --git a/apps/node/internal/adapters/registry.go b/apps/node/internal/adapters/registry.go index 3530729..09b6f5d 100644 --- a/apps/node/internal/adapters/registry.go +++ b/apps/node/internal/adapters/registry.go @@ -16,7 +16,7 @@ type LifecycleAdapter interface { // Registry holds all registered adapters by name. type Registry struct { adapters map[string]runtime.Adapter - order []string // insertion order — first registered is the default + order []string // insertion order for lifecycle and diagnostics } func NewRegistry() *Registry { @@ -37,13 +37,6 @@ func (r *Registry) Get(name string) (runtime.Adapter, bool) { return a, ok } -func (r *Registry) Default() string { - if len(r.order) == 0 { - return "" - } - return r.order[0] -} - // All returns all registered adapters in registration order. func (r *Registry) All() []runtime.Adapter { out := make([]runtime.Adapter, 0, len(r.order)) diff --git a/apps/node/internal/adapters/vllm/vllm.go b/apps/node/internal/adapters/vllm/vllm.go index 575107f..5756fea 100644 --- a/apps/node/internal/adapters/vllm/vllm.go +++ b/apps/node/internal/adapters/vllm/vllm.go @@ -1,5 +1,5 @@ -// Package vllm provides an Adapter that calls a vLLM server via its -// OpenAI-compatible /v1/chat/completions endpoint. +// Package vllm contains the experimental vLLM adapter surface. It can query +// models, but execution remains disabled until streaming support is implemented. package vllm import ( @@ -45,14 +45,12 @@ func (v *Vllm) Capabilities(_ context.Context) (runtime.Capabilities, error) { } func (v *Vllm) Execute(ctx context.Context, spec runtime.ExecutionSpec, sink runtime.EventSink) error { - // TODO: implement streaming POST to v.endpoint/v1/chat/completions - // with "stream": true and SSE response parsing. v.logger.Info("vllm adapter called", zap.String("run_id", spec.RunID), zap.String("target", spec.Target), zap.String("endpoint", v.endpoint), ) - return fmt.Errorf("vllm adapter: not yet implemented") + return fmt.Errorf("vllm adapter: experimental adapter disabled until streaming execution is implemented") } func (v *Vllm) fetchTargets() []string { diff --git a/apps/node/internal/adapters/vllm/vllm_test.go b/apps/node/internal/adapters/vllm/vllm_test.go index 4ecb504..fc64e2e 100644 --- a/apps/node/internal/adapters/vllm/vllm_test.go +++ b/apps/node/internal/adapters/vllm/vllm_test.go @@ -9,6 +9,7 @@ import ( "go.uber.org/zap" + "iop/apps/node/internal/runtime" "iop/packages/go/config" ) @@ -30,3 +31,17 @@ func TestVllmCapabilitiesQueryModels(t *testing.T) { t.Fatalf("targets: got %q", got) } } + +func TestVllmExecuteDisabled(t *testing.T) { + adapter := New(config.VllmConf{Endpoint: "http://localhost:8000"}, zap.NewNop()) + err := adapter.Execute(context.Background(), runtime.ExecutionSpec{ + RunID: "run-1", + Target: "model-a", + }, nil) + if err == nil { + t.Fatal("expected disabled execution error") + } + if !strings.Contains(err.Error(), "experimental adapter disabled") { + t.Fatalf("expected experimental disabled error, got %v", err) + } +} diff --git a/apps/node/internal/router/router.go b/apps/node/internal/router/router.go index b5e8985..5c51a7e 100644 --- a/apps/node/internal/router/router.go +++ b/apps/node/internal/router/router.go @@ -23,10 +23,7 @@ func New(reg *adapters.Registry, logger *zap.Logger) runtime.Router { func (r *defaultRouter) Resolve(_ context.Context, req runtime.RunRequest) (runtime.ExecutionSpec, error) { adapterName := req.Adapter if adapterName == "" { - adapterName = r.registry.Default() - } - if adapterName == "" { - return runtime.ExecutionSpec{}, fmt.Errorf("router: no adapters registered") + return runtime.ExecutionSpec{}, fmt.Errorf("router: adapter is required") } if _, ok := r.registry.Get(adapterName); !ok { diff --git a/apps/node/internal/router/router_test.go b/apps/node/internal/router/router_test.go index 8deb9ad..cacfe27 100644 --- a/apps/node/internal/router/router_test.go +++ b/apps/node/internal/router/router_test.go @@ -91,3 +91,22 @@ func TestResolveAdapter_NotFound(t *testing.T) { t.Fatalf("expected 'not found' in error, got %v", err) } } + +func TestResolveAdapter_RequiresExplicitAdapter(t *testing.T) { + reg := adapters.NewRegistry() + reg.Register(&stubAdapter{name: "mock"}) + r := New(reg, zap.NewNop()) + + req := runtime.RunRequest{ + RunID: "run-1", + Target: "mock-echo", + } + + _, _, err := r.ResolveAdapter(context.Background(), req) + if err == nil { + t.Fatal("expected error for empty adapter") + } + if !strings.Contains(err.Error(), "adapter is required") { + t.Fatalf("expected adapter required error, got %v", err) + } +}