Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@ jobs:
steps:
- uses: actions/checkout@v5

# Matches the tested runtime and `engines.node` — publishing must not run
# on a version the suite never exercises (this was node 20, which is EOL).
- uses: actions/setup-node@v5
with:
node-version: 20
node-version: 24

# A tag on a commit whose manifests were never bumped would publish a
# release that does not match what the plugin reports as its version.
Expand Down
20 changes: 17 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,17 @@ jobs:
runs-on: ubuntu-latest
strategy:
fail-fast: false
# Single entry on purpose. Node 18 (EOL 2025-04-30) and 20 (EOL 2026-04-30)
# were both past end-of-life, and the node:test runner bug that made this
# suite flake (nodejs/node#64061 — the result-payload length was read as a
# signed int) is never reaching them. The fix landed in 26.7.0 and was
# backported to the v24 line only; v22 did not get it, so adding "22 for
# LTS coverage" would reintroduce the flake rather than broaden coverage.
# `24` resolves to the latest 24.x, which carries the fix from 24.20.0
# (2026-08-26) onward. Add versions back here when they are both supported
# and carry the fix.
matrix:
node: [18, 20]
node: [24]
Comment thread
hsleedevelop marked this conversation as resolved.
Resolved
steps:
- uses: actions/checkout@v5

Expand Down Expand Up @@ -52,9 +61,14 @@ jobs:
done
return 0
}
grep -E '^# spawn floor' test-output.txt | annotate notice || true
# Match on content, not on a reporter prefix: node <=20 defaults to the
# TAP reporter ("# diagnostic", "not ok 1 - name", " error: '...'")
# while node >=22 defaults to spec ("i diagnostic", "x name",
# "AssertionError [ERR_ASSERTION]: ..."). Anchoring on "^# " silently
# produced empty annotations the first time this ran on node 24.
grep -E 'spawn floor .*equal-work baseline' test-output.txt | annotate notice || true
if [ "$status" -ne 0 ]; then
grep -E '^not ok |error: |exceeded the absolute ' test-output.txt \
grep -E '^not ok |^✖ |AssertionError|^ *error: |exceeded the absolute ' test-output.txt \
| head -40 | annotate error || true
fi
exit "$status"
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,24 @@ modified: 2026-08-21

## [Unreleased]

