이 워크스페이스는 한 머신에서 봇 페르소나 7개(뉴, NEWA, hyeoksu2, kyoungmoon2, eunjin2, suji2, youngduk2)를 별도 홈으로 돌린다. 파일시스템/포트/PID 격리는 정상인데, Slack 격리는 기본값이 fail-open인 필드 하나에 걸려 있다. 그리고 방금 고친 것과 같은 부류의 타이머 누수가 같은 steer 경로에 하나 더 남아 있다.
1. slack.attachPort fail-open — 이벤트 절도
src/slack/events.ts:91-95:
export function shouldAttachSlack(attachPort: unknown, currentPort: unknown): boolean {
const attach = String(attachPort ?? '').trim();
if (!attach) return true; // ← 미설정 = 모든 인스턴스가 attach
return attach === String(currentPort ?? '').trim();
}
바로 위 주석(events.ts:85-90)이 위험을 정확히 서술한다:
"Socket Mode happily opens several connections per app token and Slack round-robins events across them, so two instances sharing tokens each swallow a random slice of the traffic."
기본값은 '' (src/routes/settings.ts:149, :346). 위험을 알면서 fail-open으로 출하됐다.
페르소나마다 app 토큰이 다르면 무해하다. 하지만 두 인스턴스가 토큰을 공유하는 순간 인바운드가 비결정적으로 쪼개진다 — "봇이 가끔 나를 무시한다"의 전형적 증상이다.
관련 현장 기록 (#squad_hellobot_product_b 2026-09-01 15:50):
jun: 제가 어제 수지님 멘션할때 응답하는거 만지다가 뉴를 특정 채널 빼고 강퇴시킨 것 같습니다 다 복구해놨습니다!
페르소나 정체성 자체는 안전하다
selfUserId는 init 시 auth.test로 한 번 정해져 프로세스 전역에 저장되고 (src/slack/bot.ts:86-89, :1218-1228), 아웃바운드는 항상 같은 싱글턴을 읽는다 (send-only-client.ts:23-29). 뉴가 NEWA의 토큰으로 보내는 코드 경로는 없다. teamId도 하드 요구사항이다 (bot.ts:1252-1256).
잔여 리스크는 bleed가 아니라 절도다 — 토큰을 공유하면 뉴의 인스턴스가 NEWA용 이벤트를 받아 뉴로서 답할 수 있고, Slack UI에서는 cross-persona bleed와 구분되지 않는다.
수정안: (botToken, appToken) 해시를 기동 시 다른 관리 홈들의 settings와 교차 검증하고, 충돌 시 attachPort가 일치하지 않으면 소켓 열기를 거부한다. jaw doctor에 기존 not_attach_instance health reason(messaging/channel-health.ts:141-145) 옆에 노출.
2. waitForProcessEnd의 데드라인 타이머가 정리되지 않는다
최근 커밋 1f6641ff3("never unref the exit-settle timeout and always clear it")은 옳고 완결적이다 — src/agent/spawn.ts:562-573:
// The timer is NOT unref'd and is always cleared: an unref'd timer can vanish
// with a drained event loop (test runner), leaving the waiter pending forever.
let timer: NodeJS.Timeout;
const timeout = new Promise<void>(r => { timer = setTimeout(r, timeoutMs); });
return Promise.race([entry.promise, timeout]).then(() => {
clearTimeout(timer);
...
});
그런데 동일한 부류의 버그가 40줄 위에 그대로 살아 있다 — src/agent/spawn.ts:722-727:
return new Promise<void>(resolve => {
const check = setInterval(() => {
if (!activeMainProcesses.has(scopeKey)) { clearInterval(check); resolve(); }
}, 100);
setTimeout(() => { clearInterval(check); resolve(); }, timeoutMs); // ← 캡처도 clear도 안 됨
});
데드라인 setTimeout이 변수에 잡히지도, clear되지도 않는다. 빠른 경로(100ms 내 종료)에서 3000ms 타이머가 이벤트 루프를 붙잡은 채 남는다. 형제 함수 waitForAllProcessesEnd (spawn.ts:743-748)는 clearTimeout(deadline)을 제대로 한다 — 누락이 정책이 아니라 실수임을 증명한다.
이건 방금 고친 그 steer/kill 경로에 있다 — waitForExitSettled 바로 직전에 호출된다.
const deadline = setTimeout(() => { clearInterval(check); resolve(); }, timeoutMs);
const check = setInterval(() => {
if (!activeMainProcesses.has(scopeKey)) { clearInterval(check); clearTimeout(deadline); resolve(); }
}, 100);
부수 효과: 이 누수는 #521에 보고한 SC-009 flaky 실패('Promise resolution is still pending but the event loop has already resolved')의 유력 후보다.
startWorkerMonitor도 stall 경로에서 샌다
src/orchestrator/worker-monitor.ts:19-32 — onStall 발화 후에도 stallTimer가 계속 돈다 (stalled 가드로 재호출은 없지만, 인터벌과 클로저가 exit()/stop()까지 생존). 워커가 stall 후 깨끗이 종료되지 않으면 인터벌이 워커보다 오래 산다.
3. steer kill-path에서 도구 호출 상태가 유실된다
13개 런타임 중 11개가 kill-steer다 (structure/prompt_flow.md:185). in-band는 jwc(spawn.ts:769)와 codex-app(:780) 둘뿐.
보존되는 것은 어시스턴트 산문뿐이다. getSteerSalvageAfter는 messages 테이블을 읽는다. ctx.toolLog와 ctx.traceLog는 steerContext에 없다. kill-steer 이후 새 런은 모델이 무엇을 말했는지는 알지만 어떤 도구를 실행했는지는 모른다 — 완료된 마이그레이션을 다시 돌리거나 이메일을 재발송할 수 있다.
비대칭이 눈에 띈다: 429 경로에는 performedSideEffects(ctx) 보호가 있고 (lifecycle-handler.ts:857), steer 경로에는 없다.
대기 시간이 3초다:
const DEFAULT_STEER_WAIT_MS = 3_000; // spawn.ts:491
const CLAUDE_E_STEER_WAIT_MS = 30_000; // spawn.ts:493
도구 호출 중인 claude/cursor 런은 flush에 3초 이상 걸리는 게 일상이다. 만료되면 배리어가 그냥 resolve하고 steerContext는 조용히 null이 된다 — 컨텍스트 유실이 로깅되지 않고, "건질 게 없었음"과 구분 불가다.
수정안: (a) ctx.toolLog에서 유계 요약을 steerContext에 덧붙인다; (b) DEFAULT_STEER_WAIT_MS를 ~10s로 올리고 배리어가 salvage === null로 타임아웃하면 steer_context_lost를 명시적으로 브로드캐스트; (c) 이미 export된 isRetryPending()(spawn/queue.ts:107)로 steer 시 대기 중인 retry 타이머를 취소 (현재는 retry-wait 구간에 활성 프로세스가 없어 canSteerAgent가 false, 사용자 메시지가 재시작도 안 한 런 뒤에 큐잉된다).
4. except_3462 — 설정이 파일명에 인코딩되어 있다
두 스크립트가 딱 하나 의미 있게 다르다:
|
jaw_launchd.sh |
jaw_local_launchd_except_3462.sh |
| 바이너리 |
설치된 jaw (npm global) |
로컬 repo의 dist/bin/cli-jaw.js |
| plist |
jaw launchd에 위임 |
heredoc 수작업 |
| 대상 |
3458**–3463** (3462 포함) |
3458-3461, 3463 (3462 제외) |
| 제외 |
3457 |
3457 + 3462 |
jaw_launchd.sh:38-40이 3457 제외 이유를 문서화한다: "Port 3457 (~/.cli-jaw) is NOT managed by launchd — use Terminal foreground […] so Computer Use works" — launchd 잡은 Terminal의 macOS Automation TCC 권한을 상속받지 못한다. 로컬 스크립트의 plist는 그 손실을 확인시켜준다: 정규 생성기(src/core/launchd-plist.ts:66-69)에 있는 LimitLoadToSessionType=Aqua와 ProcessType=Interactive가 둘 다 빠져 있다.
즉 except_3462 = "3462도 3457과 같은 foreground/interactive 취급이 필요하다". --status 경로에는 하드코딩 프로브까지 있다 (:220-221):
echo " port 3462: intentionally excluded"
lsof -nP -iTCP:3462 -sTCP:LISTEN 2>/dev/null || true
두 스크립트가 3462의 관리 여부를 두고 이견을 갖는다. 순서를 잘못 실행하면 3462가 중복된다 — 로컬 스크립트의 stop_all은 3462를 건드리지 않으므로 jaw_launchd.sh의 잡이 살아남고, 수동으로 하나 더 띄울 수 있다.
또 jaw_local_launchd_except_3462.sh:18에는 이전 홈 경로의 instanceId() md5에서 온 stale 라벨 com.cli-jaw.cli-jaw-3458-7ff0583f가 남아 있다 (stop_label에만 쓰여 무해하지만 잔재).
수정안: 파일명 인코딩을 선언적 FOREGROUND_PORTS=(3457 3462) 배열로 대체하고 양쪽이 공유한다. 로컬 스크립트는 plist 생성을 jaw launchd에 위임해 ProcessType/LimitLoadToSessionType이 드리프트하지 못하게 한다. launchctl load 전에 lsof 중복 탐지 preflight 추가.
5. 잠재적 포트 충돌 두 건
| 상수 |
값 |
위치 |
DEFAULT_PORT |
'3457' |
src/core/config.ts:64 |
CDP_PORT_OFFSET |
5783 (→ 9240) |
src/core/config.ts:65 |
DASHBOARD_DEFAULT_PORT |
'24576' |
src/manager/constants.ts:1 |
MANAGED_INSTANCE_PORT_FROM/COUNT |
3457 / 50 (→3506) |
constants.ts:8-11 |
DASHBOARD_DEFAULT_PORT가 홈 파생이 아닌 맨 상수다. 한 머신의 대시보드 둘이 충돌하고, DASHBOARD_PORT env나 --port로만 회피된다. 폴백 범위 24577-24590은 스캔 범위지 auto-bind가 아니다. 수정: CDP 포트가 서버 포트에서 파생되듯, instanceId() 해시로 폴백 창 안에서 기본값을 파생.
deriveCdpPort가 오버플로 시 공유 9240으로 붕괴한다 (config.ts:74-77): return cdp > 65535 ? 9240 : cdp;. 폴백에 걸린 두 인스턴스가 CDP 포트 하나 = 브라우저 하나를 공유한다. 3457-3506은 전부 안전(9240-9289)하므로 잠재적이지만, 조용한 충돌 실패 양식이다. 수정: 공유 리터럴 반환 대신 throw하거나 빈 포트를 고른다.
정상인 것들 (참고): 모든 경로가 CLI_JAW_HOME 파생 (config.ts:46-61), 인스턴스 id는 콘텐츠 파생 md5 (src/core/instance.ts:16-21), SQLite WAL + busy_timeout=5000 (src/core/db.ts:29-30), identity 생성은 EEXIST-adoption으로 race-proof (instance-identity.ts:100-110).
6. 참고 — .jaw-install-state.json이 safe-mode
{ "schema": 1, "state": "safe-mode", "packageVersion": "2.17.36",
"ranAt": "2026-09-04T12:19:35.580Z", "node": "v26.5.0" }
dev 클론에서는 정상이지만, 이 파일은 절대 출하되면 안 된다 (scripts/install-risk-gate.mjs:100-101).
7. 문서 드리프트
structure/agent_spawn.md:233이 orchestrator/scope.ts를 "17L, always returns 'default'"로 적고 있다. 실제로는 65줄의 라이브 키잉 로직이다.
우선순위
| P |
항목 |
| P1 |
waitForProcessEnd 데드라인 clear (spawn.ts:726) — 방금 랜딩한 커밋과 같은 버그 부류, 같은 경로, SC-009 flaky 후보 |
| P1 |
attachPort fail-open → 토큰 충돌 감지 |
| P2 |
steer kill-path에 도구 호출 요약 보존 + steer_context_lost 텔레메트리 |
| P2 |
DEFAULT_STEER_WAIT_MS 3s → 10s |
| P3 |
except_3462를 선언적 FOREGROUND_PORTS로 |
| P3 |
DASHBOARD_DEFAULT_PORT / deriveCdpPort 9240 폴백 |
| P3 |
structure/agent_spawn.md:233 드리프트 |
이 워크스페이스는 한 머신에서 봇 페르소나 7개(
뉴,NEWA,hyeoksu2,kyoungmoon2,eunjin2,suji2,youngduk2)를 별도 홈으로 돌린다. 파일시스템/포트/PID 격리는 정상인데, Slack 격리는 기본값이 fail-open인 필드 하나에 걸려 있다. 그리고 방금 고친 것과 같은 부류의 타이머 누수가 같은 steer 경로에 하나 더 남아 있다.1.
slack.attachPortfail-open — 이벤트 절도src/slack/events.ts:91-95:바로 위 주석(
events.ts:85-90)이 위험을 정확히 서술한다:기본값은
''(src/routes/settings.ts:149,:346). 위험을 알면서 fail-open으로 출하됐다.페르소나마다 app 토큰이 다르면 무해하다. 하지만 두 인스턴스가 토큰을 공유하는 순간 인바운드가 비결정적으로 쪼개진다 — "봇이 가끔 나를 무시한다"의 전형적 증상이다.
관련 현장 기록 (
#squad_hellobot_product_b2026-09-01 15:50):페르소나 정체성 자체는 안전하다
selfUserId는 init 시auth.test로 한 번 정해져 프로세스 전역에 저장되고 (src/slack/bot.ts:86-89,:1218-1228), 아웃바운드는 항상 같은 싱글턴을 읽는다 (send-only-client.ts:23-29).뉴가NEWA의 토큰으로 보내는 코드 경로는 없다.teamId도 하드 요구사항이다 (bot.ts:1252-1256).잔여 리스크는 bleed가 아니라 절도다 — 토큰을 공유하면
뉴의 인스턴스가NEWA용 이벤트를 받아뉴로서 답할 수 있고, Slack UI에서는 cross-persona bleed와 구분되지 않는다.수정안:
(botToken, appToken)해시를 기동 시 다른 관리 홈들의 settings와 교차 검증하고, 충돌 시attachPort가 일치하지 않으면 소켓 열기를 거부한다.jaw doctor에 기존not_attach_instancehealth reason(messaging/channel-health.ts:141-145) 옆에 노출.2.
waitForProcessEnd의 데드라인 타이머가 정리되지 않는다최근 커밋
1f6641ff3("never unref the exit-settle timeout and always clear it")은 옳고 완결적이다 —src/agent/spawn.ts:562-573:그런데 동일한 부류의 버그가 40줄 위에 그대로 살아 있다 —
src/agent/spawn.ts:722-727:데드라인
setTimeout이 변수에 잡히지도, clear되지도 않는다. 빠른 경로(100ms 내 종료)에서 3000ms 타이머가 이벤트 루프를 붙잡은 채 남는다. 형제 함수waitForAllProcessesEnd(spawn.ts:743-748)는clearTimeout(deadline)을 제대로 한다 — 누락이 정책이 아니라 실수임을 증명한다.이건 방금 고친 그 steer/kill 경로에 있다 —
waitForExitSettled바로 직전에 호출된다.부수 효과: 이 누수는 #521에 보고한
SC-009flaky 실패('Promise resolution is still pending but the event loop has already resolved')의 유력 후보다.startWorkerMonitor도 stall 경로에서 샌다src/orchestrator/worker-monitor.ts:19-32—onStall발화 후에도stallTimer가 계속 돈다 (stalled가드로 재호출은 없지만, 인터벌과 클로저가exit()/stop()까지 생존). 워커가 stall 후 깨끗이 종료되지 않으면 인터벌이 워커보다 오래 산다.3. steer kill-path에서 도구 호출 상태가 유실된다
13개 런타임 중 11개가 kill-steer다 (
structure/prompt_flow.md:185). in-band는jwc(spawn.ts:769)와codex-app(:780) 둘뿐.보존되는 것은 어시스턴트 산문뿐이다.
getSteerSalvageAfter는 messages 테이블을 읽는다.ctx.toolLog와ctx.traceLog는steerContext에 없다. kill-steer 이후 새 런은 모델이 무엇을 말했는지는 알지만 어떤 도구를 실행했는지는 모른다 — 완료된 마이그레이션을 다시 돌리거나 이메일을 재발송할 수 있다.비대칭이 눈에 띈다: 429 경로에는
performedSideEffects(ctx)보호가 있고 (lifecycle-handler.ts:857), steer 경로에는 없다.대기 시간이 3초다:
도구 호출 중인 claude/cursor 런은 flush에 3초 이상 걸리는 게 일상이다. 만료되면 배리어가 그냥 resolve하고
steerContext는 조용히null이 된다 — 컨텍스트 유실이 로깅되지 않고, "건질 게 없었음"과 구분 불가다.수정안: (a)
ctx.toolLog에서 유계 요약을steerContext에 덧붙인다; (b)DEFAULT_STEER_WAIT_MS를 ~10s로 올리고 배리어가salvage === null로 타임아웃하면steer_context_lost를 명시적으로 브로드캐스트; (c) 이미 export된isRetryPending()(spawn/queue.ts:107)로 steer 시 대기 중인 retry 타이머를 취소 (현재는 retry-wait 구간에 활성 프로세스가 없어canSteerAgent가 false, 사용자 메시지가 재시작도 안 한 런 뒤에 큐잉된다).4.
except_3462— 설정이 파일명에 인코딩되어 있다두 스크립트가 딱 하나 의미 있게 다르다:
jaw_launchd.shjaw_local_launchd_except_3462.shjaw(npm global)dist/bin/cli-jaw.jsjaw launchd에 위임jaw_launchd.sh:38-40이 3457 제외 이유를 문서화한다: "Port 3457 (~/.cli-jaw) is NOT managed by launchd — use Terminal foreground […] so Computer Use works" — launchd 잡은 Terminal의 macOS Automation TCC 권한을 상속받지 못한다. 로컬 스크립트의 plist는 그 손실을 확인시켜준다: 정규 생성기(src/core/launchd-plist.ts:66-69)에 있는LimitLoadToSessionType=Aqua와ProcessType=Interactive가 둘 다 빠져 있다.즉
except_3462= "3462도 3457과 같은 foreground/interactive 취급이 필요하다".--status경로에는 하드코딩 프로브까지 있다 (:220-221):두 스크립트가 3462의 관리 여부를 두고 이견을 갖는다. 순서를 잘못 실행하면 3462가 중복된다 — 로컬 스크립트의
stop_all은 3462를 건드리지 않으므로jaw_launchd.sh의 잡이 살아남고, 수동으로 하나 더 띄울 수 있다.또
jaw_local_launchd_except_3462.sh:18에는 이전 홈 경로의instanceId()md5에서 온 stale 라벨com.cli-jaw.cli-jaw-3458-7ff0583f가 남아 있다 (stop_label에만 쓰여 무해하지만 잔재).수정안: 파일명 인코딩을 선언적
FOREGROUND_PORTS=(3457 3462)배열로 대체하고 양쪽이 공유한다. 로컬 스크립트는 plist 생성을jaw launchd에 위임해ProcessType/LimitLoadToSessionType이 드리프트하지 못하게 한다.launchctl load전에lsof중복 탐지 preflight 추가.5. 잠재적 포트 충돌 두 건
DEFAULT_PORT'3457'src/core/config.ts:64CDP_PORT_OFFSET5783(→ 9240)src/core/config.ts:65DASHBOARD_DEFAULT_PORT'24576'src/manager/constants.ts:1MANAGED_INSTANCE_PORT_FROM/COUNT3457/50(→3506)constants.ts:8-11DASHBOARD_DEFAULT_PORT가 홈 파생이 아닌 맨 상수다. 한 머신의 대시보드 둘이 충돌하고,DASHBOARD_PORTenv나--port로만 회피된다. 폴백 범위 24577-24590은 스캔 범위지 auto-bind가 아니다. 수정: CDP 포트가 서버 포트에서 파생되듯,instanceId()해시로 폴백 창 안에서 기본값을 파생.deriveCdpPort가 오버플로 시 공유 9240으로 붕괴한다 (config.ts:74-77):return cdp > 65535 ? 9240 : cdp;. 폴백에 걸린 두 인스턴스가 CDP 포트 하나 = 브라우저 하나를 공유한다. 3457-3506은 전부 안전(9240-9289)하므로 잠재적이지만, 조용한 충돌 실패 양식이다. 수정: 공유 리터럴 반환 대신 throw하거나 빈 포트를 고른다.정상인 것들 (참고): 모든 경로가
CLI_JAW_HOME파생 (config.ts:46-61), 인스턴스 id는 콘텐츠 파생 md5 (src/core/instance.ts:16-21), SQLite WAL +busy_timeout=5000(src/core/db.ts:29-30), identity 생성은 EEXIST-adoption으로 race-proof (instance-identity.ts:100-110).6. 참고 —
.jaw-install-state.json이safe-mode{ "schema": 1, "state": "safe-mode", "packageVersion": "2.17.36", "ranAt": "2026-09-04T12:19:35.580Z", "node": "v26.5.0" }dev 클론에서는 정상이지만, 이 파일은 절대 출하되면 안 된다 (
scripts/install-risk-gate.mjs:100-101).7. 문서 드리프트
structure/agent_spawn.md:233이orchestrator/scope.ts를 "17L, always returns'default'"로 적고 있다. 실제로는 65줄의 라이브 키잉 로직이다.우선순위
waitForProcessEnd데드라인 clear (spawn.ts:726) — 방금 랜딩한 커밋과 같은 버그 부류, 같은 경로, SC-009 flaky 후보attachPortfail-open → 토큰 충돌 감지steer_context_lost텔레메트리DEFAULT_STEER_WAIT_MS3s → 10sexcept_3462를 선언적FOREGROUND_PORTS로DASHBOARD_DEFAULT_PORT/deriveCdpPort9240 폴백structure/agent_spawn.md:233드리프트