# SDD: Node Provider 실행 Liveness 관측과 안전 복구

## 위치

- Milestone: [Milestone 문서](../../../phase/operational-observability-provider-management/milestones/node-provider-execution-liveness-recovery.md)
- Phase: [PHASE.md](../../../../phase/operational-observability-provider-management/PHASE.md)

## 상태

[승인됨]

## SDD 잠금

- 상태: 해제
- 사용자 리뷰: 없음
- 잠금 항목: 없음

## 문제 / 비목표

- 문제: 현재 Node 실행 경로에는 provider request가 terminal 없이 멈췄을 때 request liveness를 판정하고 provider 전체 health를 별도 점검한 뒤 old attempt를 fence하여 Edge 복구로 넘기는 공통 pipeline이 없다. CLI persistent idle은 일부 profile에서 `idle-timeout`을 정상 complete로 취급하고, Node heartbeat·process/socket 생존과 독립 provider probe 성공만으로는 원 요청이 실제 추론 중인지 알 수 없다.
- 비목표:
  - IOP Node를 거치지 않는 직접 Pi/provider 호출과 Chronos Server/Node 또는 외부 agent runtime 감시
  - content 반복, tool-call/schema/품질 검증과 queue wait 정책 변경
  - provider가 progress event를 내지 않을 때 내부 reasoning 상태 추정
  - provider runtime restart, credential/login 또는 model lifecycle 자동화

## Source of Truth

| 영역 | 기준 | 메모 |
|------|------|------|
| Roadmap | [Milestone 문서](../../../phase/operational-observability-provider-management/milestones/node-provider-execution-liveness-recovery.md) | 목표, 기능 Task와 완료 범위 |
| Code | `apps/node/internal/node/run_handler.go`, `run_manager.go`, `runtime_sink.go`, `tunnel_handler.go` | per-run/tunnel observer, cancel과 terminal fencing owner |
| Code | `packages/go/execution/types.go`, `failure.go`, `emitter.go` | provider-neutral activity, `ProviderProber`, typed failure와 terminal 계약 |
| Code | `apps/edge/internal/service/provider_tunnel.go`, `model_queue_release.go`, `run_cancel.go` | immutable dispatch-provider binding, provider lease·admission·routing, disconnect settlement과 cancel transport owner |
| Code | `packages/go/streamgate`, `apps/edge/internal/openai` | OpenAI response commit, request-local recovery budget, attempt abort/rebuild/dispatch owner |
| Config | `packages/go/config`, `configs/edge.yaml` | provider-first liveness timeout과 Node payload source of truth |
| Contract | [Provider Execution Runtime 계약](../../../../../agent-contract/inner/execution-runtime.md) | provider run/event/probe/failure 의미 |
| Contract | [Edge-Node Runtime Wire 계약](../../../../../agent-contract/inner/edge-node-runtime-wire.md) | normalized run/tunnel terminal과 cancel ordering |
| Contract | [Edge Config/Refresh 계약](../../../../../agent-contract/inner/edge-config-runtime-refresh.md) | provider liveness 설정과 generation isolation |
| Spec | [Edge-Node Provider Execution](../../../../../agent-spec/runtime/edge-node-execution.md), [Stream Evidence Gate](../../../../../agent-spec/runtime/stream-evidence-gate.md), [Provider Pool Config/Refresh](../../../../../agent-spec/runtime/provider-pool-config-refresh.md) | 현재 구현된 transport heartbeat, commit/recovery와 provider config 기준 |
| User Decision | 2026-07-29 사용자 대화 | Node 관측 pipeline이 감시를 소유하고, 5분 이상 응답이 없으면 health 분류 후 안전한 요청을 재실행한다. |

## State Machine

