diff --git a/README.md b/README.md index 21c8674..50ab915 100644 --- a/README.md +++ b/README.md @@ -240,8 +240,8 @@ Portal의 장기 UI 기준은 Flutter 앱이며, 필요한 웹 표면은 Flutter ## 로드맵 -제품 방향, 단계, 마일스톤, 우선순위의 단일 기준 문서는 `agent-ops/roadmap/ROADMAP.md`다. -일반 작업에서 AI가 읽어야 하는 현재 작업 기준은 `agent-ops/roadmap/current.md`가 가리키는 기본 마일스톤 또는 요청에 맞는 활성 마일스톤 문서다. +제품 방향, 단계, 마일스톤, 우선순위의 단일 기준 문서는 `agent-roadmap/ROADMAP.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로 관리한다. diff --git a/agent-roadmap/current.md b/agent-roadmap/current.md index d92c5be..cdc2568 100644 --- a/agent-roadmap/current.md +++ b/agent-roadmap/current.md @@ -7,7 +7,7 @@ ## 활성 Milestone -- [진행중] Field Bootstrap UX와 테스트 포트 정비 +- [검토중] Field Bootstrap UX와 테스트 포트 정비 - Phase: `agent-roadmap/phase/serving-routing-optimization/PHASE.md` - 경로: `agent-roadmap/phase/serving-routing-optimization/milestones/field-bootstrap-test-port-readiness.md` diff --git a/agent-roadmap/phase/serving-routing-optimization/PHASE.md b/agent-roadmap/phase/serving-routing-optimization/PHASE.md index 9be98af..fb2500f 100644 --- a/agent-roadmap/phase/serving-routing-optimization/PHASE.md +++ b/agent-roadmap/phase/serving-routing-optimization/PHASE.md @@ -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` - 요약: 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` - 요약: code-server field 테스트 포트와 Node artifact/bootstrap 경로를 정리해, 사용자가 Jenkins agent처럼 한 줄 명령으로 Node 다운로드와 Edge 접속을 끝낼 수 있게 만든다. diff --git a/agent-roadmap/phase/serving-routing-optimization/milestones/field-bootstrap-test-port-readiness.md b/agent-roadmap/phase/serving-routing-optimization/milestones/field-bootstrap-test-port-readiness.md index 85c92bf..5e613f7 100644 --- a/agent-roadmap/phase/serving-routing-optimization/milestones/field-bootstrap-test-port-readiness.md +++ b/agent-roadmap/phase/serving-routing-optimization/milestones/field-bootstrap-test-port-readiness.md @@ -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] [artifact-layout] Node field build 산출물의 고정 위치와 URL 규칙이 정리되어 있다. 검증: Apple Silicon용 `iop-node`를 정해진 URL에서 다운로드할 수 있다. -- [ ] [one-line-node] 사용자가 한 줄 명령으로 Apple Silicon Node를 다운로드하고 Edge에 접속할 수 있다. 검증: Mac 테스트 환경에서 command 한 줄 실행 후 Edge에서 node registration을 확인한다. -- [ ] [serve-lifecycle] `edge serve`가 field bootstrap에 사용할 node/openai listener를 안정적으로 유지한다. 검증: `edge serve --config ` 실행 후 Node registration과 `/v1/models` 호출이 통과한다. +- [x] [one-line-node] 사용자가 한 줄 명령으로 Apple Silicon Node를 다운로드하고 Edge에 접속할 수 있다. 검증: Mac 테스트 환경에서 command 한 줄 실행 후 Edge에서 node registration을 확인한다. +- [x] [serve-lifecycle] `edge serve`가 field bootstrap에 사용할 node/openai listener를 안정적으로 유지한다. 검증: `edge serve --config ` 실행 후 Node registration과 `/v1/models` 호출이 통과한다. - [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] [user-test-guide] 사용자가 field bootstrap을 직접 테스트할 수 있는 짧은 가이드가 분리되어 있다. 검증: `docs/field-bootstrap-user-test.md`에 Mac 준비, one-line bootstrap, 호출 확인, 문제 발생 시 공유할 출력이 정리되어 있다. ## 완료 리뷰 -- 상태: 없음 -- 요청일: 없음 -- 완료 근거: artifact layout, LLM/작업자용 runbook, 사용자용 직접 테스트 가이드는 정리됐으나, one-line bootstrap UX와 field serve 실구동 검증이 아직 충족되지 않았다. +- 상태: 요청됨 +- 요청일: 2026-05-27 +- 완료 근거: code-server field 포트, Apple Silicon artifact/bootstrap, Mac one-line Node 연결, Edge node registration, `/v1/models`, OpenAI-compatible Cline streaming delta 호출까지 실테스트로 확인했다. - 리뷰 필요: - - [ ] 사용자가 완료 결과를 확인했다 + - [x] 사용자가 완료 결과를 확인했다 - [ ] 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/02+01_one_line_node/PLAN-cloud-G07.md`에서 Apple Silicon one-line bootstrap 실기기 등록 검증을 `01_serve_lifecycle` 의존 작업으로 분리했다. - 후속 작업: 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 이동 승인 diff --git a/agent-task/archive/2026/05/m-field-bootstrap-test-port-readiness/01_serve_lifecycle/code_review_cloud_G07_0.log b/agent-task/archive/2026/05/m-field-bootstrap-test-port-readiness/01_serve_lifecycle/code_review_cloud_G07_0.log new file mode 100644 index 0000000..f34257f --- /dev/null +++ b/agent-task/archive/2026/05/m-field-bootstrap-test-port-readiness/01_serve_lifecycle/code_review_cloud_G07_0.log @@ -0,0 +1,215 @@ + + +# 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-`이면 완료 이벤트 메타데이터를 보고한다. 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 ` 실행 후 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-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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 ` 경로의 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로 이동한다. diff --git a/agent-task/archive/2026/05/m-field-bootstrap-test-port-readiness/01_serve_lifecycle/complete.log b/agent-task/archive/2026/05/m-field-bootstrap-test-port-readiness/01_serve_lifecycle/complete.log new file mode 100644 index 0000000..88f34de --- /dev/null +++ b/agent-task/archive/2026/05/m-field-bootstrap-test-port-readiness/01_serve_lifecycle/complete.log @@ -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 ` + `go run ./apps/node/cmd/node serve --config ` + `/v1/models`) - PASS; `node registered` 로그와 `field-smoke-model` 응답을 확인했다. +- `go test ./...` - PASS; 전체 Go 패키지가 `ok` 또는 `[no test files]`로 종료했다. + +## 잔여 Nit + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/archive/2026/05/m-field-bootstrap-test-port-readiness/01_serve_lifecycle/plan_cloud_G07_0.log b/agent-task/archive/2026/05/m-field-bootstrap-test-port-readiness/01_serve_lifecycle/plan_cloud_G07_0.log new file mode 100644 index 0000000..d150686 --- /dev/null +++ b/agent-task/archive/2026/05/m-field-bootstrap-test-port-readiness/01_serve_lifecycle/plan_cloud_G07_0.log @@ -0,0 +1,239 @@ + + +# 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 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 `와 `go run ./apps/node/cmd/node serve --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 ` 실행 후 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/archive/2026/05/m-field-bootstrap-test-port-readiness/02+01_one_line_node/code_review_cloud_G07_0.log b/agent-task/archive/2026/05/m-field-bootstrap-test-port-readiness/02+01_one_line_node/code_review_cloud_G07_0.log new file mode 100644 index 0000000..cf0e2df --- /dev/null +++ b/agent-task/archive/2026/05/m-field-bootstrap-test-port-readiness/02+01_one_line_node/code_review_cloud_G07_0.log @@ -0,0 +1,371 @@ + + +# 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-`이면 완료 이벤트 메타데이터를 보고한다. 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-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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" < 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. diff --git a/agent-task/archive/2026/05/m-field-bootstrap-test-port-readiness/02+01_one_line_node/complete.log b/agent-task/archive/2026/05/m-field-bootstrap-test-port-readiness/02+01_one_line_node/complete.log new file mode 100644 index 0000000..4f7d152 --- /dev/null +++ b/agent-task/archive/2026/05/m-field-bootstrap-test-port-readiness/02+01_one_line_node/complete.log @@ -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 + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/archive/2026/05/m-field-bootstrap-test-port-readiness/02+01_one_line_node/plan_cloud_G07_0.log b/agent-task/archive/2026/05/m-field-bootstrap-test-port-readiness/02+01_one_line_node/plan_cloud_G07_0.log new file mode 100644 index 0000000..0375b42 --- /dev/null +++ b/agent-task/archive/2026/05/m-field-bootstrap-test-port-readiness/02+01_one_line_node/plan_cloud_G07_0.log @@ -0,0 +1,174 @@ + + +# 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= \ +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//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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/m-field-bootstrap-test-port-readiness/reboot-handoff.md b/agent-task/m-field-bootstrap-test-port-readiness/reboot-handoff.md deleted file mode 100644 index 25e39b9..0000000 --- a/agent-task/m-field-bootstrap-test-port-readiness/reboot-handoff.md +++ /dev/null @@ -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 ` 실행 후 `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= 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/ -``` - - - 외부 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 테스트로 들어가. -``` diff --git a/apps/edge/README.md b/apps/edge/README.md index 90feb87..e2cc17a 100644 --- a/apps/edge/README.md +++ b/apps/edge/README.md @@ -178,6 +178,8 @@ openai: - "qwen3.6:35b-a3b-bf16" session_id: "cline" timeout_sec: 300 + strict_output: true + strict_stream_buffer: false nodes: - id: "node-dgx-01" diff --git a/apps/edge/internal/bootstrap/runtime.go b/apps/edge/internal/bootstrap/runtime.go index 93b5a4f..300859b 100644 --- a/apps/edge/internal/bootstrap/runtime.go +++ b/apps/edge/internal/bootstrap/runtime.go @@ -3,6 +3,7 @@ package bootstrap import ( "context" "fmt" + "sync" "time" "go.uber.org/zap" @@ -29,6 +30,9 @@ type Runtime struct { Service *edgeservice.Service Server *transport.Server Input *edgeinput.Manager + + lifetimeMu sync.Mutex + lifetimeCancel context.CancelFunc } func NewRuntime(cfg *config.EdgeConfig) (*Runtime, error) { @@ -70,23 +74,31 @@ func (r *Runtime) wireHandlers() { } func (r *Runtime) Start(ctx context.Context) error { - r.wireHandlers() - if err := r.Server.Start(ctx); err != nil { + if err := ctx.Err(); err != nil { 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() return err } - go func() { - if err := observability.ServeMetrics(r.Cfg.Metrics.Port); err != nil { - r.Logger.Warn("metrics server exited", zap.Error(err)) - } - }() + if err := r.Input.Start(lifetimeCtx); err != nil { + r.cancelLifetime() + _ = r.Server.Stop() + return err + } + r.startMetrics() return nil } func (r *Runtime) Stop() error { + r.cancelLifetime() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() inputErr := r.Input.Stop(ctx) @@ -97,3 +109,35 @@ func (r *Runtime) Stop() error { } 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)) + } + }() +} diff --git a/apps/edge/internal/bootstrap/runtime_test.go b/apps/edge/internal/bootstrap/runtime_test.go index 85cff3d..c080cb8 100644 --- a/apps/edge/internal/bootstrap/runtime_test.go +++ b/apps/edge/internal/bootstrap/runtime_test.go @@ -1,7 +1,11 @@ package bootstrap import ( + "context" + "net" + "net/http" "testing" + "time" "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) { cfg := &config.EdgeConfig{ 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") } } + +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 +} diff --git a/apps/edge/internal/openai/server.go b/apps/edge/internal/openai/server.go index b41a1fc..3810ad8 100644 --- a/apps/edge/internal/openai/server.go +++ b/apps/edge/internal/openai/server.go @@ -5,9 +5,11 @@ import ( "encoding/json" "errors" "fmt" + "html" "io" "net" "net/http" + "regexp" "strings" "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") return } - prompt := promptFromMessages(req.Messages) - if strings.TrimSpace(prompt) == "" { + basePrompt := promptFromMessages(req.Messages) + if strings.TrimSpace(basePrompt) == "" { writeError(w, http.StatusBadRequest, "invalid_request_error", "messages are required") 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{ NodeRef: s.cfg.NodeRef, @@ -143,12 +166,13 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) { Target: target, SessionID: s.resolveSessionID(), Prompt: prompt, - Input: req.runInput(prompt), + Input: input, TimeoutSec: s.resolveTimeoutSec(), Metadata: map[string]string{ "source": "openai", "openai_model": req.Model, "openai_stream": fmt.Sprintf("%t", req.Stream), + "strict_output": fmt.Sprintf("%t", outputPolicy.Strict), }, }) if err != nil { @@ -158,10 +182,10 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) { defer handle.Close() if req.Stream { - s.streamChatCompletion(w, r, req, handle) + s.streamChatCompletion(w, r, req, handle, outputPolicy) return } - s.completeChatCompletion(w, r, req, handle) + s.completeChatCompletion(w, r, req, handle, outputPolicy) } 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)) } -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) if err != nil { writeError(w, httpStatusForRunError(err), "run_error", err.Error()) 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() writeJSON(w, http.StatusOK, chatCompletionResponse{ 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) if !ok { 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 { select { case <-r.Context().Done(): @@ -263,6 +317,7 @@ func (s *Server) streamChatCompletion(w http.ResponseWriter, r *http.Request, re if event.GetDelta() == "" { continue } + contentBuilder.WriteString(event.GetDelta()) writeSSE(w, flusher, chatCompletionChunk{ ID: id, Object: "chat.completion.chunk", @@ -277,6 +332,10 @@ func (s *Server) streamChatCompletion(w http.ResponseWriter, r *http.Request, re if event.GetDelta() == "" { continue } + reasoningBuilder.WriteString(event.GetDelta()) + if outputPolicy.Strict { + continue + } writeSSE(w, flusher, chatCompletionChunk{ ID: id, 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) { var contentBuilder strings.Builder var reasoningBuilder strings.Builder @@ -396,6 +500,27 @@ func (s *Server) resolveTimeoutSec() int { 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 { var b strings.Builder for _, msg := range messages { @@ -497,10 +622,10 @@ func (m *chatMessage) UnmarshalJSON(b []byte) error { 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{ "prompt": prompt, - "messages": chatMessagesInput(req.Messages), + "messages": chatMessagesInput(messages), } if len(req.Options) > 0 { input["options"] = req.Options @@ -513,6 +638,8 @@ func (req chatCompletionRequest) runInput(prompt string) map[string]any { } if req.Think != nil { input["think"] = req.Think + } else if strictOutput { + input["think"] = false } if len(req.Tools) > 0 { input["tools"] = req.Tools @@ -520,6 +647,140 @@ func (req chatCompletionRequest) runInput(prompt string) map[string]any { 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)]*>.*?`) + 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)`) + 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)) + "\n" +} + +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] + "" + 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 { out := make([]any, 0, len(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 { ID string `json:"id"` Object string `json:"object"` diff --git a/apps/edge/internal/openai/server_test.go b/apps/edge/internal/openai/server_test.go index 6f2cd9e..957da57 100644 --- a/apps/edge/internal/openai/server_test.go +++ b/apps/edge/internal/openai/server_test.go @@ -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: "---\nhidden\n\n\nok\n\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 := "\nok\n" + 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\ndone\n"}, + {"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 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 := "\nplain answer\n" + 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: "---\nnoisea.go"} + 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, "") || strings.Contains(body, "") { + 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: "\n"} + fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok\n"} + 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) { srv := NewServer(config.EdgeOpenAIConf{Target: "fallback-model"}, &fakeRunService{}, nil) req := httptest.NewRequest(http.MethodGet, "/v1/models", nil) diff --git a/bin/build/field-binaries.sh b/bin/build/field-binaries.sh index f92f9fc..f2eb228 100755 --- a/bin/build/field-binaries.sh +++ b/bin/build/field-binaries.sh @@ -22,6 +22,9 @@ Environment: VERSION= release version label COMMIT= commit label BUILD_DATE= build date label + ARTIFACT_BASE_URL= default bootstrap artifact base URL + BOOTSTRAP_EDGE_ADDR= default Edge address written to node.yaml + BOOTSTRAP_NODE_TOKEN= default Node registration token CGO_ENABLED=0 Go CGO setting GOFLAGS="-trimpath" extra go build flags LDFLAGS="-s -w" linker flags @@ -46,6 +49,17 @@ checksum() { 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() { local app="$1" local goos="$2" @@ -60,7 +74,83 @@ build_one() { GOOS="$goos" GOARCH="$goarch" CGO_ENABLED="$CGO_ENABLED" \ 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" </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" < SHA256SUMS ) + if app_enabled node; then + write_node_bootstrap "$goos" "$goarch" + fi done echo "field binaries written to $DIST_DIR/$VERSION" diff --git a/configs/edge.yaml b/configs/edge.yaml index 7e9e024..56df98a 100644 --- a/configs/edge.yaml +++ b/configs/edge.yaml @@ -35,6 +35,8 @@ openai: - "qwen3.6:35b-a3b-bf16" session_id: "cline" timeout_sec: 300 + strict_output: true + strict_stream_buffer: false console: adapter: "cli" diff --git a/docs/architecture.md b/docs/architecture.md index e030e25..b949a0f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -5,7 +5,7 @@ IOP(Inference Operations Platform)는 모델 서빙과 CLI Agent/Automation 실행을 함께 다루는 실행 오케스트레이션 플랫폼을 지향한다. 단순한 OpenAI-compatible proxy나 모델 라우터가 아니라, Control Plane - Edge - Node 계층으로 로컬 실행 그룹을 관리하는 구조를 목표로 한다. 현재 구현은 Edge-Node 실행 스켈레톤을 검증하는 단계다. Control Plane과 worker는 placeholder이며, 세부 계약은 후속 작업에서 정리한다. -제품 Phase와 Milestone 상세는 `agent-ops/roadmap/ROADMAP.md`에서 관리하고, 이 문서는 상대적으로 덜 변하는 구조 원칙과 책임 경계를 설명한다. +제품 Phase와 Milestone 상세는 `agent-roadmap/ROADMAP.md`에서 관리하고, 이 문서는 상대적으로 덜 변하는 구조 원칙과 책임 경계를 설명한다. ## 계층 구조 diff --git a/docs/deploy-dev.md b/docs/deploy-dev.md index 95066e3..b0dc8e2 100644 --- a/docs/deploy-dev.md +++ b/docs/deploy-dev.md @@ -141,7 +141,286 @@ field bootstrap UX는 Jenkins agent 연결처럼 Edge가 command를 제시하고 - 사용자가 실행하는 명령은 한 줄 수준으로 유지하고, 내부에서 다운로드, 실행 권한 부여, config 생성, `iop-node serve`까지 이어진다. - 직접 포트가 아직 적용되지 않았거나 외부망에서 막히면 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= \ +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//darwin-arm64/iop-node +dist/field//darwin-arm64/SHA256SUMS +dist/field//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/ +``` + +사용자는 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 `까지 도달한다. +- 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 범위로 판단한다. ## 공통 Ollama 필드 테스트 환경 @@ -190,6 +469,8 @@ openai: - "gemma4:26b" session_id: "openai-field" timeout_sec: 300 + strict_output: true + strict_stream_buffer: false nodes: - id: "node-toki-labs-ollama" @@ -259,8 +540,8 @@ Edge/Node 뒤의 소켓 relay가 사용자 UX에 드러나지 않아야 하며, ## Agent Bootstrap / Bridge References -OTO 같은 specialized domain agent의 bootstrap/enrollment 상세 계획은 `agent-ops/roadmap/milestones/agent-bootstrap-oto-enrollment.md`에서 관리한다. -Agent 설치가 어렵거나 임시 제어만 필요한 대상의 remote terminal bridge 계획은 `agent-ops/roadmap/milestones/remote-terminal-bridge-poc.md`에서 관리한다. +OTO 같은 specialized domain agent의 bootstrap/enrollment 상세 계획은 `agent-roadmap/milestones/agent-bootstrap-oto-enrollment.md`에서 관리한다. +Agent 설치가 어렵거나 임시 제어만 필요한 대상의 remote terminal bridge 계획은 `agent-roadmap/milestones/remote-terminal-bridge-poc.md`에서 관리한다. 이 배포 문서에서는 현재 dev field 기준의 배포 단위와 smoke 절차만 유지한다. diff --git a/docs/field-bootstrap-user-test.md b/docs/field-bootstrap-user-test.md new file mode 100644 index 0000000..adcdc3b --- /dev/null +++ b/docs/field-bootstrap-user-test.md @@ -0,0 +1,39 @@ +# Field Bootstrap 직접 테스트 + +Apple Silicon Mac에서 아래만 실행한다. +``은 그대로 쓰지 말고, 전달받은 실제 테스트 토큰으로 바꾼다. +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="" \ +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` 값은 지운다. diff --git a/packages/config/config.go b/packages/config/config.go index da65997..b68d7dc 100644 --- a/packages/config/config.go +++ b/packages/config/config.go @@ -48,14 +48,16 @@ type EdgeServerConf struct { } type EdgeOpenAIConf struct { - Enabled bool `mapstructure:"enabled" yaml:"enabled"` - Listen string `mapstructure:"listen" yaml:"listen"` - NodeRef string `mapstructure:"node" yaml:"node"` - Adapter string `mapstructure:"adapter" yaml:"adapter"` - Target string `mapstructure:"target" yaml:"target"` - Models []string `mapstructure:"models" yaml:"models"` - SessionID string `mapstructure:"session_id" yaml:"session_id"` - TimeoutSec int `mapstructure:"timeout_sec" yaml:"timeout_sec"` + Enabled bool `mapstructure:"enabled" yaml:"enabled"` + Listen string `mapstructure:"listen" yaml:"listen"` + NodeRef string `mapstructure:"node" yaml:"node"` + Adapter string `mapstructure:"adapter" yaml:"adapter"` + Target string `mapstructure:"target" yaml:"target"` + Models []string `mapstructure:"models" yaml:"models"` + SessionID string `mapstructure:"session_id" yaml:"session_id"` + 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 { @@ -218,6 +220,8 @@ func setEdgeDefaults(v *viper.Viper) { v.SetDefault("openai.adapter", "ollama") v.SetDefault("openai.session_id", "openai") 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.listen", "0.0.0.0:8081") v.SetDefault("a2a.path", "/a2a") diff --git a/packages/config/config_test.go b/packages/config/config_test.go index 80438c2..b74f32e 100644 --- a/packages/config/config_test.go +++ b/packages/config/config_test.go @@ -91,6 +91,12 @@ func TestLoadEdge_OpenAIDefaults(t *testing.T) { if cfg.OpenAI.TimeoutSec != 120 { 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) { @@ -109,6 +115,8 @@ openai: - "llama-test" session_id: "cline" timeout_sec: 45 + strict_output: false + strict_stream_buffer: true ` if err := os.WriteFile(f, []byte(yaml), 0o600); err != nil { t.Fatalf("write yaml: %v", err) @@ -130,6 +138,12 @@ openai: if cfg.OpenAI.SessionID != "cline" || cfg.OpenAI.TimeoutSec != 45 { 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) {