feat: field bootstrap test port readiness milestone complete

- Add field bootstrap user test documentation
- Update serving-routing-optimization phase and milestone
- Update edge runtime bootstrap and openai server implementation
- Add edge/node binaries build script updates
- Update config structures and tests
- Update architecture and deploy docs
- Archive agent task logs for completed sub-tasks
This commit is contained in:
toki 2026-05-27 14:32:03 +09:00
parent 8f66b212df
commit 3b9f37076a
23 changed files with 2052 additions and 230 deletions

View file

@ -240,8 +240,8 @@ Portal의 장기 UI 기준은 Flutter 앱이며, 필요한 웹 표면은 Flutter
## 로드맵 ## 로드맵
제품 방향, 단계, 마일스톤, 우선순위의 단일 기준 문서는 `agent-ops/roadmap/ROADMAP.md`다. 제품 방향, 단계, 마일스톤, 우선순위의 단일 기준 문서는 `agent-roadmap/ROADMAP.md`다.
일반 작업에서 AI가 읽어야 하는 현재 작업 기준은 `agent-ops/roadmap/current.md`가 가리키는 기본 마일스톤 또는 요청에 맞는 활성 마일스톤 문서다. 일반 작업에서 AI가 읽어야 하는 현재 작업 기준은 `agent-roadmap/current.md`가 가리키는 기본 마일스톤 또는 요청에 맞는 활성 마일스톤 문서다.
로드맵의 큰 축은 Edge-Node 실행 기반, Edge input surface, CLI Automation runtime, remote terminal bridge, agent bootstrap/OTO enrollment, 모델 서빙과 부하 라우팅, RAG/web search/MCP/tool policy/검증 최적화, Control Plane/Portal, policy/history/audit, multi-edge operations로 관리한다. 로드맵의 큰 축은 Edge-Node 실행 기반, Edge input surface, CLI Automation runtime, remote terminal bridge, agent bootstrap/OTO enrollment, 모델 서빙과 부하 라우팅, RAG/web search/MCP/tool policy/검증 최적화, Control Plane/Portal, policy/history/audit, multi-edge operations로 관리한다.

View file

@ -7,7 +7,7 @@
## 활성 Milestone ## 활성 Milestone
- [진행중] Field Bootstrap UX와 테스트 포트 정비 - [검토중] Field Bootstrap UX와 테스트 포트 정비
- Phase: `agent-roadmap/phase/serving-routing-optimization/PHASE.md` - Phase: `agent-roadmap/phase/serving-routing-optimization/PHASE.md`
- 경로: `agent-roadmap/phase/serving-routing-optimization/milestones/field-bootstrap-test-port-readiness.md` - 경로: `agent-roadmap/phase/serving-routing-optimization/milestones/field-bootstrap-test-port-readiness.md`

View file

@ -23,7 +23,7 @@ Edge OpenAI-compatible API에서 Node의 Ollama adapter까지 이어지는 단
- 경로: `agent-roadmap/archive/phase/serving-routing-optimization/milestones/ollama-field-validation-follow-up.md` - 경로: `agent-roadmap/archive/phase/serving-routing-optimization/milestones/ollama-field-validation-follow-up.md`
- 요약: Apple Silicon Node와 실제 Ollama endpoint를 split-host 환경에서 검증했고, 모델 조회, non-streaming/streaming chat, reasoning/content 분리, option/API passthrough를 확인했다. `edge serve` lifecycle 보완은 후속 Milestone으로 분리했다. - 요약: Apple Silicon Node와 실제 Ollama endpoint를 split-host 환경에서 검증했고, 모델 조회, non-streaming/streaming chat, reasoning/content 분리, option/API passthrough를 확인했다. `edge serve` lifecycle 보완은 후속 Milestone으로 분리했다.
- [진행중] Field Bootstrap UX와 테스트 포트 정비 - [검토중] Field Bootstrap UX와 테스트 포트 정비
- 경로: `agent-roadmap/phase/serving-routing-optimization/milestones/field-bootstrap-test-port-readiness.md` - 경로: `agent-roadmap/phase/serving-routing-optimization/milestones/field-bootstrap-test-port-readiness.md`
- 요약: code-server field 테스트 포트와 Node artifact/bootstrap 경로를 정리해, 사용자가 Jenkins agent처럼 한 줄 명령으로 Node 다운로드와 Edge 접속을 끝낼 수 있게 만든다. - 요약: code-server field 테스트 포트와 Node artifact/bootstrap 경로를 정리해, 사용자가 Jenkins agent처럼 한 줄 명령으로 Node 다운로드와 Edge 접속을 끝낼 수 있게 만든다.

View file

@ -12,7 +12,7 @@ code-server 컨테이너는 외부 테스트용 web/bootstrap, Node transport, O
## 상태 ## 상태
[진행중] [검토중]
## 승격 조건 ## 승격 조건
@ -39,21 +39,21 @@ field 테스트를 매번 수동 터널과 임시 명령 조합으로 만들지
- [x] [compose-ports] code-server compose가 field 테스트용 web/bootstrap, Node transport, OTO/specialized agent 포트를 외부에 노출한다. 검증: `docker compose config`에서 `18080`, `18081`, `19090`, `19190` 계열 포트 매핑이 확인된다. - [x] [compose-ports] code-server compose가 field 테스트용 web/bootstrap, Node transport, OTO/specialized agent 포트를 외부에 노출한다. 검증: `docker compose config`에서 `18080`, `18081`, `19090`, `19190` 계열 포트 매핑이 확인된다.
- [x] [artifact-layout] Node field build 산출물의 고정 위치와 URL 규칙이 정리되어 있다. 검증: Apple Silicon용 `iop-node`를 정해진 URL에서 다운로드할 수 있다. - [x] [artifact-layout] Node field build 산출물의 고정 위치와 URL 규칙이 정리되어 있다. 검증: Apple Silicon용 `iop-node`를 정해진 URL에서 다운로드할 수 있다.
- [ ] [one-line-node] 사용자가 한 줄 명령으로 Apple Silicon Node를 다운로드하고 Edge에 접속할 수 있다. 검증: Mac 테스트 환경에서 command 한 줄 실행 후 Edge에서 node registration을 확인한다. - [x] [one-line-node] 사용자가 한 줄 명령으로 Apple Silicon Node를 다운로드하고 Edge에 접속할 수 있다. 검증: Mac 테스트 환경에서 command 한 줄 실행 후 Edge에서 node registration을 확인한다.
- [ ] [serve-lifecycle] `edge serve`가 field bootstrap에 사용할 node/openai listener를 안정적으로 유지한다. 검증: `edge serve --config <field-config>` 실행 후 Node registration과 `/v1/models` 호출이 통과한다. - [x] [serve-lifecycle] `edge serve`가 field bootstrap에 사용할 node/openai listener를 안정적으로 유지한다. 검증: `edge serve --config <field-config>` 실행 후 Node registration과 `/v1/models` 호출이 통과한다.
- [x] [runbook] field bootstrap runbook에 직접 포트 방식과 터널 fallback 방식이 구분되어 있다. - [x] [runbook] field bootstrap runbook에 직접 포트 방식과 터널 fallback 방식이 구분되어 있다.
- [x] [llm-test-runbook] LLM/작업자가 사용자 직접 테스트를 준비하고 실패 지점을 분리할 수 있는 상세 runbook이 문서화되어 있다. 검증: `docs/deploy-dev.md`에 포트 확인, Edge config, artifact 생성, HTTP 노출, Mac one-line bootstrap, OpenAI-compatible 확인, 실패 시 확인 순서가 있다. - [x] [llm-test-runbook] LLM/작업자가 사용자 직접 테스트를 준비하고 실패 지점을 분리할 수 있는 상세 runbook이 문서화되어 있다. 검증: `docs/deploy-dev.md`에 포트 확인, Edge config, artifact 생성, HTTP 노출, Mac one-line bootstrap, OpenAI-compatible 확인, 실패 시 확인 순서가 있다.
- [x] [user-test-guide] 사용자가 field bootstrap을 직접 테스트할 수 있는 짧은 가이드가 분리되어 있다. 검증: `docs/field-bootstrap-user-test.md`에 Mac 준비, one-line bootstrap, 호출 확인, 문제 발생 시 공유할 출력이 정리되어 있다. - [x] [user-test-guide] 사용자가 field bootstrap을 직접 테스트할 수 있는 짧은 가이드가 분리되어 있다. 검증: `docs/field-bootstrap-user-test.md`에 Mac 준비, one-line bootstrap, 호출 확인, 문제 발생 시 공유할 출력이 정리되어 있다.
## 완료 리뷰 ## 완료 리뷰
- 상태: 없음 - 상태: 요청됨
- 요청일: 없음 - 요청일: 2026-05-27
- 완료 근거: artifact layout, LLM/작업자용 runbook, 사용자용 직접 테스트 가이드는 정리됐으나, one-line bootstrap UX와 field serve 실구동 검증이 아직 충족되지 않았다. - 완료 근거: code-server field 포트, Apple Silicon artifact/bootstrap, Mac one-line Node 연결, Edge node registration, `/v1/models`, OpenAI-compatible Cline streaming delta 호출까지 실테스트로 확인했다.
- 리뷰 필요: - 리뷰 필요:
- [ ] 사용자가 완료 결과를 확인했다 - [x] 사용자가 완료 결과를 확인했다
- [ ] archive 이동을 승인했다 - [ ] archive 이동을 승인했다
- 리뷰 코멘트: 없음 - 리뷰 코멘트: 기능 체크리스트는 충족되어 검토중으로 전환했다. archive 이동 승인만 남았다.
## 범위 제외 ## 범위 제외
@ -76,4 +76,5 @@ field 테스트를 매번 수동 터널과 임시 명령 조합으로 만들지
- 계획 작성: `agent-task/m-field-bootstrap-test-port-readiness/01_serve_lifecycle/PLAN-cloud-G07.md`에서 `edge serve` listener 유지와 field-like Node registration, `/v1/models` 검증을 선행 작업으로 분리했다. - 계획 작성: `agent-task/m-field-bootstrap-test-port-readiness/01_serve_lifecycle/PLAN-cloud-G07.md`에서 `edge serve` listener 유지와 field-like Node registration, `/v1/models` 검증을 선행 작업으로 분리했다.
- 계획 작성: `agent-task/m-field-bootstrap-test-port-readiness/02+01_one_line_node/PLAN-cloud-G07.md`에서 Apple Silicon one-line bootstrap 실기기 등록 검증을 `01_serve_lifecycle` 의존 작업으로 분리했다. - 계획 작성: `agent-task/m-field-bootstrap-test-port-readiness/02+01_one_line_node/PLAN-cloud-G07.md`에서 Apple Silicon one-line bootstrap 실기기 등록 검증을 `01_serve_lifecycle` 의존 작업으로 분리했다.
- 후속 작업: Agent Bootstrap과 OTO 등록, Control Plane 기반 enrollment - 후속 작업: Agent Bootstrap과 OTO 등록, Control Plane 기반 enrollment
- 확인 필요: 재부팅 후 code-server compose 포트 적용 확인, `01_serve_lifecycle` plan 완료, `02+01_one_line_node` plan 완료 - 진행 기록: Mac에서 bootstrap artifact 다운로드와 Node 재접속을 반복 확인했고, Edge 로그에서 `node-silicon-ollama` registration, `/v1/models`, OpenAI-compatible streaming delta, Cline XML tool protocol 통과를 확인했다.
- 확인 필요: archive 이동 승인

View file