| 상태 | 진입 조건 | 다음 상태 | 근거 |
|------|-----------|-----------|------|
| request/observing | Node가 run/tunnel attempt를 실행하고 start clock을 시작함 | request/observing / request/stall-detected / request/terminal | provider-originated activity, monotonic clock, terminal |
| request/observing | non-empty text/reasoning/response-start/body/usage 또는 명시 provider progress가 도착함 | request/observing | Node liveness observer가 last-progress를 갱신 |
| request/observing | current connection이 먼저 `heartbeat_timeout` 또는 disconnect로 종료됨 | request/transport-lost | connection-generation과 provider lease를 fence하고 request stall metadata를 만들지 않음 |
| request/transport-lost | ingress의 기존 wait timeout, caller cancel 또는 transport close가 확정됨 | request/terminal | raw tunnel subscriber가 disconnect만으로 즉시 닫히지 않는 현재 경로에서도 기존 ingress terminal owner가 exactly once로 수렴 |
| request/stall-detected | last-progress 이후 configured threshold가 지나고 terminal이 없음 | request/fencing + provider/probing | Node watchdog이 stall을 단 한 번 확정하고 cancel/close와 bounded probe를 시작 |
| request/fencing | Node가 old attempt의 emission authority를 철회하고 local transport/execution ownership을 닫음 | request/stalled (`attempt_fence=confirmed`) | late event를 drop할 수 있는 local generation fence |
| request/fencing | bounded cancel/close가 실패하거나 완료 여부를 확정할 수 없음 | request/terminal (`attempt_fence=unconfirmed`) | 중복 output/dispatch 위험 때문에 자동 재실행 금지 |
| provider/probing | target-aware probe가 `available`을 반환함 | provider/available | provider 전체는 available이지만 원 request stall은 유지 |
| provider/probing | probe가 `unavailable` 또는 명시 target unavailable을 반환함 | provider/unavailable | Node provider health evidence |
| provider/probing | prober 미지원, timeout 또는 판정 불가 오류 | provider/unknown | provider health를 추정하지 않는 fallback |
| provider/unavailable | current Edge connection에서 같은 provider/adapter/target의 이후 probe가 더 큰 observation sequence로 `available`을 반환함 | provider/available | stale connection/sequence 또는 mismatched evidence가 아닌 fresh recovery evidence |
| request/stalled | ingress recovery owner가 uncommitted·uncanceled·side-effect-safe·budget-available로 판정함 | request/redispatched | host-owned commit boundary와 recovery coordinator |
| request/redispatched | recovery owner가 새 run/attempt identity를 발급함 | request/observing | 새 Node execution generation |
| request/stalled | recovery owner 없음, post-commit, cancel, side effect, budget 소진 또는 provider 후보 없음 | request/terminal | typed `response_stalled` terminal |
| request/terminal | complete/error/cancelled, ingress transport terminal 또는 liveness terminal이 exactly once 확정됨 | 없음 | terminal emitter/ingress terminal owner와 old generation fence |

## Interface Contract

- 계약 원문: [Provider Execution Runtime 계약](../../../../../agent-contract/inner/execution-runtime.md), [Edge-Node Runtime Wire 계약](../../../../../agent-contract/inner/edge-node-runtime-wire.md), [Edge Config/Refresh 계약](../../../../../agent-contract/inner/edge-config-runtime-refresh.md)
- 입력:
  - `nodes[].providers[].response_stall_timeout_ms`: 생략/`0`이면 `300000`, 양수이면 provider별 override, 음수이면 config 오류다. provider-first config가 Node adapter/runtime observation config로 전달되며 provider config가 없는 legacy adapter route도 기본 `300000`을 사용한다. 변경은 다른 provider-first execution field와 같이 `restart_required`로 분류한다.
  - timeout precedence: request hard deadline이나 current connection의 `heartbeat_timeout`/disconnect가 no-progress threshold보다 먼저 끝나면 각각 기존 deadline/transport 경계를 유지한다. 현재 Edge/Node의 30초 heartbeat interval과 45초 response wait는 connection-generation liveness이며 `response_stall_timeout_ms`는 queue timeout, request 전체 timeout, transport liveness와 CLI profile의 `response_idle_timeout_ms` completion heuristic을 대체하지 않는다.
  - current timeout baseline: 현재 OpenAI/A2A/Console 기본 hard timeout은 `120s`, service fallback은 `30s`다. 기본 `response_stall_timeout_ms=300000`은 이 기본 경로에서 hard timeout보다 먼저 발동하지 않으며, effective request timeout이 300초보다 길거나 provider override가 더 짧을 때만 stall 판정이 도달 가능하다. 이 관계를 자동으로 timeout 값 변경이나 config 오류로 재해석하지 않는다.
  - normalized activity: non-empty `delta`, `reasoning_delta`와 명시 provider progress. `start`는 clock 시작점이고 complete/error/cancelled terminal은 observer를 종료하지만 last-progress를 갱신하지 않는다.
  - tunnel activity: provider response start/header, non-empty body와 usage. END/ERROR terminal은 observer를 종료하지만 last-progress를 갱신하지 않으며 빈 frame, Node/Edge heartbeat, socket/process 생존은 progress가 아니다.
  - provider probe: stalled attempt의 adapter/target에 대한 bounded `ProviderProber` 결과. probe는 원 request와 별도 context에서 실행한다.
  - recovery eligibility: ingress recovery owner가 소유한 response commit, caller cancel, shared request-level fault budget, tool/비가역 side effect, provider-pool candidate와 request idempotency 상태. Node는 이 값을 계산하지 않는다.
