Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`가 된다 |
Expand Down
8 changes: 7 additions & 1 deletion src/user-notify.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(설정·요청 문제)는 장애가 아니라서 막지 않는다.
Expand All @@ -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(알림 문구가 섞일 수 있다)를 싣지 않고 짧은 분류만 담는다.
Expand Down
2 changes: 1 addition & 1 deletion tests/revive-up.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
24 changes: 19 additions & 5 deletions tests/start-brief.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 없음');
Expand All @@ -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}`);
}
});

Expand All @@ -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/);
Expand Down Expand Up @@ -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');
Expand Down
36 changes: 36 additions & 0 deletions tests/system-notify-timeout.test.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
});
Loading