diff --git a/docs/commands.md b/docs/commands.md index 76d73e8c..c9876aa1 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_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 eac58386..a56e1154 100644 --- a/src/user-notify.mjs +++ b/src/user-notify.mjs @@ -21,6 +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가 끝나길 기다릴 수 있게 환경변수로만 늘린다. 운영에서는 설정하지 않는다. +// 정수 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(설정·요청 문제)는 장애가 아니라서 막지 않는다. @@ -35,7 +41,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..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_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 44f2eb7e..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}`); } }); @@ -149,6 +157,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 +201,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'); 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; + } +});