- 출력:
  - common failure: stable `response_stalled` failure code. 기존 `Failure.retryable`은 `attempt_fence=confirmed`일 때만 true가 될 수 있는 capability hint이며 response commit, side effect와 budget을 포함한 재실행 승인은 ingress recovery owner가 별도로 판정한다.
  - wire terminal: normalized run은 exactly-once `RunEvent{type=error}`, tunnel은 exactly-once `ProviderTunnelFrame{kind=ERROR}`로 수렴한다.
  - safe Node metadata: `failure_code=response_stalled`, `provider_health=available|unavailable|unknown`, `idle_duration_ms`, `run_id`, `attempt_id`, `attempt_fence=confirmed|unconfirmed`, adapter/target identity와 `health_observation_seq`; raw provider body, reasoning, prompt, credential과 Edge-owned `recovery_eligible`은 넣지 않는다.
  - provider identity: Node의 `health_observation_seq`는 connection 안에서만 단조 증가한다. `liveness-observer` slice는 adapter/target과 observation sequence를 포함한 Node evidence 생성까지 소유한다. Edge의 binding 검증은 `recovery-handoff` slice에서 evidence를 수신한 registry connection generation에 묶은 뒤 immutable `RunDispatch`의 `(node_id, provider_id, adapter, target)`과 대조한다. stale connection/sequence 또는 identity mismatch evidence는 health projection에 적용하지 않는다.
  - provider projection: `recovery-handoff` slice는 config health를 immutable config snapshot으로 유지하고 runtime health overlay를 `(node_id, connection_generation, provider_id)`에 별도 관리한다. `unavailable` probe만 bound provider candidate를 runtime unhealthy로 낮추고, 이후 bounded status probe가 낸 current connection의 같은 provider/adapter/target `available` evidence와 더 큰 observation sequence가 있어야 다시 활성화한다. immutable dispatch에 stable `provider_id`가 없거나 adapter/target identity가 맞지 않으면 request terminal evidence만 보존하고 health overlay는 갱신하지 않는다. request-stalled/available과 health-unknown은 provider 전체 장애로 승격하지 않는다.
  - recovery: OpenAI-compatible host는 typed stall을 기존 StreamGate recovery cause/intent로 변환하고 `transport_uncommitted`에서만 기존 request-local coordinator의 공유 fault budget을 소비해 새 `run_id`와 attempt identity를 발급한다. 별도 liveness retry counter는 없다. recovery owner가 없는 surface는 typed terminal로 끝난다. 현재 `AttemptController.AbortAttempt`의 cancel 전송 성공만으로 Node local fence를 추정하지 않고, Node terminal의 `attempt_fence=confirmed`와 request-local transport close를 모두 만족해야 다음 dispatch를 허용한다.
