Skip to content
Open
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
21 changes: 17 additions & 4 deletions src/crewmail.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,9 @@ export function mailPrompt(msg, lang = 'ko', { hasTools = true } = {}) {

/** 우편 배달 — 스케줄러 틱에서 호출. runTurn(slug, msg, { from, hop, chain })을 주입받는다
(chat.mjs 직접 import는 순환이 되고, 주입이라야 단위 테스트가 배선까지 태울 수 있다).
선점은 **rename 단독** — 원자적이라 승자가 1명이다(분리 검수 CRITICAL-1: writeFile 선행 방식은
rename으로 집힌 원본을 되살려 이중 배달을 만들었다 — 21회 중 3회 실측). 반환: 이번 틱 처리 수. */
선점은 **mkdir 게이트 + rename** — 승자가 1명이다(분리 검수 CRITICAL-1: writeFile 선행 방식은
rename으로 집힌 원본을 되살려 이중 배달을 만들었다 — 21회 중 3회 실측. rename 단독도 윈도우에선
상호배제가 아니다 — 아래 선점 주석의 2026-08-06 실측). 반환: 이번 틱 처리 수. */
export async function deliverCrewMail(wsId, runTurn, { limit = MAIL_PER_TICK, now = Date.now() } = {}) {
const all = await pendingBySlug(wsId);
let done = 0;
Expand All @@ -134,9 +135,21 @@ export async function deliverCrewMail(wsId, runTurn, { limit = MAIL_PER_TICK, no
}
continue;
}
// 선점 — rename 원자성만 신뢰(승자 1명). 패자는 ENOENT로 continue.
// 선점 — rename 단독으로는 부족하다: 윈도우 MoveFileEx는 내부가 "소스 핸들 열기 → 핸들 rename"이라
// 두 주체의 호출이 겹치면 둘 다 rename 전에 핸들을 열어 **둘 다 성공**한다(2026-08-06 windows-latest
// 실측: 동시 rename 2000회 중 1999회 이중 성공 — POSIX는 커널이 이름 기준 원자 처리라 패자 ENOENT).
// mkdir은 양 플랫폼 모두 이름 기준 원자(패자 EEXIST)이므로 게이트로 승자를 1명으로 좁힌 뒤 rename한다
// — 게이트가 rename 호출의 동시 실행 자체를 막아 핸들 경합 창이 열리지 않는다.
const gate = `${item.full}.gate`;
const dropGate = () => rm(gate, { recursive: true, force: true }).catch(() => {});
try { await mkdir(gate); } catch {
// 크래시 잔재 게이트 — 방치되면 이 메시지가 영영 잠긴다. 오래된 것만 회수(claim은 다음 틱에).
try { if (now - (await stat(gate)).mtimeMs > CLAIM_STALE_MS) await dropGate(); } catch { /* 경합 소거 등 */ }
continue;
}
const claimedPath = `${item.full}.claimed`;
try { await rename(item.full, claimedPath); } catch { continue; }
try { await rename(item.full, claimedPath); } catch { await dropGate(); continue; }
await dropGate();
inFlight.add(claimedPath);
done += 1;
let msg = null;
Expand Down
19 changes: 12 additions & 7 deletions test/crewmail.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,18 @@ test('mailPrompt hasTools:false — CLI 러너 수신 턴에 send_to_crew 지시
}
});

test('선점 경합 — 두 배달 주체가 동시에 돌아도 배달은 1회(rename 원자성, 검수 CRITICAL-1)', async () => {
await mod.sendCrewMail(WS, { from: 'a', fromName: '알파', to: 'race', message: '경합 검증' });
let calls = 0;
const run = () => mod.deliverCrewMail(WS, async () => { calls += 1; await new Promise((r) => setTimeout(r, 40)); });
await Promise.all([run(), run()]);
assert.equal(calls, 1, '이중 배달 — 선점이 상호배제가 아니다');
assert.deepEqual(await mailFiles('race'), []);
test('선점 경합 — 두 배달 주체가 동시에 돌아도 배달은 1회(mkdir 게이트, 검수 CRITICAL-1)', async () => {
// rename 단독 선점은 윈도우에서 상호배제가 아니다 — MoveFileEx 내부 "핸들 열기 → 핸들 rename"
// TOCTOU로 겹친 두 rename이 둘 다 성공한다(2026-08-06 windows-latest 실측: 회차당 ~97% 이중 배달,
// run 31085690668 간헐 실패의 뿌리). 단발이면 겹침이 안 난 회차가 통과해 플레이크가 되므로 반복한다.
for (let i = 0; i < 30; i++) {
await mod.sendCrewMail(WS, { from: 'a', fromName: '알파', to: 'race', message: `경합 검증 ${i}` });
let calls = 0;
const run = () => mod.deliverCrewMail(WS, async () => { calls += 1; await new Promise((r) => setTimeout(r, 5)); });
await Promise.all([run(), run()]);
assert.equal(calls, 1, `i=${i} 이중 배달 — 선점이 상호배제가 아니다`);
assert.deepEqual(await mailFiles('race'), [], `i=${i} 선점 잔재(게이트·claimed)가 남았다`);
}
});

test('cc 상한 — CC_MAX 초과는 통째로 거절(팬아웃 총량 방어, 검수 MEDIUM)', async () => {
Expand Down
Loading