From 29d43bd8d6b668972de516ccf36b8c15c5cae21c Mon Sep 17 00:00:00 2001 From: fwkyle Date: Fri, 16 Oct 2026 21:33:40 +0900 Subject: [PATCH 1/2] =?UTF-8?q?test:=20macOS=20CI=20=ED=9D=94=EB=93=A4?= =?UTF-8?q?=EB=A6=BC=203=EA=B3=B3=EC=9D=84=20=EB=B6=80=ED=95=98=EC=99=80?= =?UTF-8?q?=20=EB=AC=B4=EA=B4=80=ED=95=98=EA=B2=8C=20=EB=A7=8C=EB=93=A0?= =?UTF-8?q?=EB=8B=A4=20(revive-up=C2=B7start-brief)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 시스템 알림 대기 상한을 KADAN_SYSTEM_NOTIFY_TIMEOUT_MS로 늘릴 수 있게 하고(기본 3초 그대로) revive-up이 쓴다. 부하 속에서 가짜 osascript가 3초 안에 못 끝나 상한 알림 0건이 됐다. - start-brief '끝내 안정되지 않음' 시험은 읽을 때마다 화면이 달라지게 tmux 래퍼를 둔다. 굶은 프로세스가 같은 화면을 두 번 그려 안정으로 읽혔다. - 세대 교체 시험이 start의 '시작됨' 출력을 3초만 기다리던 것을 30초로 늘리고 실패 시 출력을 보인다. Co-Authored-By: Claude Sonnet 5.5 --- docs/commands.md | 1 + src/user-notify.mjs | 4 +++- tests/revive-up.test.mjs | 2 +- tests/start-brief.test.mjs | 10 ++++++++-- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index 76d73e8c..2ab7caab 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -25,6 +25,7 @@ | `KADAN_SECRET_<이름>_FILE` | 이름별 비밀 파일의 절대경로(`KADAN_SECRET_DISCORD_FILE`·`_DISCORD_BOT_FILE`·`_EXPLAIN_AI_FILE`). 있으면 기계 설정(`kadan secrets`)보다 먼저다 → [비밀값 위치 규칙](secrets.md) | | `KADAN_NOTIFY_ENV_FILE` | `KADAN_SECRET_DISCORD_FILE`의 옛 이름(별칭). 둘 다 있으면 `KADAN_SECRET_DISCORD_FILE`이 이긴다. 없으면 설정, 그다음 기본 위치(`$KADAN_HOME/secrets/discord.env`), 모두 없으면 시스템 알림만 쓴다 → [사용자 알림](user-notify.md) | | `KADAN_NOTIFY` | `off`면 디스코드와 결정 알림, 사용자 우편함 알림을 보내지 않는다(시험용) | +| `KADAN_SYSTEM_NOTIFY_TIMEOUT_MS` | 맥 시스템 알림(osascript) 한 건을 기다리는 시간 상한(밀리초). 기본 3000. 시험이 부하 속에서도 가짜 osascript의 끝을 기다릴 때만 늘린다 | | `KADAN_SOCKET` | tmux 구획 이름. 기본 `kadan` (`tmux -L kadan`). `KADAN_HOME`이 기본 홈(`~/.kadan`, 링크면 실제 경로)이 아닌데 이 값이 없거나 `kadan`이면 tmux 호출을 `KADAN_LIVE_FLOOR_DENIED`로 거절한다(시험이 운영 창으로 새는 것을 막는 장치). 기본이 아닌 홈에서는 격리 이름을 주고, `KADAN_WINDOW=rottie`·`orca`도 기본이 아닌 홈에서는 같은 이유로 거절한다 | | `KADAN_ALLOW_LIVE_FLOOR` | `1`이면 위 거절을 끈다. 기본이 아닌 홈으로 운영 구획·창을 일부러 다룰 때만 쓴다 | | `KADAN_ROLE` | 카단 세션 안에서 자동으로 붙는 자기 역할. 원장의 `by`가 된다 | diff --git a/src/user-notify.mjs b/src/user-notify.mjs index eac58386..483f6ef4 100644 --- a/src/user-notify.mjs +++ b/src/user-notify.mjs @@ -21,6 +21,8 @@ export const ALERT_DEDUP_WINDOW_MS = 10 * 60 * 1000; export const TOTAL_BUDGET_MS = 12000; // 한 알림 전체의 공통 예산 const MIN_SEND_MS = 1500; // 이보다 적게 남으면 더 기다리거나 보내지 않는다 const SYSTEM_TIMEOUT_MS = 3000; +// 시험이 부하 속에서도 가짜 osascript가 끝나길 기다릴 수 있게 환경변수로만 늘린다(기본 3초는 그대로). +const systemTimeoutMs = () => Number(process.env.KADAN_SYSTEM_NOTIFY_TIMEOUT_MS) || SYSTEM_TIMEOUT_MS; // 연결 실패·시간 초과·429/5xx 소진으로 끝나면 이 시간 동안 같은 프로세스의 다음 알림은 디스코드를 건너뛰고 바로 시스템 알림으로 간다. // 근거: 장애 때 알림 한 건이 최대 12초를 막아 경보 두 건인 감시 주기가 24초 걸렸다(1라운드 검수). 60초면 감시 주기(기본 수 초~수십 초) // 당 한 번만 기다리고, 디스코드가 되살아나면 1분 안에 돌아온다. 4xx(설정·요청 문제)는 장애가 아니라서 막지 않는다. @@ -35,7 +37,7 @@ function workTitleOf(home, work) { export function systemNotify(message, spawn) { const script = `display notification ${JSON.stringify(message)} with title "kadan watch"`; - const result = spawn('osascript', ['-e', script], {encoding: 'utf8', timeout: SYSTEM_TIMEOUT_MS}); + const result = spawn('osascript', ['-e', script], {encoding: 'utf8', timeout: systemTimeoutMs()}); // 성공은 종료 코드 0이면서 오류·신호가 없을 때만이다. 시간 초과(spawnSync는 status:null, error.code:ETIMEDOUT, signal:SIGTERM)는 // stderr가 비어 있어도 실패다 — 빈 문자열을 돌려주면 호출 쪽의 `if (errorText)`가 성공으로 읽는다(3라운드 검수 R4). // 사유에는 명령 원문과 stderr(알림 문구가 섞일 수 있다)를 싣지 않고 짧은 분류만 담는다. diff --git a/tests/revive-up.test.mjs b/tests/revive-up.test.mjs index f4f2738b..150d3cd9 100644 --- a/tests/revive-up.test.mjs +++ b/tests/revive-up.test.mjs @@ -27,7 +27,7 @@ test('격리 tmux: kadan up --auto가 꺼진 기반 프로세스만 되살리고 fs.writeFileSync(botFile, `DISCORD_BOT_TOKEN=${FAKE.token}\nDISCORD_APPLICATION_ID=${FAKE.appId}\n`, {mode: 0o600}); fs.writeFileSync(hookFile, `DISCORD_USER_ID=${FAKE.userId}\nDISCORD_WEBHOOK_URL_SECRETARY=https://discord.com/api/webhooks/${FAKE.hookId}/${FAKE.hookToken}\n`, {mode: 0o600}); const env = {...process.env, PATH: `${bin}:${process.env.PATH}`, KADAN_HOME: home, KADAN_SOCKET: socket, KADAN_FLOOR: 'tmux', - KADAN_WINDOW: 'none', KADAN_NOTIFY: 'off', KADAN_DISCORD_API_BASE: fake.base, + KADAN_WINDOW: 'none', KADAN_NOTIFY: 'off', KADAN_SYSTEM_NOTIFY_TIMEOUT_MS: '30000', KADAN_DISCORD_API_BASE: fake.base, KADAN_SECRET_DISCORD_BOT_FILE: botFile, KADAN_SECRET_DISCORD_FILE: hookFile}; const kadan = (...args) => spawnSync(process.execPath, [CLI, ...args], {env, encoding: 'utf8', timeout: 60000}); const up = (...extra) => kadan('up', '--cmd', 'sh', '--port', String(port), '--hidden', '--no-prompt', ...extra); diff --git a/tests/start-brief.test.mjs b/tests/start-brief.test.mjs index 44f2eb7e..6ac1111a 100644 --- a/tests/start-brief.test.mjs +++ b/tests/start-brief.test.mjs @@ -149,6 +149,11 @@ test('격리 tmux: 화면이 끝내 안정되지 않으면 start는 종료 코 if (!hasTmux()) return t.skip('tmux 없음'); const w = world(t, 't'); w.fake('i=0\nwhile true; do i=$((i+1)); echo "frame $i"; done'); + // 화면이 바뀌는 속도는 기계 부하에 달려 있다(굶은 프로세스는 같은 화면을 두 번 그려 '안정'으로 읽힌다). + // 읽을 때마다 읽은 횟수가 화면에 찍히게 tmux 앞에 얇은 래퍼를 두어, 화면이 끝내 안정되지 않는 조건을 부하와 무관하게 만든다. + const realTmux = spawnSync('sh', ['-c', 'command -v tmux'], {encoding: 'utf8'}).stdout.trim(); + const counter = path.join(w.root, 'reads'); + fs.writeFileSync(path.join(w.bin, 'tmux'), `#!/bin/sh\ncase " $* " in *" capture-pane "*) echo x >> '${counter}'; '${realTmux}' "$@"; s=$?; wc -l < '${counter}'; exit $s;; esac\nexec '${realTmux}' "$@"\n`, {mode: 0o755}); const r = w.kadan('start', 'slow', '--hidden', '--profile', 'super', '--cmd', 'claude', '--reason', 't'); assert.equal(r.status, 0, r.stdout + r.stderr); assert.match(r.stderr, /첫 안내 못 보냄.*\/kadan-super/); @@ -188,8 +193,9 @@ test('격리 tmux: 화면을 기다리는 동안 세션이 다른 세대로 바 let stdout = '', stderr = ''; child.stdout.on('data', d => { stdout += d; }); child.stderr.on('data', d => { stderr += d; }); const done = new Promise(res => child.on('close', res)); - for (let i = 0; i < 60 && !stdout.includes('시작됨'); i++) await new Promise(r => setTimeout(r, 50)); - assert.match(stdout, /시작됨/); + // 부하가 큰 기계에서는 자식이 세션을 만들기까지 3초를 넘길 수 있다 → 시계가 아니라 '시작됨' 출력을 기다리고, 상한은 넉넉히 둔다. + for (let i = 0; i < 600 && !stdout.includes('시작됨'); i++) await new Promise(r => setTimeout(r, 50)); + assert.match(stdout, /시작됨/, `30초 안에 start가 시작됨을 출력하지 않음\nstdout: ${stdout}\nstderr: ${stderr}`); // 화면 대기 중에 세션을 닫고 같은 이름으로 새 세대를 띄운다(새 세대는 안내 없이). assert.equal(w.tmux('kill-session', '-t', '=kadan-swap').status, 0); const second = w.kadan('start', 'swap', '--hidden', '--profile', 'worker', '--cmd', 'claude', '--reason', 't', '--no-prompt'); From 485a9192d0bf050489a3509f400d466067e2b404 Mon Sep 17 00:00:00 2001 From: fwkyle Date: Fri, 16 Oct 2026 22:09:57 +0900 Subject: [PATCH 2/2] =?UTF-8?q?test:=20=EC=95=8C=EB=A6=BC=20=EB=8C=80?= =?UTF-8?q?=EA=B8=B0=20=ED=99=98=EA=B2=BD=EB=B3=80=EC=88=98=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=C2=B7=EC=8B=9C=ED=97=98=20=EC=A0=84=EC=9A=A9=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84,=20=EC=B2=AB=20=EC=95=88=EB=82=B4=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=A1=B0=EA=B1=B4=20=EB=8C=80=EA=B8=B0=20?= =?UTF-8?q?(PR=20#177=20=EA=B2=80=EC=88=98=201=EB=9D=BC=EC=9A=B4=EB=93=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - KADAN_SYSTEM_NOTIFY_TIMEOUT_MS → KADAN_TEST_SYSTEM_NOTIFY_TIMEOUT_MS. 정수 1000~120000만 받고 나머지는 기본 3000이라 spawnSync가 던지지 않는다. - start-brief 첫 안내 시험이 화면을 한 번 읽고 단언하던 것을 기대 문구가 나타날 때까지 최대 30초 기다린 뒤 단언한다. Co-Authored-By: Claude Sonnet 5.5 --- docs/commands.md | 2 +- src/user-notify.mjs | 8 +++++-- tests/revive-up.test.mjs | 2 +- tests/start-brief.test.mjs | 14 ++++++++--- tests/system-notify-timeout.test.mjs | 36 ++++++++++++++++++++++++++++ 5 files changed, 55 insertions(+), 7 deletions(-) create mode 100644 tests/system-notify-timeout.test.mjs diff --git a/docs/commands.md b/docs/commands.md index 2ab7caab..c9876aa1 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -25,7 +25,7 @@ | `KADAN_SECRET_<이름>_FILE` | 이름별 비밀 파일의 절대경로(`KADAN_SECRET_DISCORD_FILE`·`_DISCORD_BOT_FILE`·`_EXPLAIN_AI_FILE`). 있으면 기계 설정(`kadan secrets`)보다 먼저다 → [비밀값 위치 규칙](secrets.md) | | `KADAN_NOTIFY_ENV_FILE` | `KADAN_SECRET_DISCORD_FILE`의 옛 이름(별칭). 둘 다 있으면 `KADAN_SECRET_DISCORD_FILE`이 이긴다. 없으면 설정, 그다음 기본 위치(`$KADAN_HOME/secrets/discord.env`), 모두 없으면 시스템 알림만 쓴다 → [사용자 알림](user-notify.md) | | `KADAN_NOTIFY` | `off`면 디스코드와 결정 알림, 사용자 우편함 알림을 보내지 않는다(시험용) | -| `KADAN_SYSTEM_NOTIFY_TIMEOUT_MS` | 맥 시스템 알림(osascript) 한 건을 기다리는 시간 상한(밀리초). 기본 3000. 시험이 부하 속에서도 가짜 osascript의 끝을 기다릴 때만 늘린다 | +| `KADAN_TEST_SYSTEM_NOTIFY_TIMEOUT_MS` | **시험 전용, 운영에서 설정하지 않는다.** 맥 시스템 알림(osascript) 한 건을 기다리는 상한(밀리초)을 늘린다. 1000~120000 정수만 받고 그 밖의 값은 기본 3000 | | `KADAN_SOCKET` | tmux 구획 이름. 기본 `kadan` (`tmux -L kadan`). `KADAN_HOME`이 기본 홈(`~/.kadan`, 링크면 실제 경로)이 아닌데 이 값이 없거나 `kadan`이면 tmux 호출을 `KADAN_LIVE_FLOOR_DENIED`로 거절한다(시험이 운영 창으로 새는 것을 막는 장치). 기본이 아닌 홈에서는 격리 이름을 주고, `KADAN_WINDOW=rottie`·`orca`도 기본이 아닌 홈에서는 같은 이유로 거절한다 | | `KADAN_ALLOW_LIVE_FLOOR` | `1`이면 위 거절을 끈다. 기본이 아닌 홈으로 운영 구획·창을 일부러 다룰 때만 쓴다 | | `KADAN_ROLE` | 카단 세션 안에서 자동으로 붙는 자기 역할. 원장의 `by`가 된다 | diff --git a/src/user-notify.mjs b/src/user-notify.mjs index 483f6ef4..a56e1154 100644 --- a/src/user-notify.mjs +++ b/src/user-notify.mjs @@ -21,8 +21,12 @@ export const ALERT_DEDUP_WINDOW_MS = 10 * 60 * 1000; export const TOTAL_BUDGET_MS = 12000; // 한 알림 전체의 공통 예산 const MIN_SEND_MS = 1500; // 이보다 적게 남으면 더 기다리거나 보내지 않는다 const SYSTEM_TIMEOUT_MS = 3000; -// 시험이 부하 속에서도 가짜 osascript가 끝나길 기다릴 수 있게 환경변수로만 늘린다(기본 3초는 그대로). -const systemTimeoutMs = () => Number(process.env.KADAN_SYSTEM_NOTIFY_TIMEOUT_MS) || SYSTEM_TIMEOUT_MS; +// 시험 전용: 부하 속에서도 가짜 osascript가 끝나길 기다릴 수 있게 환경변수로만 늘린다. 운영에서는 설정하지 않는다. +// 정수 1000~120000만 받고 그 밖의 값(빈 값·문자·0·음수·소수·Infinity·너무 큰 값)은 기본 3초다 — spawnSync가 범위 밖 timeout에 던지지 않게. +export function systemTimeoutMs(env = process.env) { + const raw = env.KADAN_TEST_SYSTEM_NOTIFY_TIMEOUT_MS; + return typeof raw === 'string' && /^\d+$/.test(raw) && Number(raw) >= 1000 && Number(raw) <= 120000 ? Number(raw) : SYSTEM_TIMEOUT_MS; +} // 연결 실패·시간 초과·429/5xx 소진으로 끝나면 이 시간 동안 같은 프로세스의 다음 알림은 디스코드를 건너뛰고 바로 시스템 알림으로 간다. // 근거: 장애 때 알림 한 건이 최대 12초를 막아 경보 두 건인 감시 주기가 24초 걸렸다(1라운드 검수). 60초면 감시 주기(기본 수 초~수십 초) // 당 한 번만 기다리고, 디스코드가 되살아나면 1분 안에 돌아온다. 4xx(설정·요청 문제)는 장애가 아니라서 막지 않는다. diff --git a/tests/revive-up.test.mjs b/tests/revive-up.test.mjs index 150d3cd9..5f804c94 100644 --- a/tests/revive-up.test.mjs +++ b/tests/revive-up.test.mjs @@ -27,7 +27,7 @@ test('격리 tmux: kadan up --auto가 꺼진 기반 프로세스만 되살리고 fs.writeFileSync(botFile, `DISCORD_BOT_TOKEN=${FAKE.token}\nDISCORD_APPLICATION_ID=${FAKE.appId}\n`, {mode: 0o600}); fs.writeFileSync(hookFile, `DISCORD_USER_ID=${FAKE.userId}\nDISCORD_WEBHOOK_URL_SECRETARY=https://discord.com/api/webhooks/${FAKE.hookId}/${FAKE.hookToken}\n`, {mode: 0o600}); const env = {...process.env, PATH: `${bin}:${process.env.PATH}`, KADAN_HOME: home, KADAN_SOCKET: socket, KADAN_FLOOR: 'tmux', - KADAN_WINDOW: 'none', KADAN_NOTIFY: 'off', KADAN_SYSTEM_NOTIFY_TIMEOUT_MS: '30000', KADAN_DISCORD_API_BASE: fake.base, + KADAN_WINDOW: 'none', KADAN_NOTIFY: 'off', KADAN_TEST_SYSTEM_NOTIFY_TIMEOUT_MS: '30000', KADAN_DISCORD_API_BASE: fake.base, KADAN_SECRET_DISCORD_BOT_FILE: botFile, KADAN_SECRET_DISCORD_FILE: hookFile}; const kadan = (...args) => spawnSync(process.execPath, [CLI, ...args], {env, encoding: 'utf8', timeout: 60000}); const up = (...extra) => kadan('up', '--cmd', 'sh', '--port', String(port), '--hidden', '--no-prompt', ...extra); diff --git a/tests/start-brief.test.mjs b/tests/start-brief.test.mjs index 6ac1111a..6d315d44 100644 --- a/tests/start-brief.test.mjs +++ b/tests/start-brief.test.mjs @@ -109,6 +109,14 @@ function world(t, tag) { return {root, home, bin, fake, env, kadan, tmux, sends}; } const hasTmux = () => spawnSync('tmux', ['-V']).status === 0; +// 붙여넣기는 화면이 그려지는 것을 기다리지 않는다 → 기대 문구가 나타날 때까지 상한 있는 조건 대기 뒤에 화면을 돌려준다. 상한에 닿으면 그때 화면으로 단언이 실패한다. +function screenWhen(w, session, ready, {timeoutMs = 30000, stepMs = 100} = {}) { + const read = () => w.tmux('capture-pane', '-p', '-J', '-S', '-2000', '-t', `=${session}:`).stdout; + const end = Date.now() + timeoutMs; + let screen = read(); + while (!ready(screen) && Date.now() < end) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, stepMs); screen = read(); } + return screen; +} test('격리 tmux: 새 세션 + --profile은 프로필이 붙은 첫 안내를 정확히 1건 보낸다', t => { if (!hasTmux()) return t.skip('tmux 없음'); @@ -124,9 +132,9 @@ test('격리 tmux: 새 세션 + --profile은 프로필이 붙은 첫 안내를 assert.equal(added.length, 1, `${profile}: 영수증 1건`); assert.equal(added[0].role, role); assert.equal(added[0].roleProfile, profile); assert.ok(added[0].roleInstructions, `${profile}: receiver-instructions 메타데이터`); - const screen = w.tmux('capture-pane', '-p', '-J', '-S', '-2000', '-t', `=kadan-${role}:`).stdout; - assert.match(screen, new RegExp(needle), `${profile}: 본문/지침에 ${needle}`); - assert.match(screen, /kadan:receiver-instructions/); + const screen = screenWhen(w, `kadan-${role}`, text => new RegExp(needle).test(text) && /kadan:receiver-instructions/.test(text)); + assert.match(screen, new RegExp(needle), `${profile}: 본문/지침에 ${needle}\n화면:\n${screen}`); + assert.match(screen, /kadan:receiver-instructions/, `${profile}: 화면:\n${screen}`); } }); diff --git a/tests/system-notify-timeout.test.mjs b/tests/system-notify-timeout.test.mjs new file mode 100644 index 00000000..cdffff5c --- /dev/null +++ b/tests/system-notify-timeout.test.mjs @@ -0,0 +1,36 @@ +import './helpers/isolated-home.mjs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {spawnSync} from 'node:child_process'; +import {systemNotify, systemTimeoutMs} from '../src/user-notify.mjs'; + +// 시험 전용 환경변수 값 검증: 어떤 값이 와도 던지지 않고 기본 3초 또는 허용 범위 안의 값만 쓴다. 실제 알림은 띄우지 않는다. +const NAME = 'KADAN_TEST_SYSTEM_NOTIFY_TIMEOUT_MS'; +const CASES = [[undefined, 3000], ['', 3000], ['abc', 3000], ['0', 3000], ['999', 3000], ['-1', 3000], ['1.5', 3000], + ['Infinity', 3000], ['1e100', 3000], ['120001', 3000], ['99999999999999999999', 3000], [' 5000', 3000], + ['1000', 1000], ['30000', 30000], ['120000', 120000]]; + +test('systemTimeoutMs: 정수 1000~120000만 받고 나머지는 기본 3000', () => { + for (const [value, want] of CASES) { + const env = value === undefined ? {} : {[NAME]: value}; + assert.equal(systemTimeoutMs(env), want, String(value)); + } +}); + +test('systemNotify: 어떤 환경변수 값이어도 던지지 않고 spawn에 허용 범위 timeout을 넘기며 결과를 그대로 돌려준다', () => { + const saved = process.env[NAME]; + try { + for (const [value, want] of CASES) { + if (value === undefined) delete process.env[NAME]; else process.env[NAME] = value; + let passed; + // 실제 osascript 대신, 같은 옵션(timeout 포함)을 그대로 받는 무해한 node 자식을 띄운다 — spawnSync가 범위 밖 timeout이면 여기서 던진다. + const ok = systemNotify('mock', (_c, _a, o) => { passed = o.timeout; return spawnSync(process.execPath, ['-e', ''], o); }); + assert.equal(ok, null, `${value}: 성공 결과`); + assert.equal(passed, want, `${value}: 전달된 timeout`); + const failed = systemNotify('mock', (_c, _a, o) => spawnSync(process.execPath, ['-e', 'process.exit(3)'], o)); + assert.equal(failed, '시스템 알림 실패(종료 코드 3)', `${value}: 실패 결과`); + } + } finally { + if (saved === undefined) delete process.env[NAME]; else process.env[NAME] = saved; + } +});