- 금지:
  - Node/Edge heartbeat, TCP 연결, process 생존이나 독립 probe 성공을 원 request의 추론 진행 증거로 사용하지 않는다.
  - 현재 CLI persistent `idle-timeout` complete를 liveness failure로 재해석하거나 새 watchdog을 provider별 구현에 복제하지 않는다.
  - Node, Edge와 agent가 동시에 retry loop를 소유하지 않는다. Node는 detect/probe/cancel/local fence, Edge service는 lease/admission/routing, ingress recovery host는 commit/eligibility/retry를 소유한다.
  - 외부 응답 또는 비가역 side effect commit 뒤 blind replay하지 않는다.
  - old attempt를 terminal 뒤 되살리거나 lease를 두 번 반환하지 않는다.

## Acceptance Scenarios

| ID | Milestone Task | Given | When | Then |
|----|----------------|-------|------|------|
| S01 | `activity-contract` | normalized run과 raw tunnel이 provider default/override 설정으로 실행 중이고 일부 request hard timeout은 stall timeout보다 짧음 | provider text/reasoning/response-start/body, terminal, Node heartbeat, 더 이른 hard deadline과 transport disconnect가 각각 발생함 | provider-originated activity만 last-progress를 갱신하고 terminal은 observer를 종료하며, 짧은 hard timeout과 `heartbeat_timeout`은 stall로 재분류되지 않고 기존 terminal/transport 경계로 수렴한다. |
| S02 | `stall-watchdog` | terminal 없이 configured threshold 동안 provider progress가 없음 | watchdog, 늦은 provider event와 cancel/close success 또는 failure가 경쟁함 | stall/terminal과 local attempt fence가 한 번만 확정되고 confirmed일 때 old event가 drop되며 unconfirmed일 때 자동 재실행이 금지된다. |
| S03 | `health-classification` | request stall이 확정됨 | Node의 target probe가 available/unavailable/unsupported 또는 timeout을 반환함 | Node가 request stall과 provider health를 분리해 available/unavailable/unknown, adapter/target과 connection-scoped observation sequence evidence를 만들고 probe 성공을 원 요청 progress로 기록하지 않는다. |
| S04 | `failure-handoff` | normalized run과 tunnel이 각각 stall되고 Edge가 immutable dispatch binding을 소유함 | Node typed terminal과 stable provider identity 없음, stale connection/sequence, identity mismatch 또는 fresh recovery evidence가 도착함 | 두 path가 같은 failure/health/fence 의미를 보존하고 Node metadata에 recovery eligibility가 없으며, Edge는 current bound evidence만 runtime health overlay와 회복에 적용하고 old attempt lease를 정확히 한 번 정리한다. |
| S05 | `bounded-retry` | OpenAI 미커밋 request, post-commit request, unconfirmed fence와 recovery owner가 없는 request가 각각 stall됨 | ingress host가 recovery를 평가함 | confirmed·미커밋·side-effect-safe request만 StreamGate 공유 fault budget 안에서 새 run identity로 재실행되고 나머지는 terminal로 끝난다. |
| S06 | `ops-evidence` | provider-available request stall, provider-unhealthy, stale health evidence와 후속 recovery가 발생함 | Node/Edge metric·log와 provider snapshot을 조회함 | request liveness, fence/probe와 Edge commit/recovery 결정이 분리되고 stale evidence가 거부되며 high-cardinality/raw content가 노출되지 않는다. |

## Evidence Map

| Scenario | Required Evidence | `agent-task` 연결 | 완료 Evidence 기대 |
|----------|-------------------|------------------|---------------------------|
| S01 | config validation과 fake clock 기반 normalized/tunnel activity/deadline/transport table test | `agent-task/m-node-provider-execution-liveness-recovery/...` | `activity-contract` Task id, default/override/negative, terminal stop, activity reset, shorter hard timeout과 transport precedence assertion |
| S02 | threshold·timer/event·cancel/close race와 exactly-once terminal/fence test | `agent-task/m-node-provider-execution-liveness-recovery/...` | `stall-watchdog` Task id, confirmed/unconfirmed fixture와 late-event fence assertion |
| S03 | available/unavailable/unsupported/timeout target prober fixture와 Node evidence 생성 test | `agent-task/m-node-provider-execution-liveness-recovery/...` | `health-classification` Task id, request/provider 분리, adapter/target/observation sequence와 원 요청 progress 비갱신 assertion |
| S04 | RunEvent/ProviderTunnelFrame wire round-trip, provider identity 없음, stale connection/sequence, identity mismatch, fresh recovery와 queue lifecycle test | `agent-task/m-node-provider-execution-liveness-recovery/...` | `failure-handoff` Task id, stable code/fence metadata, no recovery eligibility, fail-closed binding validation, runtime health overlay recovery와 release-once assertion |
| S05 | StreamGate commit-boundary/shared-budget, provider-pool failover와 no-owner terminal test | `agent-task/m-node-provider-execution-liveness-recovery/...` | `bounded-retry` Task id, recovery-owner gating, new run identity와 bounded dispatch count assertion |
| S06 | Node/Edge metric label guard, structured log capture와 provider snapshot overlay recovery test | `agent-task/m-node-provider-execution-liveness-recovery/...` | `ops-evidence` Task id, liveness/fence/health/commit/recovery 축과 raw-free evidence |