### Changed
- **BREAKING: 최소 Node 버전을 18 → 24로 올렸다** (`engines.node: ">=24"`, task
`node-test-runner-flake`). 두 가지가 겹친 결과다. ① Node 18(2025-04-30)·20(2026-04-30)이
**둘 다 지원 종료**라 CI가 EOL 런타임만 시험하고 있었고, 릴리스 발행 job도 node 20에서
돌고 있었다. ② `node:test` 러너의 결과 payload 길이를 **부호 있는 정수로 읽는 버그**
(nodejs/node#64061)가 CI를 간헐적으로 빨갛게 만들었는데, 수정(nodejs/node#64706)은
26.7.0에 들어간 뒤 **v24 라인에만 백포트**됐다 — v22는 활성 LTS인데도 받지 못했다.
그래서 "활성 LTS 두 개(22·24)"가 아니라 **24 단독**이다. 22를 넣으면 flake가 되돌아온다.
- CI 테스트 matrix를 `[18, 20]` → `[24]`로, 릴리스 발행 런타임을 20 → 24로 옮겼다.
README·`docs/prerequisites.md`의 "Node.js ≥ 18" 서술도 함께 갱신했다.

### Notes
- 이 수정이 실제로 flake를 없애는 것은 **Node 24.20.0(2026-08-26) 이후**다. `node-version: 24`는
최신 24.x로 해석되는데, 그 이전 최신인 24.19.0에는 아직 백포트가 반영돼 있지 않다.
- CI 워크플로우가 테스트 출력을 **annotation으로 되돌린다** — 이 저장소를 유지보수하는
머신은 raw CI 로그를 받을 수 없어(blob storage 403) `gh run view --log-failed`가 실패한다.
`gh api repos/<owner>/<repo>/check-runs/<job_id>/annotations`로 읽는다.

## [0.18.1] - 2026-08-22

### Added
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,13 +163,13 @@ cd my-project

## 사전 준비

**하드 요구사항은 Node.js ≥ 18 하나뿐입니다** (`engines.node`, 런타임 npm 의존성 0개).
**하드 요구사항은 Node.js ≥ 24 하나뿐입니다** (`engines.node`, 런타임 npm 의존성 0개).
Comment thread
hsleedevelop marked this conversation as resolved.
Resolved
나머지는 "있으면 켜지고 없으면 그 기능만 꺼지는" 능력 매트릭스입니다.
`git` 아래 다섯 개는 `harness-team doctor`가 런타임에 유무를 확인해 줍니다.

| 도구 | 없으면 |
|---|---|
| Node.js ≥ 18 | `harness-team` CLI가 실행되지 않음 — 유일한 하드 요구사항 |
| Node.js ≥ 24 | `harness-team` CLI가 실행되지 않음 — 유일한 하드 요구사항 |
| `jq` | Claude Code 훅이 **저정밀 모드**로 판정 — 차단은 유지되지만 정확도가 떨어짐. 다른 넷과 달리 doctor가 `warning`으로 알림 |
| `git` | CLI는 동작하되 post-commit handoff·summary 브랜치 감지·task 전환 diff가 no-op |
| `gh` | `/harness-ship` 이후 **사용자가 직접 여는** PR 단계용 — 하네스 명령은 영향 없음 |
Expand Down
217 changes: 217 additions & 0 deletions docs/chad/node-test-runner-flake/node-test-runner-flake-artifact.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
# node-test-runner-flake — Artifact

*최종 결과물과 학습 내용을 기록한다.*

## 결과

**미착수 — 원인 확정까지만 진행했다.** 남은 것은 기술 문제가 아니라 지원 Node 범위
Comment thread
hsleedevelop marked this conversation as resolved.
Resolved
**정책 결정**이며(plan 6단계), spec의 Ambiguity 게이트를 의도적으로 미통과 상태로 뒀다.

한 줄 요약: `node:test` 러너의 업스트림 버그(nodejs/node#64061, `#processRawBuffer`가
payload 길이를 부호 있는 정수로 읽음)다. 수정은 Node 26.7.0에 들어갔고 **node 18에는
영구히 도달하지 않는다 — 이미 EOL이다.** 상세는 아래 "조사 기록".

## 조사 기록 (2026-08-25) — 구현 전, 원인 확정까지

이 task는 **구현이 시작되지 않은 상태**로 생성됐다. 아래는 원인 확정까지의 근거이며,
남은 것은 기술 문제가 아니라 지원 범위 **정책 결정**이다 (plan 6단계).

### 어떻게 발견됐나

perf flake(PR #44)를 고치면서 workflow에 annotation 계측을 넣었다 —
이 저장소를 유지보수하는 머신은 raw CI 로그를 받을 수 없기 때문이다
(`*.blob.core.windows.net` 403). 계측을 넣은 **첫 실행**에서 곧바로 잡혔다:

```
not ok 38 - tests/task-templates.test.mjs
error: 'Unable to deserialize cloned data due to invalid or unsupported version.'
```

계측이 없었다면 이 실패는 "test (18) failed, exit code 1"로만 보였을 것이고,
perf flake와 구분되지 않은 채 같은 원인으로 오인됐을 것이다.
**"CI가 무작위로 빨개진다"에는 최소 두 개의 서로 다른 원인이 섞여 있었다.**

### 우리 코드가 아니라는 근거

| 근거 | 내용 |
|---|---|
| 파일 내용 | `tests/task-templates.test.mjs`는 템플릿 문자열 assertion + fs 조작만. 자식 프로세스 스폰 없음, 직렬화 사용 없음 |
| 동일 코드 통과 이력 | 같은 테스트 코드로 앞선 실행(`af6ae4c7`)은 두 job 모두 통과 |
| 재실행 통과 | 실패 job만 재실행(attempt 2)하니 통과 → 전이성 |
| 스택 위치 | `node:internal/test_runner/runner`의 `#processRawBuffer` — 우리가 호출하지 않는 내부 함수 |
| 버전 편향 없음 | 과거 40회 실행 기준 실패율 node 18 7/39 · node 20 7/40 — 특정 버전 편향 아님 |

### 업스트림 근본 원인

- **nodejs/node#64061** — 증상 보고. `#processRawBuffer`에서 발생. "로컬 간헐적, CI 재현 잘 됨".
Appium은 Node 24.10.0에서 관측 — **18 전용 버그가 아니다.**
- **nodejs/node#64706** — 수정. payload 길이를 **부호 있는 정수**로 읽던 것을
`>>> 0`으로 부호 없는 변환. `child_process`에 이미 쓰이던 패턴.
payload가 커져 최상위 비트가 서면 길이가 무효가 되어 역직렬화가 깨진다.
동시 실행 worker가 결과를 스트리밍할 때 payload가 커지므로 CI에서 잘 터진다.
- 머지 2026-07-26 → **Node 26.7.0 (2026-08-05)** 릴리스에 포함.

### 결정적 제약 — matrix가 전부 지원 종료 런타임이다

nodejs/Release 공식 스케줄 기준 (2026-08-25 현재):

| 버전 | EOL | 상태 | 우리 사용처 |
|---|---|---|---|
| **18** | 2025-04-30 | **종료 (1년 4개월 경과)** | `test.yml` matrix · `engines: ">=18"` |
| **20** | 2026-04-30 | **종료 (4개월 경과)** | `test.yml` matrix · `release.yml` 발행 |
| 22 | 2027-04-30 | 활성 LTS | — |
| 24 | 2028-04-30 | 활성 LTS | — |

**node 18에는 이 수정이 절대 도달하지 않는다** — 이미 EOL이다.
따라서 "node 18에서 이 flake를 고친다"는 업스트림 경로로 달성 불가능하며,
이것이 후보 (A)(활성 LTS로 이동)를 권장하는 근거다.

부수 발견: **릴리스 발행(`release.yml`)도 EOL 런타임(node 20)에서 돈다.**
이 task의 원래 범위 밖이지만 같은 결정에 묶인다.

### 정정 (2026-08-25) — 백포트 실측으로 초기 권장안을 뒤집었다

초기 보고는 "활성 LTS(22/24)로 이동하면 EOL 문제와 flake가 함께 해소된다"였다.
**틀렸다.** 오케스트레이터가 "수정이 26.7.0에 들어갔다면 22/24로 옮겨도 flake는 남는 것 아닌가"를
지적했고, 확인 결과 그 지적이 맞았다 — 다만 24는 예외였다.

추정하지 않고 릴리스 라인별 소스를 직접 대조했다:

```
$ gh api "repos/nodejs/node/contents/lib/internal/test_runner/runner.js?ref=<branch>" \
--jq .content | base64 -d | grep -c '>>> 0) + kSerializedSizeHeader'
v26.x=1 v24.x=0 v22.x=0 v20.x=0 v18.x=0
v26.x-staging=1 v24.x-staging=1 v22.x-staging=0
```

| 라인 | 취약 코드 | 수정 | 최신 릴리스 | 결론 |
|---|---|---|---|---|
| v18.x | 있음 | 없음 | — | **영구 잔존** (EOL) |
| v20.x | 있음 | 없음 | — | **영구 잔존** (EOL) |
| v22.x | 있음 | **없음** (staging도) | 22.23.2 (2026-07-29) | **잔존** — 활성 LTS인데 백포트 없음 |
| v24.x | 있음 | **staging에 있음** | 24.19.0 (2026-08-03, 취약) | **24.20.0부터 해소** |
| v26.x | 있음 | 있음 | 26.7.0 (2026-08-05) | 해소됨 |

확정 근거:
- **v24 백포트** — cherry-pick `9ed7146851`이 `v24.x-staging`에 존재. 열려 있는 릴리스 PR
**nodejs/node#65461 "2026-08-26, Version 24.20.0 'Krypton' (LTS)"** 의 CHANGELOG에
`test_runner: convert to uint during deserialization (Aviv Keller) [#64706]` 로 명시.
- **v22 미백포트** — `v22.x-staging`의 해당 줄은 여전히 `const fullMessageSize = (` 이고
괄호 보정(`>>> 0`)이 없다. 백포트 PR도 없다.

**교훈: EOL과 백포트 도달 여부는 독립된 축이다.** "활성 LTS면 최신 수정이 와 있다"는
가정이 틀렸다 — v22가 반례다. 런타임을 고를 때 두 축을 따로 확인해야 한다.
이 오류는 "수정이 X 버전에 들어갔다"에서 "그 이하 활성 버전에도 있겠지"로 건너뛴 데서 나왔다.

실무적 함의:
- `[22, 24]`를 고르면 **22 job에 flake가 그대로 남는다** — 가장 자연스러워 보이는 선택이 함정이다.
- `[24]`는 `actions/setup-node`가 최신 24.x를 집으므로 **2026-08-26 이후 자동으로** 해소된다.
그 전에 검증하면 24.19.0(취약)이라 잘못된 결론이 나온다.

### 2회차 관측 — PR #44 머지 직후 main이 이 flake로 빨개졌다

2026-08-25, PR #44가 main에 머지된 직후 `test (18)`이 실패했다. annotation이 즉시 원인을 알려줬다:

```
not ok 38 - tests/task-templates.test.mjs
error: 'Unable to deserialize cloned data due to invalid or unsupported version.'
```

같은 run의 `test (20)`은 통과했고 perf 수치도 정상이었다
(`cold 1.70x / checkpoint 1.76x`, 예산 3x/5x). 즉 **#44의 회귀가 아니라 이 task가 다루는
바로 그 node 18 flake**이며, 이번에는 **main을 빨갛게 만들었다.**

두 가지가 확인된다:
1. 계측이 제 값을 했다 — 이전이라면 "exit code 1"만 보였을 실패가 **한 번에** 특정됐다.
2. 이 flake는 산발적 불편이 아니라 **기본 브랜치를 막는다.** 관측 2회차이고
`[24]` 이동(이 PR)이 그대로 해소책이다 — node 18 job이 사라지기 때문이다.

### 결정 및 구현 (2026-08-25)

**결정: `engines: ">=24"` + CI matrix `[24]`** — spec 후보표의 (A2). 하위 호환 파기를 수용하고
EOL 탈출과 flake 해소를 동시에 얻는 선택이다. **22를 넣지 않는 것이 핵심**이다 —
"활성 LTS 두 개"로 보이는 `[22, 24]`는 22 job에 flake를 남긴다.

변경 지점 (grep 전수 확인):

| 파일 | 전 | 후 |
|---|---|---|
| `package.json:20` | `"node": ">=18"` | `">=24"` |
| `.github/workflows/test.yml` | `node: [18, 20]` | `node: [24]` (+ 사유 주석) |
| `.github/workflows/release.yml` | `node-version: 20` | `24` (+ 사유 주석) |
| `README.md` | "Node.js ≥ 18" ×2 | ≥ 24 |
| `docs/prerequisites.md` | "Node.js ≥ 18" ×3 | ≥ 24 |
| `CHANGELOG.md` | — | `[Unreleased] ### Changed` BREAKING 항목 |

종결된 task의 spec(`docs/chad/prerequisites-doc/`)에 남은 "≥ 18" 서술은 **건드리지 않았다** —
그 시점 사실의 historical record이고 다른 task의 SSOT다.

### 검증

`mise x node@24 -- npm test` → **415 tests, 0 fail** (1 skipped, 기존부터).

### 주의 1 — 이 변경만으로는 아직 flake가 사라지지 않는다

`node-version: 24`는 최신 24.x로 해석되는데, 2026-08-25 현재 최신은 **24.19.0이고 백포트가
아직 없다.** 수정이 들어간 **24.20.0은 2026-08-26 릴리스 예정**이다.
따라서 오늘 CI가 green이어도 그것은 flake가 고쳐졌다는 증거가 아니다 —
확률적 flake이므로 통과 1회는 원래 증거가 되지 못한다. **2026-08-26 이후에 판단할 것.**

### 실수 1건 — 런타임을 바꾸자 계측이 조용히 죽었다

matrix를 `[24]`로 바꾼 첫 CI 실행은 green이었지만 **annotation이 비어 있었다.**

원인: PR #44에서 넣은 계측이 `grep -E '^# spawn floor'` 로 **TAP 리포터 접두사에 앵커**돼
있었다. node ≤20은 기본이 TAP(`# diagnostic`, `not ok 1 - name`, ` error: '...'`)이지만
**node ≥22는 기본이 spec**(`ℹ diagnostic`, `✖ name`, `AssertionError [ERR_ASSERTION]: ...`)이다.
런타임을 옮기는 순간 패턴이 어긋나 계측이 **실패 없이 조용히** 아무것도 내보내지 않았다.

조치: 접두사가 아니라 **내용으로** 매칭하도록 바꾸고, 실패 패턴에 두 리포터를 모두 포함시켰다.
그리고 이번에는 추정하지 않고 **네 조합(node 20·24 × 통과·실패)의 실제 출력**을 캡처해
패턴을 검증했다 — 네 경우 모두에서 진단 라인과 assertion 메시지가 잡히는 것을 확인했다.

교훈: **런타임 버전을 바꾸면 도구의 기본 출력 형식도 함께 바뀔 수 있다.**
출력 형식에 의존하는 계측은 런타임 이동 시 반드시 재검증해야 한다. 특히 이 실패는
"green인데 조용히 아무것도 안 나온다"는 형태라 눈치채기 어렵다 — 계측이 죽으면
다음 flake는 다시 진단 불가 상태(로그 403)로 돌아간다.

### 주의 2 — perf 가드의 민감도가 node 24에서 낮아진다

perf 테스트(PR #44)의 비율 예산은 node 18/20 실측으로 산정됐다. node 24는 **프로세스 시작
비용 자체가 크다**(bare spawn: node 20 ≈ 17ms vs node 24 ≈ 37ms, 같은 머신 무부하 기준).
분모(equal-work baseline)가 시작 비용을 포함하므로 분모가 부풀고, 비율이 1.0 쪽으로 압축된다:

| | node 20 | node 24 |
|---|---|---|
| bare spawn | ~17ms | ~37ms |
| baseline | ~23ms | ~42ms |
| cold | ~33ms | ~56ms |
| cold/baseline | 1.43 | **1.34** |
| 3x 예산까지 허용되는 추가 비용 | +46ms | **+69ms** |

즉 macOS에서는 같은 `3x` 예산이 node 24에서 더 느슨하게 작동한다.

**→ CI 실측 결과 이 우려는 실현되지 않았다 (해소).** 위 수치는 macOS 것이고,
CI(Linux)의 node 24 시작 비용은 macOS와 전혀 달랐다. 같은 hosted 러너에서 받은 값:

| CI job | spawn floor | baseline | cold | checkpoint |
|---|---|---|---|---|
| test (18) | 23.2ms | 38.6ms | **1.68x** | 1.79x |
| test (20) | 22.1ms | 35.8ms | **1.75x** | 1.89x |
| **test (24)** | 28.4ms | 38.3ms | **1.78x** | 1.83x |

**세 버전이 사실상 동일하다.** Linux의 node 24 시작 비용은 28.4ms로 18·20(22~23ms)과
비슷하며, macOS에서 본 37~40ms 같은 팽창이 없다. 따라서 분모가 부풀어 민감도가 낮아지는
현상은 **macOS 국한**이고 CI에는 전이되지 않는다. **예산 재산정은 불필요하다.**

교훈: 로컬(macOS) 실측만으로 CI 리스크를 단정하지 말 것 — 이 건은 로컬 수치가
실제보다 나쁜 쪽으로 오도했다. 계측을 CI에 심어 둔 덕에 추정 대신 측정으로 닫았다. `engines`를 `>=22`로 올리는 것은 **하위 호환을 깨는** 변경이라
이 task 단독으로 결정할 수 없다. spec의 Ambiguity 게이트는 이 항목 때문에
**의도적으로 미통과 상태**로 남겨 뒀다 — 남은 모호성이 기술이 아니라 정책이기 때문이다.

## Reviews
*Codex/Gemini 등 리뷰 실행 시 결과(요약·발견·조치)를 날짜와 함께 남긴다. 남기지 않은 리뷰는 "안 한 것"으로 간주.*
*기계 판독용 마커를 함께 남긴다: `<!-- harness:review kind=codex scope=worktree tip=<sha|none> at=<ISO8601> -->`*


## Learnings
36 changes: 36 additions & 0 deletions docs/chad/node-test-runner-flake/node-test-runner-flake-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# node-test-runner-flake — Context Card
<!-- working set only; UTF-8 <= 6 KiB, nonblank lines <= 100 -->

## Now
- Goal: `node:test` 업스트림 역직렬화 버그로 CI가 간헐 실패하는 것을 멈춘다.
- Current atomic step: **[결정 대기] 지원 Node 범위** — EOL 탈출과 flake 해소를 분리해
답을 받아야 한다. flake까지 없애려면 22는 답이 아니고 24(24.20.0 이후)/26이어야 한다.
- Stop / human-decision condition: **지금이 그 지점이다.** 하위 호환을 깨는 정책 결정이라
사용자 답 없이 구현에 진입하지 않는다. spec의 Ambiguity 게이트도 이 항목 때문에 미통과 상태다.

## Constraints and settled decisions
- 원인 확정: `#processRawBuffer`가 payload 길이를 **부호 있는 정수**로 읽는다 (nodejs/node#64061).
수정은 `>>> 0` 한 줄 (PR #64706), **Node 26.7.0**에 포함.
- **EOL과 백포트는 별개 축이다** (초기 판단 정정). 실측:
v18/v20 = 수정 없음·EOL이라 영구 잔존 · **v22 = 활성 LTS인데 백포트 없음(staging에도)** ·
v24 = `9ed7146851` staging, 릴리스 PR #65461 → **24.20.0(2026-08-26)부터** · v26 = 26.7.0에 포함.
- 따라서 **"활성 LTS로 이동" ≠ "flake 해소"**. 22로 가면 flake가 남는다.
- 현재 matrix `[18, 20]`은 전부 지원 종료 런타임이다.
- 우리 코드 무관 확정: 실패 파일은 스폰·직렬화 없음 · 동일 코드로 통과 이력 · 재실행 통과.
- 자동 재시도(후보 E)는 **채택 금지** — 진짜 회귀까지 숨긴다.
- PR #44(perf flake)와 **독립**. 두 flake를 한 원인으로 묶지 말 것.
Comment thread
hsleedevelop marked this conversation as resolved.
Resolved

## JIT retrieval map
- Narrow globs: `.github/workflows/test.yml`, `.github/workflows/release.yml`, `package.json`
- Identifiers: `engines.node`, `matrix.node`, `node-version`
- Read next: spec의 "후보" 표 — (A) 권장, (E) 금지 근거
- Verification command: CI annotation 수집
`gh api repos/bd-makers/team-harness/check-runs/<job_id>/annotations`

## Failure capsules (max 3 unresolved)
- (none unresolved — 원인은 확정됐고 남은 것은 정책 결정이다)

## Resume checklist
- 재현은 **CI에서만** 가능하다 — 이 머신에 node 18 없음(프록시가 nodejs.org 차단), docker 없음.
- CI 로그는 403이다. `gh run view --log-failed`에 시간 쓰지 말 것 — annotation API를 쓴다.
- 결정이 나면 spec의 Ambiguity 자가진단 Constraint 항목부터 갱신하고 게이트를 다시 평가한다.
Loading
Loading