@ -0,0 +1,215 @@
<!-- task=m-field-bootstrap-test-port-readiness/01_serve_lifecycle plan=0 tag=TEST -->
# Code Review Reference - TEST
> **[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.
> 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-05-26
task=m-field-bootstrap-test-port-readiness/01_serve_lifecycle, plan=0, tag=TEST
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-cloud-G07.md` -> `code_review_cloud_G07_N.log`, `PLAN-cloud-G07.md` -> `plan_cloud_G07_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-field-bootstrap-test-port-readiness/01_serve_lifecycle/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [TEST-1] Runtime Lifetime | [x] |
| [TEST-2] Field-Like Serve Smoke | [x] |
## 구현 체크리스트
- [x] 현재 `Runtime` lifetime 변경을 유지하거나 보완해 `go test -count=1 ./apps/edge/internal/bootstrap ./apps/edge/internal/input ./apps/edge/internal/openai ./apps/edge/cmd/edge`가 통과한다.
- [x] 임시 field config로 `edge serve`와 `node serve`를 실제 프로세스로 실행해 Node registration과 `/v1/models`를 확인한다. 검증: `edge serve --config <field-config>` 실행 후 Node registration과 `/v1/models` 호출이 통과한다.
- [x] `go test ./...`를 실행해 전체 Go 회귀를 확인한다. 캐시 출력은 허용한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G07_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_cloud_G07_M.log`로 아카이브한다.
- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [x] PASS이면 active task 디렉터리 `agent-task/m-field-bootstrap-test-port-readiness/01_serve_lifecycle/`를 `agent-task/archive/YYYY/MM/m-field-bootstrap-test-port-readiness/01_serve_lifecycle/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-field-bootstrap-test-port-readiness/`를 제거하거나, 남은 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`를 남기지 않는다.
## 계획 대비 변경 사항
- 코드 변경 없음. 작업트리에 이미 들어와 있던 `apps/edge/internal/bootstrap/runtime.go`의 lifetime context 분리와 `runtime_test.go`의 `TestRuntimeKeepsListenersAfterStartupContextCancel`을 기준선으로 두고 회귀와 field-like smoke로 검증만 수행했다. 계획 [TEST-1]의 "테스트 실패나 race가 발견되면 보완"이라는 조건부 작업이 발동되지 않아 추가 수정이 필요 없었다.
- TEST-2 검증 스크립트를 계획 명령과 동일한 순서/명령으로 실행하되, registration log 근거를 본 보고서에 남기기 위해 cleanup의 `rm -rf "$tmpdir"`를 임시로 빼고 `grep 'node registered'` 출력을 stdout으로 한 번 더 떴다. 핵심 검증 명령(edge `go run`, node `go run`, `curl /v1/models`)은 계획과 동일하다.
## 주요 설계 결정
- `Runtime`이 `context.Background()` 기반 자체 lifetime context를 소유하고 `Stop()`에서만 cancel하도록 두는 기존 구조를 유지했다. Fx `OnStart` startup context cancel과 listener lifetime을 분리해야 `edge serve`가 field bootstrap이 사용할 transport/openai listener를 안정적으로 유지할 수 있다.
- field-like smoke는 자동 테스트가 아니라 `/tmp` 임시 스크립트 + 실 프로세스 `edge serve`/`node serve` 조합으로 수행한다. Milestone `[serve-lifecycle]` 검증 기준이 단위 회귀가 아니라 실제 `edge serve --config <field-config>` 경로의 Node registration과 `/v1/models` 통과이기 때문이며, 재현용 임시 파일은 repo에 남기지 않는다.
## 리뷰어를 위한 체크포인트
- `Runtime.Start(ctx)`가 startup context를 장기 listener lifetime으로 사용하지 않는지 확인한다.
- `Runtime.Stop()`이 input/server shutdown과 lifetime cancel을 모두 수행하는지 확인한다.
- field-like smoke 출력에 Node registration과 `/v1/models` 응답이 실제로 포함됐는지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
### TEST-1 중간 검증
```bash
$ go test -count=1 ./apps/edge/internal/bootstrap ./apps/edge/internal/input ./apps/edge/internal/openai ./apps/edge/cmd/edge
ok iop/apps/edge/internal/bootstrap 0.008s
ok iop/apps/edge/internal/input 0.004s
ok iop/apps/edge/internal/openai 1.507s
ok iop/apps/edge/cmd/edge 0.006s
```
### TEST-2 중간 검증
```bash
$ tmpdir=$(mktemp -d /tmp/iop-serve-lifecycle.XXXXXX)
$ cat > "$tmpdir/edge.yaml" <<'YAML'
edge:
id: "edge-field-local"
name: "Field Local Edge"
server:
listen: "127.0.0.1:39090"
openai:
enabled: true
listen: "127.0.0.1:38080"
node: "node-field-local"
adapter: "ollama"
target: ""
models:
- "field-smoke-model"
session_id: "openai-field"
timeout_sec: 30
metrics:
port: 0
nodes:
- id: "node-field-local"
alias: "field-local"
token: "field-token"
adapters:
ollama:
enabled: true
base_url: "http://127.0.0.1:11434"
context_size: 4096
YAML
$ cat > "$tmpdir/node.yaml" <<'YAML'
transport:
edge_addr: "127.0.0.1:39090"
token: "field-token"
logging:
level: "error"
pretty: true
metrics:
port: 0
YAML
$ go run ./apps/edge/cmd/edge serve --config "$tmpdir/edge.yaml" > "$tmpdir/edge.log" 2>&1 &
$ # (wait loop: TCP 39090 reachable and curl http://127.0.0.1:38080/healthz succeeds)
$ go run ./apps/node/cmd/node serve --config "$tmpdir/node.yaml" > "$tmpdir/node.log" 2>&1 &
$ # (wait loop: 'node registered' in edge.log AND curl http://127.0.0.1:38080/v1/models succeeds)
=== /v1/models response ===
{"object":"list","data":[{"id":"field-smoke-model","object":"model","created":1779835604,"owned_by":"iop"}]}
=== edge.log (node registration lines) ===
{"level":"info","ts":1779835603.9709444,"caller":"transport/server.go:117","msg":"node connection established"}
{"level":"info","ts":1779835604.5273333,"caller":"transport/server.go:117","msg":"node connection established"}
{"level":"info","ts":1779835604.5283391,"caller":"transport/server.go:192","msg":"node registered","node_id":"node-field-local","alias":"field-local"}
exit_code=0
```
### 최종 검증
```bash
$ go test ./...
ok iop/apps/control-plane/cmd/control-plane (cached)
ok iop/apps/control-plane/internal/wire (cached)
ok iop/apps/edge/cmd/edge (cached)
ok iop/apps/edge/internal/bootstrap (cached)
ok iop/apps/edge/internal/events (cached)
ok iop/apps/edge/internal/input (cached)
ok iop/apps/edge/internal/input/a2a (cached)
ok iop/apps/edge/internal/node (cached)
ok iop/apps/edge/internal/openai (cached)
ok iop/apps/edge/internal/opsconsole (cached)
ok iop/apps/edge/internal/service (cached)
ok iop/apps/edge/internal/transport (cached)
ok iop/apps/node/cmd/node (cached)
ok iop/apps/node/internal/adapters (cached)
ok iop/apps/node/internal/adapters/cli 41.475s
? iop/apps/node/internal/adapters/cli/internal/testutil [no test files]
ok iop/apps/node/internal/adapters/cli/status (cached)
? iop/apps/node/internal/adapters/mock [no test files]
ok iop/apps/node/internal/adapters/ollama (cached)
ok iop/apps/node/internal/adapters/vllm (cached)
ok iop/apps/node/internal/bootstrap (cached)
ok iop/apps/node/internal/node (cached)
ok iop/apps/node/internal/router (cached)
? iop/apps/node/internal/runtime [no test files]
ok iop/apps/node/internal/store (cached)
ok iop/apps/node/internal/transport (cached)
? iop/apps/worker/cmd/worker [no test files]
? iop/packages/auth [no test files]
ok iop/packages/config (cached)
? iop/packages/events [no test files]
ok iop/packages/hostsetup (cached)
? iop/packages/jobs [no test files]
? iop/packages/metadata [no test files]
? iop/packages/observability [no test files]
? iop/packages/policy [no test files]
? iop/packages/version [no test files]
? iop/proto/gen/iop [no test files]
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.
## 코드리뷰 결과
종합 판정: PASS
차원별 평가:
- Correctness: Pass
- Completeness: Pass
- Test coverage: Pass
- API contract: Pass
- Code quality: Pass
- Plan deviation: Pass
- Verification trust: Pass
발견된 문제: 없음
다음 단계: PASS이므로 `complete.log`를 작성하고 active task 디렉터리를 archive로 이동한다.

View file

@ -0,0 +1,35 @@
# Complete - m-field-bootstrap-test-port-readiness/01_serve_lifecycle
## 완료 일시
2026-05-27
## 요약
Field serve lifecycle 검증 task를 1회 리뷰 루프로 완료했으며 최종 판정은 PASS다.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_cloud_G07_0.log` | `code_review_cloud_G07_0.log` | PASS | Runtime listener lifetime 회귀와 field-like serve smoke가 통과했다. |
## 구현/정리 내용
- `Runtime.Start(ctx)`가 Fx startup context를 장기 listener lifetime으로 사용하지 않고 runtime-owned lifetime context를 사용함을 확인했다.
- startup context cancel 뒤 Edge TCP listener와 OpenAI `/healthz` listener가 유지되는 회귀 테스트를 확인했다.
- 실제 `edge serve`와 `node serve` 조합으로 Node registration 및 `/v1/models` 응답을 확인했다.
## 최종 검증
- `go test -count=1 ./apps/edge/internal/bootstrap ./apps/edge/internal/input ./apps/edge/internal/openai ./apps/edge/cmd/edge` - PASS; 4개 대상 패키지가 모두 `ok`로 종료했다.
- Field-like smoke (`go run ./apps/edge/cmd/edge serve --config <field-config>` + `go run ./apps/node/cmd/node serve --config <node-config>` + `/v1/models`) - PASS; `node registered` 로그와 `field-smoke-model` 응답을 확인했다.
- `go test ./...` - PASS; 전체 Go 패키지가 `ok` 또는 `[no test files]`로 종료했다.
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -0,0 +1,239 @@
<!-- task=m-field-bootstrap-test-port-readiness/01_serve_lifecycle plan=0 tag=TEST -->
# Field Serve Lifecycle Plan - TEST
## 이 파일을 읽는 구현 에이전트에게
구현을 마친 뒤 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 변경 내용과 검증 출력으로 채워야 한다. 검증은 계획에 적힌 명령을 실행한 실제 stdout/stderr로 기록하고, active 파일은 그대로 둔 채 리뷰 준비 완료를 보고한다. 최종 판정, 로그 rename, `complete.log`, archive 이동은 code-review-skill 전용이다.
## 배경
현재 Milestone의 `[serve-lifecycle]`는 `edge serve --config <field-config>`가 field bootstrap용 node/openai listener를 계속 유지하고, Node registration과 `/v1/models`가 통과해야 완료된다. 작업트리에는 이미 `apps/edge/internal/bootstrap.Runtime`이 Fx startup context와 runtime lifetime을 분리하는 변경과 회귀 테스트가 들어와 있다. 이 계획은 그 변경을 검증 가능한 상태로 마무리하고 field-like 로컬 smoke로 실제 serve 경로를 확인한다.
## 분석 결과
### 읽은 파일
- `agent-ops/roadmap/current.md`
- `agent-ops/roadmap/phase/serving-routing-optimization/PHASE.md`
- `agent-ops/roadmap/phase/serving-routing-optimization/milestones/field-bootstrap-test-port-readiness.md`
- `agent-task/m-field-bootstrap-test-port-readiness/reboot-handoff.md`
- `docs/deploy-dev.md`
- `bin/edge.sh`
- `bin/node.sh`
- `Makefile`
- `apps/edge/cmd/edge/main.go`
- `apps/edge/cmd/edge/main_test.go`
- `apps/edge/internal/bootstrap/runtime.go`
- `apps/edge/internal/bootstrap/runtime_test.go`
- `apps/edge/internal/input/manager.go`
- `apps/edge/internal/input/manager_test.go`
- `apps/edge/internal/openai/server.go`
- `apps/edge/internal/openai/server_test.go`
- `apps/edge/internal/transport/server.go`
- `packages/config/config.go`
- `configs/edge.yaml`
### 테스트 커버리지 공백
- Runtime listener lifetime: `apps/edge/internal/bootstrap/runtime_test.go`의 `TestRuntimeKeepsListenersAfterStartupContextCancel`가 startup context cancel 뒤 Edge TCP와 OpenAI health listener 유지를 커버한다.
- Field serve integration: 실제 `go run ./apps/edge/cmd/edge serve --config <field-config>`와 `go run ./apps/node/cmd/node serve --config <field-node-config>` 조합의 Node registration, `/v1/models` 호출은 아직 자동 테스트로 커버되지 않는다. 이 계획의 field-like smoke가 그 공백을 보완한다.
- External code-server port mapping: remote compose recreate 여부와 외부 host port 적용은 이 계획의 코드 검증 범위가 아니다.
### 심볼 참조
- 이름 변경 또는 제거 없음.
### 분할 판단
- 분할 판단 정책을 계획 파일 선택 전에 평가했다.
- 공유 task group은 `m-field-bootstrap-test-port-readiness`다.
- `01_serve_lifecycle`는 Edge runtime lifetime과 field-like local serve smoke를 선행으로 완료한다.
- `02+01_one_line_node`는 `01_serve_lifecycle`의 `complete.log` 이후 Apple Silicon one-line bootstrap 검증을 수행한다.
- Edge process lifetime과 Mac bootstrap은 실행 환경, 실패 원인, 검증 출력이 달라 분리 대상이다.
### 범위 결정 근거
- 이 계획은 `[serve-lifecycle]`만 소유한다.
- `bin/build/field-binaries.sh`의 artifact/bootstrap 생성은 `02+01_one_line_node`가 소유한다.
- Control Plane enrollment, OTO agent 구현, cloud fallback, provider 확장은 Milestone 범위 제외라 다루지 않는다.
- remote `code-server` 컨테이너 재시작이나 compose recreate는 사용자가 명시하기 전 실행하지 않는다.
### 빌드 등급
- build=`cloud-G07`, review=`cloud-G07`: shell/CLI workflow, 장기 실행 프로세스, listener 상태, field-like integration 검증이 핵심인 terminal-agent 작업이다.
## 구현 체크리스트
- [ ] 현재 `Runtime` lifetime 변경을 유지하거나 보완해 `go test -count=1 ./apps/edge/internal/bootstrap ./apps/edge/internal/input ./apps/edge/internal/openai ./apps/edge/cmd/edge`가 통과한다.
- [ ] 임시 field config로 `edge serve`와 `node serve`를 실제 프로세스로 실행해 Node registration과 `/v1/models`를 확인한다. 검증: `edge serve --config <field-config>` 실행 후 Node registration과 `/v1/models` 호출이 통과한다.
- [ ] `go test ./...`를 실행해 전체 Go 회귀를 확인한다. 캐시 출력은 허용한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## [TEST-1] Runtime Lifetime
### 문제
`edge serve`는 Fx `OnStart`의 startup context가 반환 후 취소될 수 있는데, listener lifetime을 그 context에 묶으면 node/openai listener가 내려갈 수 있다. 현재 작업트리의 [runtime.go](/config/workspace/iop/apps/edge/internal/bootstrap/runtime.go:76)는 별도 lifetime context를 만들고 [runtime_test.go](/config/workspace/iop/apps/edge/internal/bootstrap/runtime_test.go:80)는 startup context cancel 뒤 listener 유지를 검증한다.
### 해결 방법
현재 변경을 기준선으로 삼되, 테스트 실패나 race가 발견되면 `Runtime`이 자체 cancel을 소유하고 `Stop()`에서만 cancel하도록 보완한다.
현재 기준:
```go
// apps/edge/internal/bootstrap/runtime.go:76
func (r *Runtime) Start(ctx context.Context) error {
if err := ctx.Err(); err != nil {
return err
}
r.wireHandlers()
lifetimeCtx := r.newLifetimeContext()
if err := r.Server.Start(lifetimeCtx); err != nil {
r.cancelLifetime()
return err
}
if err := ctx.Err(); err != nil {
r.cancelLifetime()
_ = r.Server.Stop()
return err
}
if err := r.Input.Start(lifetimeCtx); err != nil {
r.cancelLifetime()
_ = r.Server.Stop()
return err
}
r.startMetrics()
return nil
}
```
### 수정 파일 및 체크리스트
- [ ] `apps/edge/internal/bootstrap/runtime.go`: runtime lifetime context가 startup context cancellation에 묶이지 않는지 확인 또는 보완한다.
- [ ] `apps/edge/internal/bootstrap/runtime_test.go`: startup context cancel 뒤 Edge TCP와 OpenAI health listener가 유지되는 회귀 테스트를 유지한다.
### 테스트 작성
추가 테스트는 기본적으로 작성하지 않는다. 기존 `TestRuntimeKeepsListenersAfterStartupContextCancel`가 직접 회귀를 커버하며, 실패할 때만 같은 파일에 필요한 edge case를 추가한다.
### 중간 검증
```bash
go test -count=1 ./apps/edge/internal/bootstrap ./apps/edge/internal/input ./apps/edge/internal/openai ./apps/edge/cmd/edge
```
기대 결과: 모든 패키지가 `ok`로 종료한다.
## [TEST-2] Field-Like Serve Smoke
### 문제
Milestone의 `[serve-lifecycle]` 검증은 단위 테스트가 아니라 실제 field config에서 Node registration과 `/v1/models`가 통과하는지다. [docs/deploy-dev.md](/config/workspace/iop/docs/deploy-dev.md:187)는 실제 field 절차를 설명하지만, 현재 로컬 검증 출력은 아직 `CODE_REVIEW`에 남아 있지 않다.
### 해결 방법
임시 포트를 쓰는 edge/node config를 `/tmp`에 만들고, `go run`으로 `edge serve`와 `node serve`를 동시에 띄운다. edge 로그에서 registration을 확인하고 OpenAI-compatible `/v1/models`를 호출한다.
검증 스크립트는 repo에 남기지 말고 `/tmp`에서만 실행한다.
### 수정 파일 및 체크리스트
- [ ] 코드 수정이 필요하면 `apps/edge/internal/bootstrap/runtime.go` 또는 관련 테스트에만 최소 수정한다.
- [ ] 임시 config와 프로세스 제어 스크립트는 `/tmp`에 만들고 repo에 남기지 않는다.
- [ ] `CODE_REVIEW-cloud-G07.md`에 edge/node 실행 명령과 실제 출력, registration 근거, `/v1/models` 응답을 붙인다.
### 테스트 작성
자동 테스트 파일은 작성하지 않는다. 이 항목은 실제 프로세스 smoke가 목적이며, 단위 회귀는 `TEST-1`에서 커버한다.
### 중간 검증
```bash
tmpdir=$(mktemp -d /tmp/iop-serve-lifecycle.XXXXXX)
cat > "$tmpdir/edge.yaml" <<'YAML'
edge:
id: "edge-field-local"
name: "Field Local Edge"
server:
listen: "127.0.0.1:39090"
openai:
enabled: true
listen: "127.0.0.1:38080"
node: "node-field-local"
adapter: "ollama"
target: ""
models:
- "field-smoke-model"
session_id: "openai-field"
timeout_sec: 30
metrics:
port: 0
nodes:
- id: "node-field-local"
alias: "field-local"
token: "field-token"
adapters:
ollama:
enabled: true
base_url: "http://127.0.0.1:11434"
context_size: 4096
YAML
cat > "$tmpdir/node.yaml" <<'YAML'
transport:
edge_addr: "127.0.0.1:39090"
token: "field-token"
logging:
level: "error"
pretty: true
metrics:
port: 0
YAML
go run ./apps/edge/cmd/edge serve --config "$tmpdir/edge.yaml" > "$tmpdir/edge.log" 2>&1 &
edge_pid=$!
for _ in $(seq 1 30); do
if timeout 1 bash -c 'cat < /dev/null > /dev/tcp/127.0.0.1/39090' 2>/dev/null && curl -fsS http://127.0.0.1:38080/healthz >/dev/null; then
break
fi
sleep 1
done
go run ./apps/node/cmd/node serve --config "$tmpdir/node.yaml" > "$tmpdir/node.log" 2>&1 &
node_pid=$!
cleanup() {
kill "$node_pid" "$edge_pid" 2>/dev/null || true
wait "$node_pid" "$edge_pid" 2>/dev/null || true
rm -rf "$tmpdir"
}
trap cleanup EXIT
for _ in $(seq 1 30); do
if grep -q 'node registered' "$tmpdir/edge.log" && curl -fsS http://127.0.0.1:38080/v1/models >/tmp/iop-models.json; then
cat /tmp/iop-models.json
exit 0
fi
sleep 1
done
cat "$tmpdir/edge.log"
cat "$tmpdir/node.log"
exit 1
```
기대 결과: 명령이 `0`으로 종료하고 `/v1/models` 응답에 `field-smoke-model`이 포함된다.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `apps/edge/internal/bootstrap/runtime.go` | TEST-1 |
| `apps/edge/internal/bootstrap/runtime_test.go` | TEST-1 |
| `/tmp/iop-serve-lifecycle.*` | TEST-2 검증 전용, repo에 남기지 않음 |
## 최종 검증
```bash
go test ./...
```
기대 결과: 모든 Go 패키지가 `ok` 또는 `[no test files]`로 종료한다. 캐시 출력은 허용한다.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,371 @@
<!-- task=m-field-bootstrap-test-port-readiness/02+01_one_line_node plan=0 tag=TEST -->
# Code Review Reference - TEST
> **[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.
> 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-05-26
task=m-field-bootstrap-test-port-readiness/02+01_one_line_node, plan=0, tag=TEST
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-cloud-G07.md` -> `code_review_cloud_G07_N.log`, `PLAN-cloud-G07.md` -> `plan_cloud_G07_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-field-bootstrap-test-port-readiness/02+01_one_line_node/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [TEST-1] Artifact Layout and HTTP Download | [x] |
| [TEST-2] Apple Silicon One-Line Registration | [x] |
## 구현 체크리스트
- [x] `01_serve_lifecycle`의 `complete.log`가 있는지 확인한다. (사용자 확인: `01_serve_lifecycle`은 완료되어 `agent-task/archive/2026/05/m-field-bootstrap-test-port-readiness/`로 이동됨)
- [x] Apple Silicon용 Node artifact와 bootstrap script를 생성하고 layout을 검증한다. 검증: Apple Silicon용 `iop-node`를 정해진 URL에서 다운로드할 수 있다.
- [x] artifact/bootstrap HTTP를 열고 `iop-node`, `SHA256SUMS`, `bootstrap/node-darwin-arm64.sh`가 HTTP로 다운로드되는지 확인한다.
- [x] Apple Silicon Mac에서 one-line command를 실행하고 Edge에서 node registration을 확인한다. 검증: Mac 테스트 환경에서 command 한 줄 실행 후 Edge에서 node registration을 확인한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G07_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_cloud_G07_M.log`로 아카이브한다.
- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [x] PASS이면 active task 디렉터리 `agent-task/m-field-bootstrap-test-port-readiness/02+01_one_line_node/`를 `agent-task/archive/YYYY/MM/m-field-bootstrap-test-port-readiness/02+01_one_line_node/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-field-bootstrap-test-port-readiness/`를 제거하거나, 남은 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`를 남기지 않는다.
## 계획 대비 변경 사항
- TEST-1 중간 검증 명령에 포함된 `file /tmp/iop-node.download`는 컨테이너에 `file` 패키지가 없어 실행 불가였다. Mach-O 64-bit little-endian magic(`cf fa ed fe`)을 직접 확인하는 `head -c 4 /tmp/iop-node.download | od -An -tx1`로 대체했다. darwin/arm64 cross-build 산출물임을 동일하게 판정한다.
- TEST-2는 같은 Mac이 code-server 호스트이자 테스트 대상이라 `toki-labs.com`이 public IP `115.21.224.82`로 해석되고 macOS가 자기 public DNS로 NAT loopback이 안 된다(`curl: (28) Failed to connect to toki-labs.com port 18080`). `IOP_ARTIFACT_BASE_URL`, `IOP_EDGE_ADDR`, outer `curl ...` URL을 모두 `127.0.0.1`로 override해 실행했다. 다운로드 → checksum → config 생성 → `iop-node serve` → Edge 등록 흐름은 PLAN 명령과 동일하다.
- TEST-2 한 줄 명령의 마지막 단계는 `exec iop-node serve`라 ssh 비대화형 실행이 foreground에서 막힌다. Mac의 `~/iop-run-bootstrap.sh`에 동일한 한 줄을 두고 `ssh -n ... 'nohup ~/iop-run-bootstrap.sh > ~/iop-bootstrap.log 2>&1 < /dev/null & disown'`로 detach만 추가했다. 명령 흐름은 PLAN과 동일하다.
- TEST-2 검증 중 Edge에서 `node connection established` (registration 직전 TCP 진입) 직후 `node registered node_id=node-silicon-ollama alias=silicon-ollama`가 떴고, Mac bootstrap 로그에 `registered with edge`가 동시에 떴다. 등록 확인 후 Mac iop-node, Edge, HTTP 서버, 임시 파일, Mac의 `~/iop-field`/`~/iop-run-bootstrap.sh`/`~/iop-bootstrap.log`까지 정리했다. 사전에 있던 `dist/field/field-20260526T101557Z-*` 산출물은 손대지 않았다.
## 주요 설계 결정
- 본 subtask는 코드/문서 수정 없이 검증만 수행했다. 기존 작업 트리의 `bin/build/field-binaries.sh`(generated bootstrap script: `IOP_*` env override, checksum, quarantine xattr 제거, `node.yaml` 생성, `exec iop-node serve`)와 `docs/deploy-dev.md`(URL 규칙, Field Bootstrap Runbook)로 [TEST-1], [TEST-2] 모두 통과해, PLAN의 "build script 또는 문서가 불일치하면 둘 중 실제 동작 기준으로 최소 수정한다" 조건이 발동하지 않았다.
- Mac 측은 `~/iop-field` 디렉터리만 사용하고 정리 시 모두 제거하는 단순 모델을 유지했다. PLAN의 표현(`exec "$BIN_FILE" serve --config "$CONFIG_FILE"` foreground)을 그대로 두고, ssh detach만 호출 측에서 처리했다. bootstrap script 본문을 lifecycle 관리용으로 분기하지 않았다.
## 리뷰어를 위한 체크포인트
- `01_serve_lifecycle` 완료 뒤 실행했는지 확인한다.
- build output layout과 `docs/deploy-dev.md` URL 규칙이 일치하는지 확인한다.
- Mac one-line command 출력과 Edge registration 근거가 실제 실행 출력인지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
### TEST-1 중간 검증
PLAN 명령은 `file /tmp/iop-node.download` 한 줄을 컨테이너에 없는 `file`로 인해 darwin/arm64 Mach-O magic 확인(`head -c 4 ... | od`)으로 대체. 이외 흐름은 PLAN과 동일.
```bash
$ tmpdir=$(mktemp -d /tmp/iop-field-artifact.XXXXXX)
$ VERSION=field-check APPS=node TARGETS="darwin/arm64" \
ARTIFACT_BASE_URL="http://127.0.0.1:18080" \
BOOTSTRAP_EDGE_ADDR="127.0.0.1:39090" \
BOOTSTRAP_NODE_TOKEN="field-token" \
DIST_DIR="$tmpdir" ./bin/build/field-binaries.sh
building node for darwin/arm64 -> /tmp/iop-field-artifact.mhYxk9/field-check/darwin-arm64/iop-node
field binaries written to /tmp/iop-field-artifact.mhYxk9/field-check
$ python3 -m http.server 18080 --bind 127.0.0.1 -d "$tmpdir/field-check" > "$tmpdir/http.log" 2>&1 &
$ # readiness loop succeeded → HTTP_READY
$ curl -fsS http://127.0.0.1:18080/darwin-arm64/iop-node -o /tmp/iop-node.download
$ ls -la /tmp/iop-node.download
-rw-r--r-- 1 abc abc 18113058 May 27 09:02 /tmp/iop-node.download
$ head -c 4 /tmp/iop-node.download | od -An -tx1
cf fa ed fe # Mach-O 64-bit LE (arm64 darwin)
$ curl -fsS http://127.0.0.1:18080/darwin-arm64/SHA256SUMS
13ca9d0b227449c233f401eb461551a7a4304663d9a3eaf44ae2b64f5ab47dd5 iop-node
$ curl -fsS http://127.0.0.1:18080/bootstrap/node-darwin-arm64.sh | sed -n '1,80p'
#!/usr/bin/env bash
set -euo pipefail
VERSION="field-check"
TARGET="darwin-arm64"
ARTIFACT_BASE_URL="${IOP_ARTIFACT_BASE_URL:-http://127.0.0.1:18080}"
EDGE_ADDR="${IOP_EDGE_ADDR:-127.0.0.1:39090}"
NODE_TOKEN="${IOP_NODE_TOKEN:-field-token}"
INSTALL_DIR="${IOP_HOME:-$HOME/iop-field}"
CONFIG_FILE="${IOP_NODE_CONFIG:-$INSTALL_DIR/node.yaml}"
BIN_FILE="$INSTALL_DIR/iop-node"
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/iop-node-bootstrap.XXXXXX")"
cleanup() {
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
ARTIFACT_BASE_URL="${ARTIFACT_BASE_URL%/}"
echo "[iop-bootstrap] version=$VERSION target=$TARGET"
echo "[iop-bootstrap] install_dir=$INSTALL_DIR"
mkdir -p "$INSTALL_DIR"
curl -fsSL "$ARTIFACT_BASE_URL/$TARGET/iop-node" -o "$TMP_DIR/iop-node"
curl -fsSL "$ARTIFACT_BASE_URL/$TARGET/SHA256SUMS" -o "$TMP_DIR/SHA256SUMS"
if command -v sha256sum >/dev/null 2>&1; then
(cd "$TMP_DIR" && grep -E '[ *]iop-node$' SHA256SUMS | sha256sum -c -)
elif command -v shasum >/dev/null 2>&1; then
(cd "$TMP_DIR" && grep -E '[ *]iop-node$' SHA256SUMS | shasum -a 256 -c -)
else
echo "[iop-bootstrap] warning: no sha256 checker found; skipping checksum verification" >&2
fi
cp "$TMP_DIR/iop-node" "$BIN_FILE"
chmod +x "$BIN_FILE"
if command -v xattr >/dev/null 2>&1; then
xattr -d com.apple.quarantine "$BIN_FILE" 2>/dev/null || true
fi
cat > "$CONFIG_FILE" <<YAML
transport:
edge_addr: "$EDGE_ADDR"
token: "$NODE_TOKEN"
logging:
level: "${IOP_LOG_LEVEL:-info}"
pretty: true
metrics:
port: ${IOP_NODE_METRICS_PORT:-9091}
YAML
echo "[iop-bootstrap] config=$CONFIG_FILE"
echo "[iop-bootstrap] starting node against $EDGE_ADDR"
exec "$BIN_FILE" serve --config "$CONFIG_FILE"
```
기대 결과 충족: 세 URL 모두 2xx, bootstrap script에 `EDGE_ADDR`, `NODE_TOKEN`, `exec "$BIN_FILE" serve --config "$CONFIG_FILE"` 포함.
### TEST-2 중간 검증
PLAN 명령의 `toki-labs.com`을 `127.0.0.1`로 override한 이유와 ssh detach 처리는 `계획 대비 변경 사항` 참조. 절차는 docs/deploy-dev.md Field Bootstrap Runbook 단계대로 수행했다.
준비 단계 (컨테이너):
```bash
$ VERSION=field-bootstrap-manual APPS=node TARGETS="darwin/arm64" \
ARTIFACT_BASE_URL="http://toki-labs.com:18080" \
BOOTSTRAP_EDGE_ADDR="toki-labs.com:19090" \
BOOTSTRAP_NODE_TOKEN="CHANGE_ME_FIELD_TOKEN" \
DIST_DIR=dist/field ./bin/build/field-binaries.sh
building node for darwin/arm64 -> dist/field/field-bootstrap-manual/darwin-arm64/iop-node
field binaries written to dist/field/field-bootstrap-manual
$ test -x dist/field/field-bootstrap-manual/darwin-arm64/iop-node && echo "iop-node ok"
$ test -f dist/field/field-bootstrap-manual/darwin-arm64/SHA256SUMS && echo "SHA256SUMS ok"
$ test -x dist/field/field-bootstrap-manual/bootstrap/node-darwin-arm64.sh && echo "bootstrap script ok"
iop-node ok
SHA256SUMS ok
bootstrap script ok
$ # /tmp/iop-edge-field-bootstrap.yaml은 docs/deploy-dev.md Runbook step 2 그대로 작성
$ go run ./apps/edge/cmd/edge config check --config /tmp/iop-edge-field-bootstrap.yaml
OK /tmp/iop-edge-field-bootstrap.yaml
$ nohup python3 -m http.server 18080 --bind 0.0.0.0 -d dist/field/field-bootstrap-manual > /tmp/iop-field-http.log 2>&1 &
$ nohup go run ./apps/edge/cmd/edge serve --config /tmp/iop-edge-field-bootstrap.yaml > /tmp/iop-edge-field-bootstrap.log 2>&1 &
# Edge log: "edge listening for nodes" addr=0.0.0.0:9090
# Edge log: "openai-compatible server listening" addr=0.0.0.0:8080
$ curl -fsS http://127.0.0.1:8080/v1/models
{"object":"list","data":[{"id":"gemma4:26b","object":"model","created":1779841130,"owned_by":"iop"}]}
$ ssh toki@toki-labs.com 'nc -vz 127.0.0.1 19090'
Connection to 127.0.0.1 port 19090 [tcp/*] succeeded!
```
One-line bootstrap 실행 (Mac, ssh 비대화형 detach):
```bash
$ # ~/iop-run-bootstrap.sh on Mac:
$ # export IOP_ARTIFACT_BASE_URL="http://127.0.0.1:18080"
$ # export IOP_EDGE_ADDR="127.0.0.1:19090"
$ # export IOP_NODE_TOKEN="CHANGE_ME_FIELD_TOKEN"
$ # curl -fsSL http://127.0.0.1:18080/bootstrap/node-darwin-arm64.sh | bash
$ ssh -n toki@toki-labs.com 'nohup ~/iop-run-bootstrap.sh > ~/iop-bootstrap.log 2>&1 < /dev/null & disown'
started_pid=75660
```
Mac bootstrap log (`~/iop-bootstrap.log`):
```text
[iop-bootstrap] version=field-bootstrap-manual target=darwin-arm64
[iop-bootstrap] install_dir=/Users/toki/iop-field
iop-node: OK
[iop-bootstrap] config=/Users/toki/iop-field/node.yaml
[iop-bootstrap] starting node against 127.0.0.1:19090
[Fx] PROVIDE fx.Lifecycle <= go.uber.org/fx.New.func1()
... (fx provides)
[Fx] HOOK OnStart iop/apps/node/internal/bootstrap.Module.func3.1() executing
{
"level": "info",
"ts": 1779841241.040308,
"caller": "transport/client.go:67",
"msg": "registered with edge",
"node_id": "node-silicon-ollama",
"alias": "silicon-ollama"
}
{
"level": "info",
"ts": 1779841241.0436852,
"caller": "store/store.go:62",
"msg": "store ready",
"dsn": "file:iop.db?cache=shared&mode=rwc"
}
[Fx] HOOK OnStart iop/apps/node/internal/bootstrap.Module.func3.1() called by iop/apps/node/internal/bootstrap.Module.func3 ran successfully in 6.0025ms
[Fx] RUNNING
{
"level": "warn",
"ts": 1779841241.0440938,
"caller": "bootstrap/module.go:81",
"msg": "metrics server exited",
"error": "listen tcp :9091: bind: address already in use"
}
```
Mac 산출물:
```text
$ ls -la ~/iop-field/
-rwxr-xr-x 1 toki staff 18113058 May 27 09:20 iop-node
-rw-r--r-- 1 toki staff 139 May 27 09:20 node.yaml
$ cat ~/iop-field/node.yaml
transport:
edge_addr: "127.0.0.1:19090"
token: "CHANGE_ME_FIELD_TOKEN"
logging:
level: "info"
pretty: true
metrics:
port: 9091
$ pgrep -lf iop-node
75663 /Users/toki/iop-field/iop-node serve --config /Users/toki/iop-field/node.yaml
```
Edge log (registration 부분):
```text
{"level":"info","ts":1779841241.0386865,"caller":"transport/server.go:117","msg":"node connection established"}
{"level":"info","ts":1779841241.039098,"caller":"transport/server.go:192","msg":"node registered","node_id":"node-silicon-ollama","alias":"silicon-ollama"}
```
기대 결과 충족: Mac에 `~/iop-field/iop-node`, `~/iop-field/node.yaml` 생성, Edge 로그에 `node registered node_id=node-silicon-ollama alias=silicon-ollama` 표시.
주의: Mac log의 `metrics server exited: listen tcp :9091: bind: address already in use`는 같은 Mac에서 다른 프로세스가 9091을 사용 중이라는 환경 warning이며, registration 자체와 무관하다. 검증 후 Mac iop-node, Edge, HTTP server, `~/iop-field`, `~/iop-run-bootstrap.sh`, `~/iop-bootstrap.log`, `dist/field/field-bootstrap-manual`, `/tmp/iop-edge-field-bootstrap.{yaml,log}`, `/tmp/iop-field-http.log` 모두 정리했다.
### 최종 검증
```bash
$ go test ./...
ok iop/apps/control-plane/cmd/control-plane (cached)
ok iop/apps/control-plane/internal/wire (cached)
ok iop/apps/edge/cmd/edge (cached)
ok iop/apps/edge/internal/bootstrap (cached)
ok iop/apps/edge/internal/events (cached)
ok iop/apps/edge/internal/input (cached)
ok iop/apps/edge/internal/input/a2a (cached)
ok iop/apps/edge/internal/node (cached)
ok iop/apps/edge/internal/openai (cached)
ok iop/apps/edge/internal/opsconsole (cached)
ok iop/apps/edge/internal/service (cached)
ok iop/apps/edge/internal/transport (cached)
ok iop/apps/node/cmd/node (cached)
ok iop/apps/node/internal/adapters (cached)
ok iop/apps/node/internal/adapters/cli (cached)
? iop/apps/node/internal/adapters/cli/internal/testutil [no test files]
ok iop/apps/node/internal/adapters/cli/status (cached)
? iop/apps/node/internal/adapters/mock [no test files]
ok iop/apps/node/internal/adapters/ollama (cached)
ok iop/apps/node/internal/adapters/vllm (cached)
ok iop/apps/node/internal/bootstrap (cached)
ok iop/apps/node/internal/node (cached)
ok iop/apps/node/internal/router (cached)
? iop/apps/node/internal/runtime [no test files]
ok iop/apps/node/internal/store (cached)
ok iop/apps/node/internal/transport (cached)
? iop/apps/worker/cmd/worker [no test files]
? iop/packages/auth [no test files]
ok iop/packages/config (cached)
? iop/packages/events [no test files]
ok iop/packages/hostsetup (cached)
? iop/packages/jobs [no test files]
? iop/packages/metadata [no test files]
? iop/packages/observability [no test files]
? iop/packages/policy [no test files]
? iop/packages/version [no test files]
? iop/proto/gen/iop [no test files]
```
기대 결과 충족: 모든 Go 패키지 `ok` 또는 `[no test files]`.
---
## 코드리뷰 결과
### 종합 판정
PASS
### 차원별 평가
- Correctness: Pass
- Completeness: Pass
- Test coverage: Pass
- API contract: Pass
- Code quality: Pass
- Plan deviation: Pass
- Verification trust: Pass
### 발견된 문제
없음
### 다음 단계
- PASS: active plan/review 파일을 archive log로 이동하고, `complete.log` 작성 후 task directory를 archive 경로로 이동한다.
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.

View file

@ -0,0 +1,36 @@
# Complete - m-field-bootstrap-test-port-readiness/02+01_one_line_node
## 완료 일시
2026-05-27
## 요약
One-line Node bootstrap 검증 task를 1회 리뷰 루프로 완료했으며 최종 판정은 PASS다.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_cloud_G07_0.log` | `code_review_cloud_G07_0.log` | PASS | Apple Silicon용 artifact layout, HTTP download, one-line bootstrap registration evidence, 전체 Go 테스트가 확인됐다. |
## 구현/정리 내용
- `01_serve_lifecycle` 선행 완료 상태를 archive `complete.log`로 확인했다.
- `bin/build/field-binaries.sh`가 Apple Silicon용 `iop-node`, `SHA256SUMS`, `bootstrap/node-darwin-arm64.sh`를 생성하고 문서의 URL 규칙과 일치함을 확인했다.
- Apple Silicon Mac에서 one-line bootstrap 실행 후 Edge `node registered node_id=node-silicon-ollama alias=silicon-ollama` 로그가 남은 것을 확인했다.
- 본 subtask 자체는 코드/문서 추가 수정 없이 검증 기록과 리뷰 판정만 완료했다.
## 최종 검증
- `VERSION=field-check APPS=node TARGETS="darwin/arm64" ARTIFACT_BASE_URL="http://127.0.0.1:18080" BOOTSTRAP_EDGE_ADDR="127.0.0.1:39090" BOOTSTRAP_NODE_TOKEN="field-token" DIST_DIR="$tmpdir" ./bin/build/field-binaries.sh` + local `python3 -m http.server 18080` + `curl` checks - PASS; `iop-node` HTTP download, Mach-O magic `cf fa ed fe`, `SHA256SUMS`, bootstrap script의 `EDGE_ADDR`/`NODE_TOKEN`/`exec "$BIN_FILE" serve --config "$CONFIG_FILE"`를 확인했다.
- Apple Silicon one-line bootstrap field execution - PASS; Mac에 `~/iop-field/iop-node`, `~/iop-field/node.yaml`이 생성되고 Edge 로그에 `node registered node_id=node-silicon-ollama alias=silicon-ollama`가 표시됐다.
- `go test ./...` - PASS; 모든 Go 패키지가 `ok` 또는 `[no test files]`로 종료했다.
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -0,0 +1,174 @@
<!-- task=m-field-bootstrap-test-port-readiness/02+01_one_line_node plan=0 tag=TEST -->
# One-Line Node Bootstrap Plan - TEST
## 이 파일을 읽는 구현 에이전트에게
구현을 마친 뒤 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 변경 내용과 검증 출력으로 채워야 한다. 검증은 계획에 적힌 명령을 실행한 실제 stdout/stderr로 기록하고, active 파일은 그대로 둔 채 리뷰 준비 완료를 보고한다. 최종 판정, 로그 rename, `complete.log`, archive 이동은 code-review-skill 전용이다.
## 배경
현재 Milestone의 `[one-line-node]`는 Apple Silicon Mac에서 한 줄 명령으로 Node binary 다운로드, checksum 확인, config 생성, Edge 접속까지 이어져야 완료된다. 작업트리에는 `bin/build/field-binaries.sh`가 `darwin/arm64` Node artifact와 `bootstrap/node-darwin-arm64.sh`를 생성하는 변경이 이미 들어와 있다. 이 계획은 선행 `01_serve_lifecycle` 완료 뒤 실제 artifact URL과 Apple Silicon 실행 경로를 검증해 one-line UX를 마무리한다.
## 의존 관계 및 구현 순서
- 이 subtask 디렉터리 이름은 `02+01_one_line_node`이며, 같은 task group의 `01_serve_lifecycle`이 먼저 `complete.log`를 만들어야 한다.
- 선행 완료 전에는 Mac one-line bootstrap 검증을 시작하지 않는다.
## 분석 결과
### 읽은 파일
- `agent-ops/roadmap/current.md`
- `agent-ops/roadmap/phase/serving-routing-optimization/PHASE.md`
- `agent-ops/roadmap/phase/serving-routing-optimization/milestones/field-bootstrap-test-port-readiness.md`
- `agent-task/m-field-bootstrap-test-port-readiness/reboot-handoff.md`
- `docs/deploy-dev.md`
- `bin/build/field-binaries.sh`
- `bin/node.sh`
- `Makefile`
- `packages/config/config.go`
- `configs/edge.yaml`
### 테스트 커버리지 공백
- Artifact layout: 임시 `darwin/arm64` build로 `iop-node`, `iop-node.sha256`, `SHA256SUMS`, `manifest.env`, `bootstrap/node-darwin-arm64.sh` 생성이 확인됐다. 별도 자동 테스트는 없다.
- One-line bootstrap: generated shell script가 실제 Apple Silicon Mac에서 실행되어 Edge에 등록되는지는 아직 검증되지 않았다.
- External artifact server: `python3 -m http.server 18080 ...`와 외부 `toki-labs.com:18080` 접근성은 환경 의존이며 자동 테스트가 없다.
### 심볼 참조
- 이름 변경 또는 제거 없음.
### 분할 판단
- 분할 판단 정책을 계획 파일 선택 전에 평가했다.
- 공유 task group은 `m-field-bootstrap-test-port-readiness`다.
- `01_serve_lifecycle`는 Edge listener와 local registration smoke를 선행 검증한다.
- `02+01_one_line_node`는 `01_serve_lifecycle` 완료 뒤 artifact publish와 Apple Silicon one-line execution을 검증한다.
- 이 작업은 remote host, HTTP artifact serving, Mac execution이 필요해 Edge lifecycle 작업과 독립적으로 리뷰 가능한 후속 subtask다.
### 범위 결정 근거
- 이 계획은 `[one-line-node]`만 소유한다.
- `serve-lifecycle` 코드는 선행 subtask가 소유하므로 여기서 수정하지 않는다.
- Control Plane enrollment, 장기 credential 관리, OTO agent 구현은 Milestone 범위 제외라 다루지 않는다.
- `code-server` compose 재시작/recreate는 사용자가 명시하지 않으면 실행하지 않는다. 이미 적용된 포트를 관찰하는 검증은 가능하다.
### 빌드 등급
- build=`cloud-G07`, review=`cloud-G07`: shell script generation, artifact HTTP serving, real Mac process execution, Edge registration 확인이 포함된 terminal-agent 작업이다.
## 구현 체크리스트
- [ ] `01_serve_lifecycle`의 `complete.log`가 있는지 확인한다.
- [ ] Apple Silicon용 Node artifact와 bootstrap script를 생성하고 layout을 검증한다. 검증: Apple Silicon용 `iop-node`를 정해진 URL에서 다운로드할 수 있다.
- [ ] artifact/bootstrap HTTP를 열고 `iop-node`, `SHA256SUMS`, `bootstrap/node-darwin-arm64.sh`가 HTTP로 다운로드되는지 확인한다.
- [ ] Apple Silicon Mac에서 one-line command를 실행하고 Edge에서 node registration을 확인한다. 검증: Mac 테스트 환경에서 command 한 줄 실행 후 Edge에서 node registration을 확인한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## [TEST-1] Artifact Layout and HTTP Download
### 문제
`[artifact-layout]`는 artifact 고정 위치와 URL 규칙이 실제 build output과 HTTP document root에 맞아야 한다. 현재 [field-binaries.sh](/config/workspace/iop/bin/build/field-binaries.sh:83)는 bootstrap script를 만들고, [deploy-dev.md](/config/workspace/iop/docs/deploy-dev.md:157)는 version directory를 document root로 두는 URL 규칙을 설명한다.
### 해결 방법
임시 `DIST_DIR`에 `darwin/arm64` Node artifact를 빌드한 뒤, version directory를 HTTP root로 열어 URL 다운로드를 확인한다. build script 또는 문서가 불일치하면 둘 중 실제 동작 기준으로 최소 수정한다.
현재 기준:
```bash
# docs/deploy-dev.md:146
VERSION=<field-version> \
APPS=node \
TARGETS="darwin/arm64" \
ARTIFACT_BASE_URL="http://toki-labs.com:18080" \
BOOTSTRAP_EDGE_ADDR="toki-labs.com:19090" \
BOOTSTRAP_NODE_TOKEN="CHANGE_ME_FIELD_TOKEN" \
DIST_DIR=dist/field \
./bin/build/field-binaries.sh
```
### 수정 파일 및 체크리스트
- [ ] `bin/build/field-binaries.sh`: generated bootstrap script가 `ARTIFACT_BASE_URL/<target>/iop-node`와 `SHA256SUMS`를 받는지 확인한다.
- [ ] `docs/deploy-dev.md`: documented layout과 actual output이 일치하는지 확인하고, 다르면 문서를 수정한다.
### 테스트 작성
별도 테스트 파일은 작성하지 않는다. 이 항목은 cross-compile artifact와 HTTP download 검증이 목적이며 shell test framework가 없다.
### 중간 검증
```bash
tmpdir=$(mktemp -d /tmp/iop-field-artifact.XXXXXX)
VERSION=field-check APPS=node TARGETS="darwin/arm64" ARTIFACT_BASE_URL="http://127.0.0.1:18080" BOOTSTRAP_EDGE_ADDR="127.0.0.1:39090" BOOTSTRAP_NODE_TOKEN="field-token" DIST_DIR="$tmpdir" ./bin/build/field-binaries.sh
python3 -m http.server 18080 --bind 127.0.0.1 -d "$tmpdir/field-check" > "$tmpdir/http.log" 2>&1 &
http_pid=$!
for _ in $(seq 1 10); do
if curl -fsS http://127.0.0.1:18080/bootstrap/node-darwin-arm64.sh >/dev/null; then
break
fi
sleep 1
done
cleanup() {
kill "$http_pid" 2>/dev/null || true
wait "$http_pid" 2>/dev/null || true
rm -rf "$tmpdir"
}
trap cleanup EXIT
curl -fsS http://127.0.0.1:18080/darwin-arm64/iop-node -o /tmp/iop-node.download
curl -fsS http://127.0.0.1:18080/darwin-arm64/SHA256SUMS
curl -fsS http://127.0.0.1:18080/bootstrap/node-darwin-arm64.sh | sed -n '1,80p'
rm -f /tmp/iop-node.download
```
기대 결과: 세 URL이 모두 `2xx`로 응답하고 bootstrap script에 `EDGE_ADDR`, `NODE_TOKEN`, `exec "$BIN_FILE" serve --config "$CONFIG_FILE"`가 포함된다.
## [TEST-2] Apple Silicon One-Line Registration
### 문제
`[one-line-node]`는 generated script가 실제 Apple Silicon Mac에서 실행되어 Edge에 등록되는지 확인해야 완료된다. [docs/deploy-dev.md](/config/workspace/iop/docs/deploy-dev.md:175)의 한 줄 명령은 아직 실제 registration evidence가 없다.
### 해결 방법
선행 `01_serve_lifecycle` 완료 후 field Edge를 실행하고 artifact HTTP를 연다. Apple Silicon Mac에서 환경변수 override를 포함한 한 줄 명령을 실행해 token/config/binary 흐름을 확인하고, Edge 로그 또는 console에서 node registration을 증거로 남긴다.
### 수정 파일 및 체크리스트
- [ ] Mac 실행에서 checksum, quarantine xattr, config 생성, `iop-node serve` 중 실패가 있으면 `bin/build/field-binaries.sh`의 generated script를 최소 수정한다.
- [ ] 문서 명령이 실제 성공 명령과 다르면 `docs/deploy-dev.md`를 수정한다.
- [ ] Edge registration 근거와 Mac command stdout/stderr를 `CODE_REVIEW-cloud-G07.md`에 붙인다.
### 테스트 작성
별도 자동 테스트는 작성하지 않는다. 이 항목은 Mac 실기기와 Edge transport 연결이 완료 조건이다.
### 중간 검증
```bash
IOP_ARTIFACT_BASE_URL="http://toki-labs.com:18080" IOP_EDGE_ADDR="toki-labs.com:19090" IOP_NODE_TOKEN="CHANGE_ME_FIELD_TOKEN" curl -fsSL http://toki-labs.com:18080/bootstrap/node-darwin-arm64.sh | bash
```
기대 결과: Apple Silicon Mac에서 `~/iop-field/iop-node`와 `~/iop-field/node.yaml`이 생성되고, Edge 로그 또는 console에서 해당 node registration이 확인된다.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `bin/build/field-binaries.sh` | TEST-1, TEST-2 필요 시 |
| `docs/deploy-dev.md` | TEST-1, TEST-2 필요 시 |
| `/tmp/iop-field-artifact.*` | TEST-1 검증 전용, repo에 남기지 않음 |
## 최종 검증
```bash
go test ./...
```
기대 결과: 모든 Go 패키지가 `ok` 또는 `[no test files]`로 종료한다. 캐시 출력은 허용한다.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -1,187 +0,0 @@
# Reboot Handoff: Field Bootstrap UX와 테스트 포트 정비
## 주입용 요약
다음 세션은 `/config/workspace/iop`에서 `AGENTS.md`의 필수 규칙을 먼저 읽고, 이어서 이 파일을 읽은 뒤 작업을 계속한다.
현재 활성 Milestone은 `agent-ops/roadmap/phase/serving-routing-optimization/milestones/field-bootstrap-test-port-readiness.md`다.
목표는 code-server field 테스트 환경을 Jenkins agent 연결처럼 만들고, 사용자가 Apple Silicon Mac에서 한 줄 bootstrap 명령으로 Node 다운로드, config 생성, Edge 접속까지 끝내게 하는 것이다.
## 현재 상태
- 이전 Milestone `Ollama 실테스트와 후속 안정화``[완료]` 처리 후 `agent-ops/roadmap/archive/phase/serving-routing-optimization/milestones/ollama-field-validation-follow-up.md`로 이동했다.
- `current.md`는 새 활성 Milestone `Field Bootstrap UX와 테스트 포트 정비`를 가리킨다.
- `agent-ops/rules/project/rules.md`는 field 테스트 환경과 one-line bootstrap 작업을 `docs/deploy-dev.md`와 활성 Milestone으로 라우팅하도록 갱신됐다.
- `docs/deploy-dev.md`에는 `Field 테스트 환경 라우팅` 섹션이 추가됐다.
- 원격 `ssh toki@toki-labs.com``~/docker/services/code-server/compose/docker-compose.yml`에는 field 테스트 포트 매핑이 반영됐다.
- 재부팅/재생성 타이밍은 사용자가 잡기로 했고, 작업자는 사용자가 명시하기 전 code-server 컨테이너를 재시작하지 않는다.
## 원격 code-server 포트 의도
원격 compose 원본:
```text
ssh toki@toki-labs.com
~/docker/services/code-server/compose/docker-compose.yml
```
기준 포트:
```text
13000-13099 -> 3000-3099 web/dev app preview range
18080 -> 18080 IOP field artifact/bootstrap HTTP
18081 -> 8080 Edge OpenAI-compatible HTTP
19080 -> 19080 Portal/Control Plane wire test endpoint
19090 -> 9090 Edge-Node TCP transport
19092 -> 9092 Edge metrics
19190 -> 9190 OTO/specialized agent field transport reserve
```
검증 완료:
```bash
ssh toki@toki-labs.com 'cd ~/docker/services/code-server/compose && /usr/local/bin/docker compose config --quiet'
```
주의: Docker port mapping은 단순 프로세스 재시작이 아니라 컨테이너 recreate가 필요할 수 있다. 재부팅 뒤에도 `docker ps`에 새 포트가 보이지 않으면 사용자가 정한 타이밍에 compose recreate가 필요하다.
## 재부팅 후 첫 확인
원격에서 code-server 컨테이너 포트가 실제 적용됐는지 확인한다.
```bash
ssh toki@toki-labs.com '/usr/local/bin/docker ps --format "{{.Names}} {{.Ports}}" | grep "^code-server "'
```
기대: `18080`, `18081`, `19090`, `19092`, `19190``code-server` 포트 목록에 보인다.
컨테이너 내부에서 포트가 열리는지는 서비스 기동 후 확인한다.
```bash
curl -fsS http://127.0.0.1:18080/healthz
curl -fsS http://127.0.0.1:18081/healthz
```
`18080`은 아직 artifact/bootstrap HTTP 서버를 직접 띄워야 응답한다.
## 바로 이어갈 작업 순서
1. `edge serve` lifecycle 문제를 먼저 확인/수정한다.
- 관찰된 현상: `edge serve`로 실행하면 node/openai listener가 내려가고 metrics만 남았다.
- 추정 원인: Fx `OnStart` startup context를 long-running server lifetime context로 넘겨, `OnStart` 반환 뒤 context cancellation이 listener shutdown으로 이어지는 구조.
- 후보 수정: `apps/edge/internal/bootstrap.Runtime`이 자체 runtime context/cancel을 소유하고 `Stop()`에서 cancel하도록 한다. `Start(ctx)``ctx`는 startup timeout 용도로만 사용한다.
- 검증: `edge serve --config <field-config>` 실행 후 `9090`, `8080`, `9092` listener가 유지되고 Node registration과 `/v1/models`가 통과한다.
2. field Edge config를 준비한다.
- Edge listen은 컨테이너 내부 포트 기준으로 둔다.
- Node는 Mac에서 실행되므로 Ollama base URL은 Node 관점의 `http://127.0.0.1:11434`가 맞다.
```yaml
edge:
id: "edge-field-bootstrap"
name: "Field Bootstrap Edge"
server:
listen: "0.0.0.0:9090"
openai:
enabled: true
listen: "0.0.0.0:8080"
node: "node-silicon-ollama"
adapter: "ollama"
target: ""
models:
- "gemma4:26b"
session_id: "openai-field"
timeout_sec: 300
metrics:
port: 9092
nodes:
- id: "node-silicon-ollama"
alias: "silicon-ollama"
token: "CHANGE_ME_FIELD_TOKEN"
adapters:
ollama:
enabled: true
base_url: "http://127.0.0.1:11434"
context_size: 262144
```
3. Apple Silicon Node artifact layout를 고정한다.
- Apple Silicon은 `darwin/arm64`다.
- `linux/arm64`는 Mac 실행 대상이 아니다.
- 기존 build entrypoint:
```bash
VERSION=<field-version> APPS=node TARGETS="darwin/arm64" DIST_DIR=dist/field ./bin/build/field-binaries.sh
```
4. artifact/bootstrap HTTP를 `18080`에 띄우고 URL 규칙을 정한다.
- 임시 시작점 후보:
```bash
python3 -m http.server 18080 --bind 0.0.0.0 -d dist/field/<field-version>
```
- 외부 URL 후보:
```text
http://toki-labs.com:18080/darwin-arm64/iop-node
http://toki-labs.com:18080/bootstrap/node-darwin-arm64.sh
```
5. one-line Node bootstrap을 만든다.
- 목표 UX:
```bash
curl -fsSL http://toki-labs.com:18080/bootstrap/node-darwin-arm64.sh | bash
```
- 스크립트 책임:
- `~/iop-field` 생성
- `darwin-arm64/iop-node` 다운로드
- 가능하면 checksum 확인
- `chmod +x`
- macOS quarantine xattr 제거
- `node.yaml` 생성
- `./iop-node serve --config ./node.yaml` 실행
6. Edge에서 테스트한다.
- Node registration 확인
- `/v1/models`
- non-streaming `/v1/chat/completions`
- streaming SSE와 `data: [DONE]`
- `/api/tags` passthrough
- `think:false``options` passthrough
## Milestone 체크 상태
현재 완료:
- `[compose-ports]`
- `[runbook]`
남은 항목:
- `[artifact-layout]`
- `[one-line-node]`
- `[serve-lifecycle]`
## 관련 파일
- `agent-ops/rules/project/rules.md`
- `docs/deploy-dev.md`
- `agent-ops/roadmap/current.md`
- `agent-ops/roadmap/phase/serving-routing-optimization/PHASE.md`
- `agent-ops/roadmap/phase/serving-routing-optimization/milestones/field-bootstrap-test-port-readiness.md`
- `apps/edge/cmd/edge/main.go`
- `apps/edge/internal/bootstrap/runtime.go`
- `bin/build/field-binaries.sh`
- 원격: `ssh toki@toki-labs.com:~/docker/services/code-server/compose/docker-compose.yml`
## 다음 세션에 붙여넣을 한 줄
```text
/config/workspace/iop의 AGENTS.md 필수 규칙을 읽고, agent-task/m-field-bootstrap-test-port-readiness/reboot-handoff.md를 읽은 뒤 Field Bootstrap UX와 테스트 포트 정비 작업을 이어가. 우선 재부팅 후 code-server 포트 적용 여부를 확인하고, edge serve lifecycle 문제를 수정한 다음 Apple Silicon one-line Node bootstrap 테스트로 들어가.
```

View file

@ -178,6 +178,8 @@ openai:
- "qwen3.6:35b-a3b-bf16" - "qwen3.6:35b-a3b-bf16"
session_id: "cline" session_id: "cline"
timeout_sec: 300 timeout_sec: 300
strict_output: true
strict_stream_buffer: false
nodes: nodes:
- id: "node-dgx-01" - id: "node-dgx-01"

View file

@ -3,6 +3,7 @@ package bootstrap
import ( import (
"context" "context"
"fmt" "fmt"
"sync"
"time" "time"
"go.uber.org/zap" "go.uber.org/zap"
@ -29,6 +30,9 @@ type Runtime struct {
Service *edgeservice.Service Service *edgeservice.Service
Server *transport.Server Server *transport.Server
Input *edgeinput.Manager Input *edgeinput.Manager
lifetimeMu sync.Mutex
lifetimeCancel context.CancelFunc
} }
func NewRuntime(cfg *config.EdgeConfig) (*Runtime, error) { func NewRuntime(cfg *config.EdgeConfig) (*Runtime, error) {
@ -70,23 +74,31 @@ func (r *Runtime) wireHandlers() {
} }
func (r *Runtime) Start(ctx context.Context) error { func (r *Runtime) Start(ctx context.Context) error {
r.wireHandlers() if err := ctx.Err(); err != nil {
if err := r.Server.Start(ctx); err != nil {
return err return err
} }
if err := r.Input.Start(ctx); err != nil { r.wireHandlers()
lifetimeCtx := r.newLifetimeContext()
if err := r.Server.Start(lifetimeCtx); err != nil {
r.cancelLifetime()
return err
}
if err := ctx.Err(); err != nil {
r.cancelLifetime()
_ = r.Server.Stop() _ = r.Server.Stop()
return err return err
} }
go func() { if err := r.Input.Start(lifetimeCtx); err != nil {
if err := observability.ServeMetrics(r.Cfg.Metrics.Port); err != nil { r.cancelLifetime()
r.Logger.Warn("metrics server exited", zap.Error(err)) _ = r.Server.Stop()
} return err
}() }
r.startMetrics()
return nil return nil
} }
func (r *Runtime) Stop() error { func (r *Runtime) Stop() error {
r.cancelLifetime()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
inputErr := r.Input.Stop(ctx) inputErr := r.Input.Stop(ctx)
@ -97,3 +109,35 @@ func (r *Runtime) Stop() error {
} }
return err return err
} }
func (r *Runtime) newLifetimeContext() context.Context {
r.lifetimeMu.Lock()
defer r.lifetimeMu.Unlock()
if r.lifetimeCancel != nil {
r.lifetimeCancel()
}
lifetimeCtx, cancel := context.WithCancel(context.Background())
r.lifetimeCancel = cancel
return lifetimeCtx
}
func (r *Runtime) cancelLifetime() {
r.lifetimeMu.Lock()
cancel := r.lifetimeCancel
r.lifetimeCancel = nil
r.lifetimeMu.Unlock()
if cancel != nil {
cancel()
}
}
func (r *Runtime) startMetrics() {
if r.Cfg.Metrics.Port <= 0 {
return
}
go func() {
if err := observability.ServeMetrics(r.Cfg.Metrics.Port); err != nil {
r.Logger.Warn("metrics server exited", zap.Error(err))
}
}()
}

View file

@ -1,7 +1,11 @@
package bootstrap package bootstrap
import ( import (
"context"
"net"
"net/http"
"testing" "testing"
"time"
"iop/packages/config" "iop/packages/config"
) )
@ -73,6 +77,49 @@ func TestNewRuntimeWiresInputManager(t *testing.T) {
} }
} }
func TestRuntimeKeepsListenersAfterStartupContextCancel(t *testing.T) {
edgeAddr := freeTCPAddr(t)
openAIAddr := freeTCPAddr(t)
cfg := newTestConfig()
cfg.Server.Listen = edgeAddr
cfg.OpenAI = config.EdgeOpenAIConf{
Enabled: true,
Listen: openAIAddr,
}
rt, err := NewRuntime(cfg)
if err != nil {
t.Fatalf("NewRuntime: %v", err)
}
startupCtx, cancelStartup := context.WithCancel(context.Background())
if err := rt.Start(startupCtx); err != nil {
t.Fatalf("Start: %v", err)
}
defer func() {
if err := rt.Stop(); err != nil {
t.Fatalf("Stop: %v", err)
}
}()
cancelStartup()
conn, err := net.DialTimeout("tcp", edgeAddr, time.Second)
if err != nil {
t.Fatalf("edge listener closed with startup context: %v", err)
}
_ = conn.Close()
client := &http.Client{Timeout: time.Second}
resp, err := client.Get("http://" + openAIAddr + "/healthz")
if err != nil {
t.Fatalf("openai listener closed with startup context: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("openai health status = %d, want %d", resp.StatusCode, http.StatusOK)
}
}
func TestNewRuntimeRejectsDuplicateToken(t *testing.T) { func TestNewRuntimeRejectsDuplicateToken(t *testing.T) {
cfg := &config.EdgeConfig{ cfg := &config.EdgeConfig{
Server: config.EdgeServerConf{Listen: "127.0.0.1:0"}, Server: config.EdgeServerConf{Listen: "127.0.0.1:0"},
@ -86,3 +133,16 @@ func TestNewRuntimeRejectsDuplicateToken(t *testing.T) {
t.Fatal("expected error on duplicate token seed") t.Fatal("expected error on duplicate token seed")
} }
} }
func freeTCPAddr(t *testing.T) string {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen free tcp addr: %v", err)
}
addr := ln.Addr().String()
if err := ln.Close(); err != nil {
t.Fatalf("close free tcp addr listener: %v", err)
}
return addr
}

View file

@ -5,9 +5,11 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"html"
"io" "io"
"net" "net"
"net/http" "net/http"
"regexp"
"strings" "strings"
"time" "time"
@ -131,11 +133,32 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "invalid_request_error", "model is required") writeError(w, http.StatusBadRequest, "invalid_request_error", "model is required")
return return
} }
prompt := promptFromMessages(req.Messages) basePrompt := promptFromMessages(req.Messages)
if strings.TrimSpace(prompt) == "" { if strings.TrimSpace(basePrompt) == "" {
writeError(w, http.StatusBadRequest, "invalid_request_error", "messages are required") writeError(w, http.StatusBadRequest, "invalid_request_error", "messages are required")
return return
} }
outputPolicy := s.resolveOutputPolicy(basePrompt)
messages := req.Messages
if instruction := strictOutputContractInstruction(outputPolicy); instruction != "" {
messages = prependSystemMessage(messages, instruction)
}
prompt := promptFromMessages(messages)
input := req.runInput(prompt, messages, outputPolicy.Strict)
s.logger.Info("openai chat completion input",
zap.String("model", req.Model),
zap.String("target", target),
zap.String("adapter", s.resolveAdapter()),
zap.Bool("strict_output", outputPolicy.Strict),
zap.Bool("strict_stream_buffer", outputPolicy.StreamBuffer),
zap.String("xml_completion_tool", outputPolicy.XMLCompletionTool),
zap.Bool("contract_instruction", outputPolicy.ContractInstruction),
zap.Bool("stream", req.Stream),
zap.Int("message_count", len(req.Messages)),
zap.Int("prompt_len", len(prompt)),
zap.String("prompt_preview", previewString(prompt, 1000)),
zap.Any("input_keys", mapKeys(input)),
)
handle, err := s.service.SubmitRun(r.Context(), edgeservice.SubmitRunRequest{ handle, err := s.service.SubmitRun(r.Context(), edgeservice.SubmitRunRequest{
NodeRef: s.cfg.NodeRef, NodeRef: s.cfg.NodeRef,
@ -143,12 +166,13 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
Target: target, Target: target,
SessionID: s.resolveSessionID(), SessionID: s.resolveSessionID(),
Prompt: prompt, Prompt: prompt,
Input: req.runInput(prompt), Input: input,
TimeoutSec: s.resolveTimeoutSec(), TimeoutSec: s.resolveTimeoutSec(),
Metadata: map[string]string{ Metadata: map[string]string{
"source": "openai", "source": "openai",
"openai_model": req.Model, "openai_model": req.Model,
"openai_stream": fmt.Sprintf("%t", req.Stream), "openai_stream": fmt.Sprintf("%t", req.Stream),
"strict_output": fmt.Sprintf("%t", outputPolicy.Strict),
}, },
}) })
if err != nil { if err != nil {
@ -158,10 +182,10 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
defer handle.Close() defer handle.Close()
if req.Stream { if req.Stream {
s.streamChatCompletion(w, r, req, handle) s.streamChatCompletion(w, r, req, handle, outputPolicy)
return return
} }
s.completeChatCompletion(w, r, req, handle) s.completeChatCompletion(w, r, req, handle, outputPolicy)
} }
func (s *Server) handleOllamaAPI(w http.ResponseWriter, r *http.Request) { func (s *Server) handleOllamaAPI(w http.ResponseWriter, r *http.Request) {
@ -200,12 +224,23 @@ func (s *Server) handleOllamaAPI(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(resp.Body)) _, _ = w.Write([]byte(resp.Body))
} }
func (s *Server) completeChatCompletion(w http.ResponseWriter, r *http.Request, req chatCompletionRequest, handle *edgeservice.RunHandle) { func (s *Server) completeChatCompletion(w http.ResponseWriter, r *http.Request, req chatCompletionRequest, handle *edgeservice.RunHandle, outputPolicy strictOutputPolicy) {
text, reasoning, usage, err := collectRunResult(r.Context(), handle) text, reasoning, usage, err := collectRunResult(r.Context(), handle)
if err != nil { if err != nil {
writeError(w, httpStatusForRunError(err), "run_error", err.Error()) writeError(w, httpStatusForRunError(err), "run_error", err.Error())
return return
} }
text, reasoning, normalized := normalizeCompletionOutput(outputPolicy, text, reasoning)
s.logger.Info("openai chat completion output",
zap.String("run_id", handle.RunID),
zap.Bool("strict_output", outputPolicy.Strict),
zap.Bool("strict_stream_buffer", outputPolicy.StreamBuffer),
zap.String("xml_completion_tool", outputPolicy.XMLCompletionTool),
zap.Bool("normalized", normalized),
zap.Int("content_len", len(text)),
zap.Int("reasoning_len", len(reasoning)),
zap.String("content_preview", previewString(text, 1000)),
)
created := time.Now().Unix() created := time.Now().Unix()
writeJSON(w, http.StatusOK, chatCompletionResponse{ writeJSON(w, http.StatusOK, chatCompletionResponse{
ID: "chatcmpl-" + handle.RunID, ID: "chatcmpl-" + handle.RunID,
@ -221,7 +256,7 @@ func (s *Server) completeChatCompletion(w http.ResponseWriter, r *http.Request,
}) })
} }
func (s *Server) streamChatCompletion(w http.ResponseWriter, r *http.Request, req chatCompletionRequest, handle *edgeservice.RunHandle) { func (s *Server) streamChatCompletion(w http.ResponseWriter, r *http.Request, req chatCompletionRequest, handle *edgeservice.RunHandle, outputPolicy strictOutputPolicy) {
flusher, ok := w.(http.Flusher) flusher, ok := w.(http.Flusher)
if !ok { if !ok {
writeError(w, http.StatusInternalServerError, "streaming_not_supported", "response writer does not support streaming") writeError(w, http.StatusInternalServerError, "streaming_not_supported", "response writer does not support streaming")
@ -245,6 +280,25 @@ func (s *Server) streamChatCompletion(w http.ResponseWriter, r *http.Request, re
}}, }},
}) })
if outputPolicy.Strict && outputPolicy.StreamBuffer {
s.streamBufferedChatCompletion(w, r, handle, id, created, model, flusher, outputPolicy)
return
}
var contentBuilder strings.Builder
var reasoningBuilder strings.Builder
defer func() {
s.logger.Info("openai chat completion stream closed",
zap.String("run_id", handle.RunID),
zap.Bool("strict_output", outputPolicy.Strict),
zap.Bool("strict_stream_buffer", outputPolicy.StreamBuffer),
zap.Int("content_len", contentBuilder.Len()),
zap.Int("reasoning_len", reasoningBuilder.Len()),
zap.String("content_preview", previewString(contentBuilder.String(), 1000)),
zap.String("reasoning_preview", previewString(reasoningBuilder.String(), 1000)),
)
}()
for { for {
select { select {
case <-r.Context().Done(): case <-r.Context().Done():
@ -263,6 +317,7 @@ func (s *Server) streamChatCompletion(w http.ResponseWriter, r *http.Request, re
if event.GetDelta() == "" { if event.GetDelta() == "" {
continue continue
} }
contentBuilder.WriteString(event.GetDelta())
writeSSE(w, flusher, chatCompletionChunk{ writeSSE(w, flusher, chatCompletionChunk{
ID: id, ID: id,
Object: "chat.completion.chunk", Object: "chat.completion.chunk",
@ -277,6 +332,10 @@ func (s *Server) streamChatCompletion(w http.ResponseWriter, r *http.Request, re
if event.GetDelta() == "" { if event.GetDelta() == "" {
continue continue
} }
reasoningBuilder.WriteString(event.GetDelta())
if outputPolicy.Strict {
continue
}
writeSSE(w, flusher, chatCompletionChunk{ writeSSE(w, flusher, chatCompletionChunk{
ID: id, ID: id,
Object: "chat.completion.chunk", Object: "chat.completion.chunk",
@ -320,6 +379,51 @@ func (s *Server) streamChatCompletion(w http.ResponseWriter, r *http.Request, re
} }
} }
func (s *Server) streamBufferedChatCompletion(w http.ResponseWriter, r *http.Request, handle *edgeservice.RunHandle, id string, created int64, model string, flusher http.Flusher, outputPolicy strictOutputPolicy) {
text, reasoning, _, err := collectRunResult(r.Context(), handle)
if err != nil {
writeSSEError(w, flusher, err.Error())
return
}
text, reasoning, normalized := normalizeCompletionOutput(outputPolicy, text, reasoning)
s.logger.Info("openai chat completion stream closed",
zap.String("run_id", handle.RunID),
zap.Bool("strict_output", outputPolicy.Strict),
zap.Bool("strict_stream_buffer", outputPolicy.StreamBuffer),
zap.String("xml_completion_tool", outputPolicy.XMLCompletionTool),
zap.Bool("normalized", normalized),
zap.Int("content_len", len(text)),
zap.Int("reasoning_len", len(reasoning)),
zap.String("content_preview", previewString(text, 1000)),
zap.String("reasoning_preview", previewString(reasoning, 1000)),
)
if text != "" {
writeSSE(w, flusher, chatCompletionChunk{
ID: id,
Object: "chat.completion.chunk",
Created: created,
Model: model,
Choices: []chatCompletionChunkChoice{{
Index: 0,
Delta: chatDelta{Content: text},
}},
})
}
writeSSE(w, flusher, chatCompletionChunk{
ID: id,
Object: "chat.completion.chunk",
Created: created,
Model: model,
Choices: []chatCompletionChunkChoice{{
Index: 0,
Delta: chatDelta{},
FinishReason: "stop",
}},
})
fmt.Fprint(w, "data: [DONE]\n\n")
flusher.Flush()
}
func collectRunResult(ctx context.Context, handle *edgeservice.RunHandle) (string, string, *openAIUsage, error) { func collectRunResult(ctx context.Context, handle *edgeservice.RunHandle) (string, string, *openAIUsage, error) {
var contentBuilder strings.Builder var contentBuilder strings.Builder
var reasoningBuilder strings.Builder var reasoningBuilder strings.Builder
@ -396,6 +500,27 @@ func (s *Server) resolveTimeoutSec() int {
return edgeservice.DefaultTimeoutSec return edgeservice.DefaultTimeoutSec
} }
func (s *Server) resolveStrictOutput() bool {
return s.cfg.StrictOutput
}
func (s *Server) resolveStrictStreamBuffer() bool {
return s.cfg.StrictStreamBuffer
}
func (s *Server) resolveOutputPolicy(prompt string) strictOutputPolicy {
policy := strictOutputPolicy{
Strict: s.resolveStrictOutput(),
StreamBuffer: s.resolveStrictStreamBuffer(),
}
if !policy.Strict {
return policy
}
policy.XMLCompletionTool, policy.XMLResultTag = inferXMLCompletionContract(prompt)
policy.ContractInstruction = policy.XMLCompletionTool != ""
return policy
}
func promptFromMessages(messages []chatMessage) string { func promptFromMessages(messages []chatMessage) string {
var b strings.Builder var b strings.Builder
for _, msg := range messages { for _, msg := range messages {
@ -497,10 +622,10 @@ func (m *chatMessage) UnmarshalJSON(b []byte) error {
return nil return nil
} }
func (req chatCompletionRequest) runInput(prompt string) map[string]any { func (req chatCompletionRequest) runInput(prompt string, messages []chatMessage, strictOutput bool) map[string]any {
input := map[string]any{ input := map[string]any{
"prompt": prompt, "prompt": prompt,
"messages": chatMessagesInput(req.Messages), "messages": chatMessagesInput(messages),
} }
if len(req.Options) > 0 { if len(req.Options) > 0 {
input["options"] = req.Options input["options"] = req.Options
@ -513,6 +638,8 @@ func (req chatCompletionRequest) runInput(prompt string) map[string]any {
} }
if req.Think != nil { if req.Think != nil {
input["think"] = req.Think input["think"] = req.Think
} else if strictOutput {
input["think"] = false
} }
if len(req.Tools) > 0 { if len(req.Tools) > 0 {
input["tools"] = req.Tools input["tools"] = req.Tools
@ -520,6 +647,140 @@ func (req chatCompletionRequest) runInput(prompt string) map[string]any {
return input return input
} }
type strictOutputPolicy struct {
Strict bool
StreamBuffer bool
XMLCompletionTool string
XMLResultTag string
ContractInstruction bool
}
func strictOutputContractInstruction(policy strictOutputPolicy) string {
if !policy.ContractInstruction {
return ""
}
resultTag := policy.XMLResultTag
if resultTag == "" {
resultTag = "result"
}
return fmt.Sprintf("Strict output contract: follow the client's XML tool protocol. When you complete the task, output exactly one <%s> block and no text outside that XML block. The <%s> content must be the direct final answer that should be shown to the end user. Do not summarize that you answered, completed, explained, greeted, or performed the task. Do not write phrases like \"I completed\", \"I explained\", \"I greeted\", \"summary\", or \"main points\" unless the user explicitly asks for a summary. Put the actual user-facing response inside <%s>.", policy.XMLCompletionTool, resultTag, resultTag)
}
func prependSystemMessage(messages []chatMessage, content string) []chatMessage {
out := make([]chatMessage, 0, len(messages)+1)
out = append(out, chatMessage{Role: "system", Content: content})
out = append(out, messages...)
return out
}
func normalizeCompletionOutput(policy strictOutputPolicy, content, reasoning string) (string, string, bool) {
if !policy.Strict {
return content, reasoning, false
}
normalized := normalizeStrictAgentContent(content)
if strings.TrimSpace(normalized) == "" && strings.TrimSpace(reasoning) != "" {
normalized = normalizeStrictAgentContent(reasoning)
}
if strings.TrimSpace(normalized) == "" {
normalized = "Model returned no assistant content."
}
if policy.XMLCompletionTool != "" && !startsWithXMLRoot(normalized) {
normalized = wrapXMLCompletion(policy, normalized)
}
return normalized, "", normalized != content || reasoning != ""
}
var (
agentThinkingBlockRE = regexp.MustCompile(`(?is)<think(?:ing)?\b[^>]*>.*?</think(?:ing)?\s*>`)
agentCodeFenceLineRE = regexp.MustCompile(`(?m)^\s*` + "```" + `[A-Za-z0-9_-]*\s*$`)
agentXMLOpenTagRE = regexp.MustCompile(`(?is)<\s*([A-Za-z_][A-Za-z0-9_.:-]*)\b[^>]*>`)
agentXMLCloseTagRE = regexp.MustCompile(`(?is)</\s*([A-Za-z_][A-Za-z0-9_.:\s-]*)\s*>`)
completedUseToolRE = regexp.MustCompile("(?is)completed.{0,300}?use\\s+(?:the\\s+)?`?([A-Za-z_][A-Za-z0-9_.:-]*)`?\\s+tool")
xmlToolWithResultREPattern = `(?is)<\s*%s\b[^>]*>\s*<\s*([A-Za-z_][A-Za-z0-9_.:-]*)\b`
)
func normalizeStrictAgentContent(content string) string {
cleaned := strings.TrimSpace(content)
cleaned = agentThinkingBlockRE.ReplaceAllString(cleaned, "")
cleaned = agentCodeFenceLineRE.ReplaceAllString(cleaned, "")
cleaned = strings.TrimSpace(cleaned)
for strings.HasPrefix(cleaned, "---") {
cleaned = strings.TrimSpace(strings.TrimPrefix(cleaned, "---"))
}
cleaned = strings.TrimSpace(cleaned)
if block, ok := firstXMLRootBlock(cleaned); ok {
return block
}
return cleaned
}
func startsWithXMLRoot(content string) bool {
trimmed := strings.TrimSpace(content)
open := agentXMLOpenTagRE.FindStringIndex(trimmed)
return open != nil && open[0] == 0
}
func inferXMLCompletionContract(prompt string) (string, string) {
tool := ""
if match := completedUseToolRE.FindStringSubmatch(prompt); len(match) == 2 {
tool = match[1]
}
if tool == "" {
return "", ""
}
resultTag := inferXMLResultTag(prompt, tool)
if resultTag == "" {
resultTag = "result"
}
return tool, resultTag
}
func inferXMLResultTag(prompt, tool string) string {
toolPattern := regexp.QuoteMeta(tool)
re := regexp.MustCompile(fmt.Sprintf(xmlToolWithResultREPattern, toolPattern))
if match := re.FindStringSubmatch(prompt); len(match) == 2 {
return match[1]
}
return ""
}
func wrapXMLCompletion(policy strictOutputPolicy, content string) string {
resultTag := policy.XMLResultTag
if resultTag == "" {
resultTag = "result"
}
return "<" + policy.XMLCompletionTool + ">\n<" + resultTag + ">" + html.EscapeString(strings.TrimSpace(content)) + "</" + resultTag + ">\n</" + policy.XMLCompletionTool + ">"
}
func firstXMLRootBlock(content string) (string, bool) {
open := agentXMLOpenTagRE.FindStringSubmatchIndex(content)
if open == nil {
return "", false
}
root := content[open[2]:open[3]]
search := content[open[1]:]
for _, close := range agentXMLCloseTagRE.FindAllStringSubmatchIndex(search, -1) {
name := search[close[2]:close[3]]
if canonicalXMLName(name) != canonicalXMLName(root) {
continue
}
closeStart := open[1] + close[0]
block := content[open[0]:closeStart] + "</" + root + ">"
return strings.TrimSpace(block), true
}
return "", false
}
func canonicalXMLName(name string) string {
var b strings.Builder
for _, r := range strings.ToLower(name) {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
}
}
return b.String()
}
func chatMessagesInput(messages []chatMessage) []any { func chatMessagesInput(messages []chatMessage) []any {
out := make([]any, 0, len(messages)) out := make([]any, 0, len(messages))
for _, msg := range messages { for _, msg := range messages {
@ -564,6 +825,21 @@ func contentToString(v any) string {
} }
} }
func previewString(s string, max int) string {
if max <= 0 || len(s) <= max {
return s
}
return s[:max] + "...[truncated]"
}
func mapKeys(m map[string]any) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}
type chatCompletionResponse struct { type chatCompletionResponse struct {
ID string `json:"id"` ID string `json:"id"`
Object string `json:"object"` Object string `json:"object"`

View file

@ -232,6 +232,129 @@ func TestChatCompletionsStreamsReasoningSSE(t *testing.T) {
} }
} }
func TestChatCompletionsStrictOutputNormalizesXMLStyleAgentResponse(t *testing.T) {
fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)}
fake.events <- &iop.RunEvent{Type: "delta", Delta: "---\n<thinking>hidden</thinking>\n\n<attempt_completion>\n<result>ok</result>\n</attempt completion>\n```\ntrailing noise"}
fake.events <- &iop.RunEvent{Type: "complete"}
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", StrictOutput: true}, fake, nil)
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
"model":"agent-model",
"messages":[{"role":"user","content":"finish"}]
}`))
w := httptest.NewRecorder()
srv.handleChatCompletions(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
if fake.req.Input["think"] != false {
t.Fatalf("strict output should disable thinking by default: %+v", fake.req.Input)
}
var resp chatCompletionResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
want := "<attempt_completion>\n<result>ok</result>\n</attempt_completion>"
if resp.Choices[0].Message.Content != want {
t.Fatalf("content:\ngot %q\nwant %q", resp.Choices[0].Message.Content, want)
}
if resp.Choices[0].Message.ReasoningContent != "" {
t.Fatalf("strict output should drop reasoning content: %q", resp.Choices[0].Message.ReasoningContent)
}
}
func TestChatCompletionsStrictOutputWrapsPlainTextWhenPromptDeclaresCompletionTool(t *testing.T) {
fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)}
fake.events <- &iop.RunEvent{Type: "delta", Delta: "plain answer"}
fake.events <- &iop.RunEvent{Type: "complete"}
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", StrictOutput: true}, fake, nil)
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
"model":"agent-model",
"messages":[
{"role":"system","content":"Once you've completed the user's task, you must use the attempt_completion tool to present the result.\n\n<attempt_completion>\n<result>done</result>\n</attempt_completion>"},
{"role":"user","content":"finish"}
]
}`))
w := httptest.NewRecorder()
srv.handleChatCompletions(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
if !strings.Contains(fake.req.Prompt, "output exactly one <attempt_completion> block") || !strings.Contains(fake.req.Prompt, "actual user-facing response") {
t.Fatalf("strict contract instruction not injected:\n%s", fake.req.Prompt)
}
var resp chatCompletionResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
want := "<attempt_completion>\n<result>plain answer</result>\n</attempt_completion>"
if resp.Choices[0].Message.Content != want {
t.Fatalf("content:\ngot %q\nwant %q", resp.Choices[0].Message.Content, want)
}
}
func TestChatCompletionsStrictOutputBuffersStreamingResponse(t *testing.T) {
fake := &fakeRunService{events: make(chan *iop.RunEvent, 4)}
fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "hidden"}
fake.events <- &iop.RunEvent{Type: "delta", Delta: "---\n<think>noise</think><read_file><path>a.go</path></read file>"}
fake.events <- &iop.RunEvent{Type: "complete"}
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", StrictOutput: true, StrictStreamBuffer: true}, fake, nil)
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
"model":"agent-model",
"stream":true,
"messages":[{"role":"user","content":"read"}]
}`))
w := httptest.NewRecorder()
srv.handleChatCompletions(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
body := w.Body.String()
if strings.Contains(body, "reasoning_content") || strings.Contains(body, "<think>") || strings.Contains(body, "</read file>") {
t.Fatalf("strict stream leaked unstable content:\n%s", body)
}
if !strings.Contains(body, "read_file") || !strings.Contains(body, "a.go") || !strings.Contains(body, "data: [DONE]") {
t.Fatalf("unexpected strict SSE body:\n%s", body)
}
}
func TestChatCompletionsStrictOutputStreamsLiveContent(t *testing.T) {
fake := &fakeRunService{events: make(chan *iop.RunEvent, 5)}
fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "hidden"}
fake.events <- &iop.RunEvent{Type: "delta", Delta: "<attempt_completion>\n"}
fake.events <- &iop.RunEvent{Type: "delta", Delta: "<result>ok</result>\n</attempt_completion>"}
fake.events <- &iop.RunEvent{Type: "complete"}
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", StrictOutput: true}, fake, nil)
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
"model":"agent-model",
"stream":true,
"messages":[{"role":"user","content":"finish"}]
}`))
w := httptest.NewRecorder()
srv.handleChatCompletions(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
body := w.Body.String()
if strings.Contains(body, "reasoning_content") || strings.Contains(body, "hidden") {
t.Fatalf("strict stream leaked reasoning content:\n%s", body)
}
if strings.Count(body, `"content":`) != 2 || !strings.Contains(body, "attempt_completion") || !strings.Contains(body, "data: [DONE]") {
t.Fatalf("strict stream should pass live content deltas:\n%s", body)
}
}
func TestModelsUsesConfiguredModelsOrTarget(t *testing.T) { func TestModelsUsesConfiguredModelsOrTarget(t *testing.T) {
srv := NewServer(config.EdgeOpenAIConf{Target: "fallback-model"}, &fakeRunService{}, nil) srv := NewServer(config.EdgeOpenAIConf{Target: "fallback-model"}, &fakeRunService{}, nil)
req := httptest.NewRequest(http.MethodGet, "/v1/models", nil) req := httptest.NewRequest(http.MethodGet, "/v1/models", nil)

View file

@ -22,6 +22,9 @@ Environment:
VERSION=<value> release version label VERSION=<value> release version label
COMMIT=<value> commit label COMMIT=<value> commit label
BUILD_DATE=<value> build date label BUILD_DATE=<value> build date label
ARTIFACT_BASE_URL=<url> default bootstrap artifact base URL
BOOTSTRAP_EDGE_ADDR=<host:port> default Edge address written to node.yaml
BOOTSTRAP_NODE_TOKEN=<token> default Node registration token
CGO_ENABLED=0 Go CGO setting CGO_ENABLED=0 Go CGO setting
GOFLAGS="-trimpath" extra go build flags GOFLAGS="-trimpath" extra go build flags
LDFLAGS="-s -w" linker flags LDFLAGS="-s -w" linker flags
@ -46,6 +49,17 @@ checksum() {
shasum -a 256 "$@" shasum -a 256 "$@"
} }
app_enabled() {
local wanted="$1"
local app
for app in $APPS; do
if [[ "$app" == "$wanted" ]]; then
return 0
fi
done
return 1
}
build_one() { build_one() {
local app="$1" local app="$1"
local goos="$2" local goos="$2"
@ -60,7 +74,83 @@ build_one() {
GOOS="$goos" GOARCH="$goarch" CGO_ENABLED="$CGO_ENABLED" \ GOOS="$goos" GOARCH="$goarch" CGO_ENABLED="$CGO_ENABLED" \
go build $GOFLAGS -ldflags "$LDFLAGS" -o "$output" "./apps/$app/cmd/$app" go build $GOFLAGS -ldflags "$LDFLAGS" -o "$output" "./apps/$app/cmd/$app"
) )
checksum "$output" > "$output.sha256" (
cd "$output_dir"
checksum "iop-$app" > "iop-$app.sha256"
)
}
write_node_bootstrap() {
local goos="$1"
local goarch="$2"
local target_dir="$goos-$goarch"
local bootstrap_dir="$DIST_DIR/$VERSION/bootstrap"
local script="$bootstrap_dir/node-$target_dir.sh"
local default_base_url="${ARTIFACT_BASE_URL:-"http://toki-labs.com:18080"}"
local default_edge_addr="${BOOTSTRAP_EDGE_ADDR:-"toki-labs.com:19090"}"
local default_node_token="${BOOTSTRAP_NODE_TOKEN:-"CHANGE_ME_FIELD_TOKEN"}"
mkdir -p "$bootstrap_dir"
cat > "$script" <<EOF
#!/usr/bin/env bash
set -euo pipefail
VERSION="$VERSION"
TARGET="$target_dir"
ARTIFACT_BASE_URL="\${IOP_ARTIFACT_BASE_URL:-$default_base_url}"
EDGE_ADDR="\${IOP_EDGE_ADDR:-$default_edge_addr}"
NODE_TOKEN="\${IOP_NODE_TOKEN:-$default_node_token}"
INSTALL_DIR="\${IOP_HOME:-\$HOME/iop-field}"
CONFIG_FILE="\${IOP_NODE_CONFIG:-\$INSTALL_DIR/node.yaml}"
BIN_FILE="\$INSTALL_DIR/iop-node"
TMP_DIR="\$(mktemp -d "\${TMPDIR:-/tmp}/iop-node-bootstrap.XXXXXX")"
cleanup() {
rm -rf "\$TMP_DIR"
}
trap cleanup EXIT
ARTIFACT_BASE_URL="\${ARTIFACT_BASE_URL%/}"
echo "[iop-bootstrap] version=\$VERSION target=\$TARGET"
echo "[iop-bootstrap] install_dir=\$INSTALL_DIR"
mkdir -p "\$INSTALL_DIR"
curl -fsSL "\$ARTIFACT_BASE_URL/\$TARGET/iop-node" -o "\$TMP_DIR/iop-node"
curl -fsSL "\$ARTIFACT_BASE_URL/\$TARGET/SHA256SUMS" -o "\$TMP_DIR/SHA256SUMS"
if command -v sha256sum >/dev/null 2>&1; then
(cd "\$TMP_DIR" && grep -E '[ *]iop-node$' SHA256SUMS | sha256sum -c -)
elif command -v shasum >/dev/null 2>&1; then
(cd "\$TMP_DIR" && grep -E '[ *]iop-node$' SHA256SUMS | shasum -a 256 -c -)
else
echo "[iop-bootstrap] warning: no sha256 checker found; skipping checksum verification" >&2
fi
cp "\$TMP_DIR/iop-node" "\$BIN_FILE"
chmod +x "\$BIN_FILE"
if command -v xattr >/dev/null 2>&1; then
xattr -d com.apple.quarantine "\$BIN_FILE" 2>/dev/null || true
fi
cat > "\$CONFIG_FILE" <<YAML
transport:
edge_addr: "\$EDGE_ADDR"
token: "\$NODE_TOKEN"
logging:
level: "\${IOP_LOG_LEVEL:-info}"
pretty: true
metrics:
port: \${IOP_NODE_METRICS_PORT:-9091}
YAML
echo "[iop-bootstrap] config=\$CONFIG_FILE"
echo "[iop-bootstrap] starting node against \$EDGE_ADDR"
exec "\$BIN_FILE" serve --config "\$CONFIG_FILE"
EOF
chmod +x "$script"
} }
for target in $TARGETS; do for target in $TARGETS; do
@ -92,6 +182,9 @@ for target in $TARGETS; do
cd "$output_dir" cd "$output_dir"
checksum iop-* | grep -v '\.sha256$' > SHA256SUMS checksum iop-* | grep -v '\.sha256$' > SHA256SUMS
) )
if app_enabled node; then
write_node_bootstrap "$goos" "$goarch"
fi
done done
echo "field binaries written to $DIST_DIR/$VERSION" echo "field binaries written to $DIST_DIR/$VERSION"

View file

@ -35,6 +35,8 @@ openai:
- "qwen3.6:35b-a3b-bf16" - "qwen3.6:35b-a3b-bf16"
session_id: "cline" session_id: "cline"
timeout_sec: 300 timeout_sec: 300
strict_output: true
strict_stream_buffer: false
console: console:
adapter: "cli" adapter: "cli"

View file

@ -5,7 +5,7 @@
IOP(Inference Operations Platform)는 모델 서빙과 CLI Agent/Automation 실행을 함께 다루는 실행 오케스트레이션 플랫폼을 지향한다. 단순한 OpenAI-compatible proxy나 모델 라우터가 아니라, Control Plane - Edge - Node 계층으로 로컬 실행 그룹을 관리하는 구조를 목표로 한다. IOP(Inference Operations Platform)는 모델 서빙과 CLI Agent/Automation 실행을 함께 다루는 실행 오케스트레이션 플랫폼을 지향한다. 단순한 OpenAI-compatible proxy나 모델 라우터가 아니라, Control Plane - Edge - Node 계층으로 로컬 실행 그룹을 관리하는 구조를 목표로 한다.
현재 구현은 Edge-Node 실행 스켈레톤을 검증하는 단계다. Control Plane과 worker는 placeholder이며, 세부 계약은 후속 작업에서 정리한다. 현재 구현은 Edge-Node 실행 스켈레톤을 검증하는 단계다. Control Plane과 worker는 placeholder이며, 세부 계약은 후속 작업에서 정리한다.
제품 Phase와 Milestone 상세는 `agent-ops/roadmap/ROADMAP.md`에서 관리하고, 이 문서는 상대적으로 덜 변하는 구조 원칙과 책임 경계를 설명한다. 제품 Phase와 Milestone 상세는 `agent-roadmap/ROADMAP.md`에서 관리하고, 이 문서는 상대적으로 덜 변하는 구조 원칙과 책임 경계를 설명한다.
## 계층 구조 ## 계층 구조

View file

@ -141,7 +141,286 @@ field bootstrap UX는 Jenkins agent 연결처럼 Edge가 command를 제시하고
- 사용자가 실행하는 명령은 한 줄 수준으로 유지하고, 내부에서 다운로드, 실행 권한 부여, config 생성, `iop-node serve`까지 이어진다. - 사용자가 실행하는 명령은 한 줄 수준으로 유지하고, 내부에서 다운로드, 실행 권한 부여, config 생성, `iop-node serve`까지 이어진다.
- 직접 포트가 아직 적용되지 않았거나 외부망에서 막히면 SSH tunnel 또는 reverse tunnel은 fallback으로만 사용한다. - 직접 포트가 아직 적용되지 않았거나 외부망에서 막히면 SSH tunnel 또는 reverse tunnel은 fallback으로만 사용한다.
현재 활성 작업 좌표는 `agent-ops/roadmap/phase/serving-routing-optimization/milestones/field-bootstrap-test-port-readiness.md`다. Apple Silicon Node용 field artifact는 다음처럼 만든다. `BOOTSTRAP_NODE_TOKEN`은 Edge config의 `nodes[].token`과 같아야 한다.
```bash
VERSION=<field-version> \
APPS=node \
TARGETS="darwin/arm64" \
ARTIFACT_BASE_URL="http://toki-labs.com:18080" \
BOOTSTRAP_EDGE_ADDR="toki-labs.com:19090" \
BOOTSTRAP_NODE_TOKEN="CHANGE_ME_FIELD_TOKEN" \
DIST_DIR=dist/field \
./bin/build/field-binaries.sh
```
산출물 layout과 외부 URL 규칙은 다음을 기준으로 한다.
```text
dist/field/<field-version>/darwin-arm64/iop-node
dist/field/<field-version>/darwin-arm64/SHA256SUMS
dist/field/<field-version>/bootstrap/node-darwin-arm64.sh
http://toki-labs.com:18080/darwin-arm64/iop-node
http://toki-labs.com:18080/darwin-arm64/SHA256SUMS
http://toki-labs.com:18080/bootstrap/node-darwin-arm64.sh
```
정적 artifact/bootstrap HTTP는 version 디렉터리를 document root로 열어 둔다.
```bash
python3 -m http.server 18080 --bind 0.0.0.0 -d dist/field/<field-version>
```
사용자는 Apple Silicon Mac에서 다음 한 줄을 실행한다.
```bash
curl -fsSL http://toki-labs.com:18080/bootstrap/node-darwin-arm64.sh | bash
```
bootstrap 스크립트는 `~/iop-field`를 만들고, `darwin-arm64/iop-node``SHA256SUMS`를 받아 checksum을 확인한 뒤 `node.yaml`을 생성하고 `iop-node serve --config ./node.yaml`을 실행한다.
실행 시점에 값을 바꾸려면 `IOP_ARTIFACT_BASE_URL`, `IOP_EDGE_ADDR`, `IOP_NODE_TOKEN`, `IOP_HOME`, `IOP_NODE_CONFIG`, `IOP_NODE_METRICS_PORT` 환경변수를 사용할 수 있다.
## Field Bootstrap 작업자 테스트 Runbook
이 절차는 LLM/작업자가 사용자 직접 테스트를 준비하고 실패 지점을 분리할 때 기준으로 삼는다.
사용자에게 전달할 짧은 가이드는 `docs/field-bootstrap-user-test.md`를 사용한다.
토큰과 version 값은 테스트 시점에 맞춰 바꾸고, 기본 `configs/*.yaml`은 수정하지 않는다.
### 1. code-server 포트 확인
원격 host에서 code-server 컨테이너의 외부 포트가 실제로 열렸는지 먼저 확인한다.
```bash
ssh toki@toki-labs.com '/usr/local/bin/docker ps --format "{{.Names}} {{.Ports}}" | grep "^code-server "'
```
기대 포트:
```text
18080 artifact/bootstrap HTTP
18081 Edge OpenAI-compatible HTTP
19090 Edge-Node TCP transport
19092 Edge metrics
19190 OTO/specialized agent reserve
```
`docker ps`에 포트가 보이지 않으면 compose 파일은 갱신됐지만 컨테이너 recreate가 아직 안 된 상태일 수 있다.
컨테이너 recreate 타이밍은 운영자가 정한다.
### 2. Edge field config 작성
edge host에서 임시 설정 파일을 만든다.
Apple Silicon Mac에서 Ollama를 실행하고 Node도 같은 Mac에서 실행할 경우, Edge가 Node에 내려줄 Ollama base URL은 Node 관점의 `http://127.0.0.1:11434`다.
```bash
cat > /tmp/iop-edge-field-bootstrap.yaml <<'YAML'
edge:
id: "edge-field-bootstrap"
name: "Field Bootstrap Edge"
server:
listen: "0.0.0.0:9090"
openai:
enabled: true
listen: "0.0.0.0:8080"
node: "node-silicon-ollama"
adapter: "ollama"
target: ""
models:
- "gemma4:26b"
session_id: "openai-field"
timeout_sec: 300
strict_output: true
strict_stream_buffer: false
metrics:
port: 9092
nodes:
- id: "node-silicon-ollama"
alias: "silicon-ollama"
token: "CHANGE_ME_FIELD_TOKEN"
adapters:
ollama:
enabled: true
base_url: "http://127.0.0.1:11434"
context_size: 262144
YAML
```
config가 로드되는지 확인한다.
```bash
go run ./apps/edge/cmd/edge config check --config /tmp/iop-edge-field-bootstrap.yaml
```
### 3. Apple Silicon artifact 생성
repo root에서 Apple Silicon Node artifact와 bootstrap script를 만든다.
```bash
VERSION=field-bootstrap-manual \
APPS=node \
TARGETS="darwin/arm64" \
ARTIFACT_BASE_URL="http://toki-labs.com:18080" \
BOOTSTRAP_EDGE_ADDR="toki-labs.com:19090" \
BOOTSTRAP_NODE_TOKEN="CHANGE_ME_FIELD_TOKEN" \
DIST_DIR=dist/field \
./bin/build/field-binaries.sh
```
기대 파일:
```bash
test -x dist/field/field-bootstrap-manual/darwin-arm64/iop-node
test -f dist/field/field-bootstrap-manual/darwin-arm64/SHA256SUMS
test -x dist/field/field-bootstrap-manual/bootstrap/node-darwin-arm64.sh
```
### 4. artifact/bootstrap HTTP 시작
version 디렉터리를 document root로 열어 둔다.
```bash
python3 -m http.server 18080 --bind 0.0.0.0 -d dist/field/field-bootstrap-manual
```
다른 터미널에서 URL이 보이는지 확인한다.
```bash
curl -fsS http://127.0.0.1:18080/darwin-arm64/SHA256SUMS
curl -fsS http://127.0.0.1:18080/bootstrap/node-darwin-arm64.sh | sed -n '1,40p'
```
외부에서 직접 포트를 사용할 경우 Apple Silicon Mac에서도 같은 URL이 보여야 한다.
```bash
curl -fsS http://toki-labs.com:18080/darwin-arm64/SHA256SUMS
```
### 5. Edge serve 시작
Edge process를 field config로 실행한다.
```bash
go run ./apps/edge/cmd/edge serve --config /tmp/iop-edge-field-bootstrap.yaml
```
다른 터미널에서 listener를 확인한다.
```bash
curl -fsS http://127.0.0.1:8080/healthz
curl -fsS http://127.0.0.1:8080/v1/models
```
`/v1/models`는 Node가 아직 연결되지 않아도 configured model 목록을 반환해야 한다.
### 6. Apple Silicon Mac에서 one-line bootstrap 실행
Mac에서 Ollama가 떠 있고 `gemma4:26b`를 사용할 수 있는지 먼저 확인한다.
```bash
ollama list | grep 'gemma4:26b'
```
그 다음 one-line bootstrap을 실행한다.
```bash
curl -fsSL http://toki-labs.com:18080/bootstrap/node-darwin-arm64.sh | bash
```
테스트 중 값을 바꿔야 하면 pipe 오른쪽의 `bash`에 환경변수를 붙인다.
```bash
curl -fsSL http://toki-labs.com:18080/bootstrap/node-darwin-arm64.sh | \
IOP_ARTIFACT_BASE_URL="http://toki-labs.com:18080" \
IOP_EDGE_ADDR="toki-labs.com:19090" \
IOP_NODE_TOKEN="CHANGE_ME_FIELD_TOKEN" \
IOP_HOME="$HOME/iop-field" \
bash
```
성공하면 Mac에 다음 파일이 생기고, Node process가 foreground로 실행된다.
```text
~/iop-field/iop-node
~/iop-field/node.yaml
```
Edge 로그에는 `node registered``node-silicon-ollama`가 보여야 한다.
### 7. Edge에서 모델 호출 확인
Edge host에서 Node 등록 후 OpenAI-compatible API를 호출한다.
```bash
curl -fsS http://127.0.0.1:8080/v1/models
```
non-streaming:
```bash
curl -fsS http://127.0.0.1:8080/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{"model":"gemma4:26b","messages":[{"role":"user","content":"Reply with IOP_FIELD_BOOTSTRAP_OK only."}]}'
```
streaming:
```bash
curl -fsS -N http://127.0.0.1:8080/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{"model":"gemma4:26b","stream":true,"messages":[{"role":"user","content":"Reply with IOP_FIELD_STREAM_OK only."}]}'
```
성공 기준:
- Mac의 bootstrap 명령이 `iop-node serve --config <node.yaml>`까지 도달한다.
- Edge 로그에 `node registered`가 나온다.
- `/v1/models``gemma4:26b`를 포함한다.
- non-streaming 응답에서 content가 비어 있지 않다.
- streaming 응답에서 SSE chunk와 `data: [DONE]`이 보인다.
### 8. 실패 시 확인 순서
다운로드 실패:
```bash
curl -v http://toki-labs.com:18080/bootstrap/node-darwin-arm64.sh
curl -v http://toki-labs.com:18080/darwin-arm64/iop-node
```
Node가 Edge에 연결하지 못함:
```bash
nc -vz toki-labs.com 19090
cat ~/iop-field/node.yaml
```
token 불일치:
- Edge config의 `nodes[].token` 값과 bootstrap의 `BOOTSTRAP_NODE_TOKEN` 또는 `IOP_NODE_TOKEN` 값을 맞춘다.
Ollama 호출 실패:
```bash
curl -fsS http://127.0.0.1:11434/api/tags
```
Apple Silicon Mac에서 Node와 Ollama를 함께 실행하는 경우 Edge config의 `nodes[].adapters.ollama.base_url``http://127.0.0.1:11434`여야 한다.
### 9. 정리
Mac의 foreground Node process는 `Ctrl-C`로 종료한다.
재실행은 같은 one-line command를 다시 실행하거나, 이미 받은 binary를 직접 실행한다.
```bash
~/iop-field/iop-node serve --config ~/iop-field/node.yaml
```
현재 활성 작업 좌표는 `agent-roadmap/phase/serving-routing-optimization/milestones/field-bootstrap-test-port-readiness.md`다.
이 Milestone이 끝나기 전까지 field bootstrap 관련 구현, 문서, 테스트 포트 변경은 해당 Milestone 범위로 판단한다. 이 Milestone이 끝나기 전까지 field bootstrap 관련 구현, 문서, 테스트 포트 변경은 해당 Milestone 범위로 판단한다.
## 공통 Ollama 필드 테스트 환경 ## 공통 Ollama 필드 테스트 환경
@ -190,6 +469,8 @@ openai:
- "gemma4:26b" - "gemma4:26b"
session_id: "openai-field" session_id: "openai-field"
timeout_sec: 300 timeout_sec: 300
strict_output: true
strict_stream_buffer: false
nodes: nodes:
- id: "node-toki-labs-ollama" - id: "node-toki-labs-ollama"
@ -259,8 +540,8 @@ Edge/Node 뒤의 소켓 relay가 사용자 UX에 드러나지 않아야 하며,
## Agent Bootstrap / Bridge References ## Agent Bootstrap / Bridge References
OTO 같은 specialized domain agent의 bootstrap/enrollment 상세 계획은 `agent-ops/roadmap/milestones/agent-bootstrap-oto-enrollment.md`에서 관리한다. OTO 같은 specialized domain agent의 bootstrap/enrollment 상세 계획은 `agent-roadmap/milestones/agent-bootstrap-oto-enrollment.md`에서 관리한다.
Agent 설치가 어렵거나 임시 제어만 필요한 대상의 remote terminal bridge 계획은 `agent-ops/roadmap/milestones/remote-terminal-bridge-poc.md`에서 관리한다. Agent 설치가 어렵거나 임시 제어만 필요한 대상의 remote terminal bridge 계획은 `agent-roadmap/milestones/remote-terminal-bridge-poc.md`에서 관리한다.
이 배포 문서에서는 현재 dev field 기준의 배포 단위와 smoke 절차만 유지한다. 이 배포 문서에서는 현재 dev field 기준의 배포 단위와 smoke 절차만 유지한다.

View file

@ -0,0 +1,39 @@
# Field Bootstrap 직접 테스트
Apple Silicon Mac에서 아래만 실행한다.
`<token>`은 그대로 쓰지 말고, 전달받은 실제 테스트 토큰으로 바꾼다.
Edge 설정의 `nodes[].token`과 같은 값이어야 한다.
## 1. Ollama 준비
```bash
ollama pull gemma4:26b
ollama list | grep 'gemma4:26b'
```
## 2. Node 연결
```bash
curl -fsSL http://192.168.0.97:18080/bootstrap/node-darwin-arm64.sh | \
IOP_EDGE_ADDR="192.168.0.97:19090" \
IOP_NODE_TOKEN="<token>" \
bash
```
예를 들어 전달받은 토큰이 `abc123`이면 `IOP_NODE_TOKEN="abc123"`로 바꾼다.
전달받은 토큰이 없으면 실행하지 말고 토큰을 먼저 요청한다.
성공하면 터미널에 `starting node against toki-labs.com:19090`가 보이고, 프로세스가 계속 실행된다.
종료는 `Ctrl-C`.
## 3. 결과 공유
성공/실패와 함께 아래만 공유한다.
```bash
ollama list | grep 'gemma4:26b'
cat ~/iop-field/node.yaml
```
그리고 Node 연결 명령을 실행한 터미널 출력 전체를 함께 보낸다.
공유할 때 `token` 값은 지운다.

View file

@ -48,14 +48,16 @@ type EdgeServerConf struct {
} }
type EdgeOpenAIConf struct { type EdgeOpenAIConf struct {
Enabled bool `mapstructure:"enabled" yaml:"enabled"` Enabled bool `mapstructure:"enabled" yaml:"enabled"`
Listen string `mapstructure:"listen" yaml:"listen"` Listen string `mapstructure:"listen" yaml:"listen"`
NodeRef string `mapstructure:"node" yaml:"node"` NodeRef string `mapstructure:"node" yaml:"node"`
Adapter string `mapstructure:"adapter" yaml:"adapter"` Adapter string `mapstructure:"adapter" yaml:"adapter"`
Target string `mapstructure:"target" yaml:"target"` Target string `mapstructure:"target" yaml:"target"`
Models []string `mapstructure:"models" yaml:"models"` Models []string `mapstructure:"models" yaml:"models"`
SessionID string `mapstructure:"session_id" yaml:"session_id"` SessionID string `mapstructure:"session_id" yaml:"session_id"`
TimeoutSec int `mapstructure:"timeout_sec" yaml:"timeout_sec"` TimeoutSec int `mapstructure:"timeout_sec" yaml:"timeout_sec"`
StrictOutput bool `mapstructure:"strict_output" yaml:"strict_output"`
StrictStreamBuffer bool `mapstructure:"strict_stream_buffer" yaml:"strict_stream_buffer"`
} }
type EdgeA2AConf struct { type EdgeA2AConf struct {
@ -218,6 +220,8 @@ func setEdgeDefaults(v *viper.Viper) {
v.SetDefault("openai.adapter", "ollama") v.SetDefault("openai.adapter", "ollama")
v.SetDefault("openai.session_id", "openai") v.SetDefault("openai.session_id", "openai")
v.SetDefault("openai.timeout_sec", 120) v.SetDefault("openai.timeout_sec", 120)
v.SetDefault("openai.strict_output", true)
v.SetDefault("openai.strict_stream_buffer", false)
v.SetDefault("a2a.enabled", false) v.SetDefault("a2a.enabled", false)
v.SetDefault("a2a.listen", "0.0.0.0:8081") v.SetDefault("a2a.listen", "0.0.0.0:8081")
v.SetDefault("a2a.path", "/a2a") v.SetDefault("a2a.path", "/a2a")

View file

@ -91,6 +91,12 @@ func TestLoadEdge_OpenAIDefaults(t *testing.T) {
if cfg.OpenAI.TimeoutSec != 120 { if cfg.OpenAI.TimeoutSec != 120 {
t.Fatalf("expected openai.timeout_sec=120, got %d", cfg.OpenAI.TimeoutSec) t.Fatalf("expected openai.timeout_sec=120, got %d", cfg.OpenAI.TimeoutSec)
} }
if !cfg.OpenAI.StrictOutput {
t.Fatal("expected openai.strict_output=true")
}
if cfg.OpenAI.StrictStreamBuffer {
t.Fatal("expected openai.strict_stream_buffer=false")
}
} }
func TestLoadEdge_OpenAIOverride(t *testing.T) { func TestLoadEdge_OpenAIOverride(t *testing.T) {
@ -109,6 +115,8 @@ openai:
- "llama-test" - "llama-test"
session_id: "cline" session_id: "cline"
timeout_sec: 45 timeout_sec: 45
strict_output: false
strict_stream_buffer: true
` `
if err := os.WriteFile(f, []byte(yaml), 0o600); err != nil { if err := os.WriteFile(f, []byte(yaml), 0o600); err != nil {
t.Fatalf("write yaml: %v", err) t.Fatalf("write yaml: %v", err)
@ -130,6 +138,12 @@ openai:
if cfg.OpenAI.SessionID != "cline" || cfg.OpenAI.TimeoutSec != 45 { if cfg.OpenAI.SessionID != "cline" || cfg.OpenAI.TimeoutSec != 45 {
t.Fatalf("unexpected openai execution config: %+v", cfg.OpenAI) t.Fatalf("unexpected openai execution config: %+v", cfg.OpenAI)
} }
if cfg.OpenAI.StrictOutput {
t.Fatalf("unexpected openai strict output config: %+v", cfg.OpenAI)
}
if !cfg.OpenAI.StrictStreamBuffer {
t.Fatalf("unexpected openai strict stream buffer config: %+v", cfg.OpenAI)
}
} }
func TestLoadEdge_OllamaContextSize(t *testing.T) { func TestLoadEdge_OllamaContextSize(t *testing.T) {