## Cross-repo Dependencies

- 없음

## Drift Check

- [x] Milestone 기능 Task와 Acceptance Scenario가 일치한다.
- [x] Evidence Map이 code-review/complete.log에서 검증 가능하다.
- [x] agent-contract를 쓰는 경우 SDD에 계약 원문을 복제하지 않았다.
- [x] 사용자 리뷰가 필요한 항목은 `USER_REVIEW.md`에만 남겼다.
- [x] 2026-08-03 현재 `packages/go/execution`, Edge-Node 30초/45초 transport heartbeat, 30초/120초 hard timeout, provider tunnel disconnect lifecycle과 StreamGate abort/recovery seam을 다시 확인했다.

## 사용자 리뷰 이력

- 2026-07-29: 사용자가 agent가 아니라 IOP 내부 Node 관측 pipeline이 감시를 소유하고, 5분 no-response 뒤 provider health를 분리 판정해 재요청하는 방향을 승인했다.
- 2026-08-03: 사용자가 D01 추천안을 승인했다. `health-classification`은 Node-side probe 분류와 evidence 생성까지 소유하고, Edge runtime health overlay의 binding 검증·unhealthy/recovery 적용은 `failure-handoff`에서 ingress recovery host와 함께 구현한다.

## 작업 컨텍스트

- 표준선: Node는 execution-local liveness, local attempt fence와 probe evidence를 소유한다. Edge service는 provider lease·candidate eligibility를, ingress recovery host는 response commit·bounded retry를 소유한다. Control Plane은 projection을 소비할 수 있지만 canonical 실행 상태나 watchdog을 소유하지 않는다.
- 재사용 기준: OpenAI-compatible 경로는 [OpenAI-compatible 출력 검증 필터 SDD](../../../../sdd/knowledge-tool-optimization-extension/openai-compatible-output-validation-filters/SDD.md)의 StreamGate commit/recovery 경계를 사용한다. liveness failure는 Node 관측 결과를 소비하는 recovery cause/intent이며 별도 output content filter나 retry coordinator가 아니다.
- 현재 구현 차이: `response_stalled` failure/wire metadata, provider runtime health overlay와 `response_stall_timeout_ms`는 아직 구현되지 않았다. raw tunnel subscriber도 Node disconnect만으로 즉시 닫히지 않고 ingress wait timeout/cancel에 의존한다. 기존 `ProviderProber`, terminal emitter, provider tunnel release-once와 StreamGate recovery coordinator를 확장하며 구현 완료로 간주하지 않는다.
- 계획 분할 기준: `liveness-observer`의 `health-classification`은 Node observer/watchdog/probe와 adapter/target/observation sequence evidence 생성까지 구현한다. Edge binding 검증과 runtime health overlay의 unhealthy/recovery 적용은 `recovery-handoff`의 `failure-handoff`에서 ingress recovery host와 함께 구현한다. 후자는 plan 생성 시 [IOP 실행 프리셋과 Hot Path](../../knowledge-tool-optimization-extension/iop-hot-path-one-shot-execution/SDD.md)의 최신 OpenAI/StreamGate 변경을 다시 확인한다.
- 후속 SDD: [요청 실행 로그와 Usage Ledger 기반 SDD](../../../../sdd/operational-observability-provider-management/request-execution-log-usage-ledger-foundation/SDD.md)
