요약
현재 Slack은 스레드마다 별도 chat session 을 만든다. 세션 수가 스레드 수만큼 늘어 관리가 어렵다는 지적이 있었다. 채널 단위로 접는 안을 검토했고, 그 대가와 대안을 정리해 결정을 요청한다.
핵심 트레이드오프: 채널 단위로 접으면 같은 채널 안에서는 응답이 순차 처리된다. 세션 키가 실행 스코프를 겸하는 구조에서 나오는 필연적 귀결이다.
이 문서는 독립 서브에이전트 감사를 거쳤고, 초안의 사실오류 5건을 반영해 수정했다. 특히 최초 권고(D안)는 감사 결과 그대로는 성립하지 않아 재작성했다.
1. 현재 구조
buildRemoteBindingKey (src/messaging/session-key.ts:34-39) 가 threadId 를 키에 넣는다.
jaw:slack:channel:C123 ← 채널 최상위
jaw:slack:channel:C123:thread:1754983201.123456 ← 스레드마다 별개 키
이 키는 remote_session_bindings 의 PK(src/core/db.ts:105-107)이고, 키가 없으면 resolveOrCreateRemoteSession (src/core/chat-sessions.ts:122-142) 이 새 chat_sessions 행을 INSERT 한다. 스레드 1개 = 세션 1개 = 사이드바 항목 1개.
이 키가 동시에 결정하는 것
scopeForChatSession (src/orchestrator/scope.ts:52-53) 이 remoteKey 를 그대로 scope 로 반환한다. 그래서 세션 키는 세션만 정하지 않는다.
| 파생물 |
코드 |
의미 |
| chat_sessions 행 |
chat-sessions.ts:137-140 |
대화 기록 분리 |
CLI 세션 버킷 <cli>:<model>:<scope> |
src/agent/args.ts:194 |
vendor 대화 컨텍스트 분리 |
sessionLanes 실행 레인 |
src/orchestrator/session-lanes.ts:39-51 |
동시 실행 단위 |
isAgentBusy(scope) |
src/agent/spawn.ts:467 |
busy 판정 단위 |
세션 키를 바꾸면 동시 실행 단위가 같이 바뀐다. 이게 이 이슈의 전부다.
2. 현재 최대 동시 병렬 응답 수
레인에 들어가는 턴의 상한은 multiSession.maxConcurrent
// src/orchestrator/session-lanes.ts:100-102
export const sessionLanes = new SessionLanes(
() => settings["multiSession"]?.maxConcurrent ?? 1,
);
// src/orchestrator/session-lanes.ts:90-96
private pump(): void {
const limit = positiveInt(this.readMaxConcurrent());
while (this.active < limit) { ... }
}
active 는 프로세스 전역 카운터(session-lanes.ts:22)다.
| 출처 |
값 |
코드 기본값 (src/core/config.ts:348) |
2 |
레거시 승계분 (config.ts:228) |
1 |
multiSession.enabled=false |
1 (scope가 전부 default 로 collapse, scope.ts:52) |
단, 이건 전역 천장이 아니다 (감사 정정)
Telegram·Discord·heartbeat 턴은 sessionLanes 를 거치지 않는다. 레인에 계수되지도, 제한되지도 않는다.
$ rg -n 'sessionLanes' src/telegram/bot.ts src/discord/bot.ts src/memory/heartbeat.ts
(빈 결과)
src/telegram/bot.ts:1073 — await orchestrateAndCollectData(...) 직접 호출
src/discord/bot.ts:554 — await orchestrateAndCollect(...) 직접 호출
src/memory/heartbeat.ts:225 — 동일
Slack만 admitSlackRun 이 감싼다 (src/slack/ingress.ts:249).
재진입 예외도 있다 — session-lanes.ts:27 의 if (this.runningScope.getStore() === scopeKey) return Promise.resolve().then(task); 는 active 를 올리지 않고 실행된다.
답
Slack 단독 활성 호스트에서 레인 경유 턴의 동시 실행 상한은 2. (이 조건은 messaging.enabledChannels: ["slack"] 인 현재 배포 형태와 일치한다.) Telegram/Discord/heartbeat를 함께 켜면 그 경로는 이 상한 밖에서 추가로 돈다.
인그레스 레인(src/slack/ingress.ts:180-205)은 동시성 상한이 아니다 — 같은 키의 이벤트를 순서대로 흘리는 직렬화일 뿐이고, 키가 다르면 무제한 진입한다.
즉 스레드가 30개여도 Slack 동시 응답은 2개이고 나머지는 대기한다.
3. 선택지
A안 — 현행 유지 (스레드마다 세션)
얻는 것: 스레드별 독립 실행(상한 내), 스레드별 CLI 컨텍스트 격리, 작은 오배송 표면.
잃는 것: 세션 수가 스레드 수만큼 증가. vendor 세션도 같이 증가. 스레드 간 맥락 공유 불가.
B안 — 채널 단위로 접기
얻는 것: 세션 수 = 채널 수. 채널 내 스레드가 하나의 대화 맥락 공유.
잃는 것: 같은 채널 안에서 병렬 응답 불가. scope 가 채널 단위가 되고 sessionLanes 는 스코프별로 tail 을 체이닝하므로(session-lanes.ts:39-51) 같은 채널의 턴은 무조건 순차다. 스레드1이 3분 작업을 돌면 스레드2는 3분 뒤에 답을 받는다.
인그레스 레인 키만 스레드로 남겨도 해소되지 않는다 — sessionLanes 가 별도 레이어다. (slackIngressLaneKey 가 buildRemoteBindingKey 를 재사용하므로, 바인딩을 접으면 인그레스 레인도 같이 접힌다.)
C안 — 세션은 채널, 실행은 스레드 (감사 결과 재평가: 유력 후보)
초안은 이 안을 "세션 목록만 예뻐지는 절충"으로 기각했으나, 감사 결과 그 기각이 틀렸다.
- scope와 session은 이미 별개 파라미터다 —
resolveOrcScope (scope.ts:57-61)가 둘을 따로 받고, Slack은 preResolvedScope 로 이미 독립 주입한다(src/slack/ingress.ts:239-241).
- DB 히스토리 블록은 scope가 아니라
chatSessionId 로 키잉된다 — buildHistoryBlock(currentPrompt, workingDir, chatSessionId, ...) (src/agent/spawn.ts:791, 호출 :1351). 따라서 C안에서 새 스레드의 첫 턴은 resume가 아니므로 shouldBuildHistoryBlock 이 true가 되고(src/agent/prompt-context.ts:21-22), 에이전트는 채널의 스레드 간 히스토리를 받는다.
- 버킷은 scope로 키잉되므로(
src/agent/args.ts:194) 동시 스레드는 서로 다른 버킷을 쓴다 — "같은 vendor 세션에 두 턴" 문제가 발생하지 않는다.
즉 C안은 채널 단위 세션 수 + 스레드별 병렬성 + 스레드 진입 시 교차 맥락을 동시에 준다.
진짜 대가: 첫 턴 이후에는 vendor 측 컨텍스트가 스레드별로 갈린다(교차 맥락은 DB 히스토리 경유로만).
설계 제약: 스레드 scope가 isRemoteBindingScope (scope.ts:29-31, startsWith('jaw:'))와 충돌하면 안 된다 — 충돌 시 바인딩 키로 오인된다.
D안 — A안 유지 + 유휴 스레드 세션 회수
초안의 설계는 틀렸다. 감사가 두 가지를 지적했다.
- 바인딩만 지우면 세션 목록이 줄지 않는다. 목록은
chat_sessions 기준이고 바인딩은 LEFT JOIN 이다(chat-sessions.ts:45-55). 바인딩 없는 세션도 그대로 나온다. CASCADE 는 반대 방향으로만 걸려 있다(db.ts:110). → deleteChatSession (chat-sessions.ts:188)을 호출해야 하고, 그러면 deleteMessagesStmt.run(sessionId) (:195)로 해당 스레드의 DB 대화 기록이 삭제된다. "CLI 컨텍스트 손실"이 아니라 기록 파괴다.
- 스레드 히스토리 프리페치가 맥락을 복구하지 못한다. 프리페치 클레임은 owner generation 으로 키잉된 인메모리 1회성이다(
src/slack/thread-tracker.ts:158-160). 바인딩 회수는 scopeGenerations 를 올리지 않으므로 클레임이 살아남아 재주입이 안 된다(src/slack/bot.ts:816). → 회수 시 bumpScopeSessionGeneration(scope) (src/agent/session-persistence.ts:46)를 같이 호출해야 한다.
수정하면 여전히 유효한 안이다: TTL 초과 + isAgentBusy(scope) false 인 jaw:slack:%:thread:% 만 회수, 채널 최상위는 제외.
4. B안 선택 시 같이 봐야 하는 것 — mid-run 주입 (런타임 조건부)
초안은 이를 "기본값"이라 했으나 틀렸다. 주입은 활성 런타임이 jwc 일 때만 일어난다.
// src/agent/spawn.ts:698-701
export function canSteerAgent(scopeKey: string): boolean {
const run = activeMainProcesses.get(scopeKey);
return run?.meta.cli === 'jwc' && jawRuntimesByScope.get(scopeKey)?.busy === true;
}
기본 CLI 는 codex-app 이다(src/cli/registry.ts:271, 이 호스트 설정도 "cli": "codex-app"). canSteerAgent 가 false 면 gateway.ts:95 의 if (!canSteerAgent(ctx.scopeKey)) return queue(); 로 떨어지고, 큐 항목은 자기 target 을 들고 있으므로(src/agent/spawn/queue.ts:531) 자기 스레드로 답한다.
jwc 런타임에서는 실재하는 위험이다: spawn.ts:716 → jwc-runtime.ts:174 (streamingBehavior:'steer')로 돌고 있는 턴에 주입되고, settleOnce(meta?.requestId, 'steered') (spawn.ts:720)라 주입된 메시지는 자기 답을 받지 못한다. collect 병합도 runItems[0].target 으로만 답한다(queue.ts:511-512, 522-527).
→ B안의 선행 조건은 런타임 조건부이며, codex-app 호스트에서는 순차 큐잉으로 degrade 되어 라우팅은 정확하다.
5. 인접 이슈 (중복 방지)
본 이슈는 세션 키 단위 결정만 다루며 위 폴백 설계를 중복하지 않는다.
한편 답장 target 자체는 안전하다 — 이벤트에서 만들어 클로저로 유지되고(src/slack/bot.ts:409-490), 결과 수집은 requestId 로 필터된다(src/orchestrator/collect.ts:55). 다만 이 필터는 양쪽 값이 모두 있을 때만 동작하며, Slack 은 항상 공급하므로 현재는 성립한다.
6. 권고 (감사 반영 후 수정됨)
C안과 D안(수정본)을 동급 후보로 놓고, C안을 우선 검토한다.
- 초안은 D안을 1순위로 권고했으나, 감사 결과 D는 회수 대상을 잘못 지정했고(세션 행이 아니라 바인딩) 맥락 복구가 발화하지 않는다. 고칠 수 있지만 대가가 초안이 말한 것보다 크다(대화 기록 삭제).
- C안은 채널 단위 세션 수와 스레드별 병렬성을 동시에 준다. 초안의 기각 근거였던 "scope 계약을 바꿔야 한다"와 "맥락 공유를 못 얻는다"가 둘 다 코드상 사실이 아니었다.
- B안의 대가(채널 내 순차 응답)는 실재하고 되돌리기 어렵다. 교차 스레드 맥락 공유가 진짜 요구일 때만 선택할 값이다.
7. 결정 요청
- 세션 단위: A(현행) / B(채널) / C(세션=채널, 실행=스레드) / D(현행+회수, 수정본) 중 택 1
- B안 선택 시 — 채널 내 순차 응답을 수용하는가?
- D안 선택 시 — 회수 TTL 기본값(제안 72시간)과 대화 기록 삭제를 수용하는가?
multiSession.maxConcurrent 를 2에서 올릴 것인가? (별개 축이지만 체감 병렬성에 직결)
부록: 검증 환경
npm run typecheck → exit 0 (실측).
npm test → 작업트리 clean 상태에서 고유 실패 25건 + 요약줄 1 (PRD-* 10, P3-* 5, dispatch 6, sidecar-prune 2, 로드 실패 3파일 등). 회귀 판정은 이 baseline 대비 comm -13 비교로만 가능하며 "전체 통과"를 수용 기준으로 쓸 수 없다. npm test 는 tests/integration/ 을 제외한다.
감사: 독립 서브에이전트 1회(GO-WITH-FIXES, blockers=5). 지적 5건 모두 main 세션이 코드로 재확인 후 본문에 반영했다.
요약
현재 Slack은 스레드마다 별도 chat session 을 만든다. 세션 수가 스레드 수만큼 늘어 관리가 어렵다는 지적이 있었다. 채널 단위로 접는 안을 검토했고, 그 대가와 대안을 정리해 결정을 요청한다.
핵심 트레이드오프: 채널 단위로 접으면 같은 채널 안에서는 응답이 순차 처리된다. 세션 키가 실행 스코프를 겸하는 구조에서 나오는 필연적 귀결이다.
1. 현재 구조
buildRemoteBindingKey(src/messaging/session-key.ts:34-39) 가threadId를 키에 넣는다.이 키는
remote_session_bindings의 PK(src/core/db.ts:105-107)이고, 키가 없으면resolveOrCreateRemoteSession(src/core/chat-sessions.ts:122-142) 이 새chat_sessions행을 INSERT 한다. 스레드 1개 = 세션 1개 = 사이드바 항목 1개.이 키가 동시에 결정하는 것
scopeForChatSession(src/orchestrator/scope.ts:52-53) 이remoteKey를 그대로scope로 반환한다. 그래서 세션 키는 세션만 정하지 않는다.chat-sessions.ts:137-140<cli>:<model>:<scope>src/agent/args.ts:194sessionLanes실행 레인src/orchestrator/session-lanes.ts:39-51isAgentBusy(scope)src/agent/spawn.ts:467세션 키를 바꾸면 동시 실행 단위가 같이 바뀐다. 이게 이 이슈의 전부다.
2. 현재 최대 동시 병렬 응답 수
레인에 들어가는 턴의 상한은
multiSession.maxConcurrentactive는 프로세스 전역 카운터(session-lanes.ts:22)다.src/core/config.ts:348)config.ts:228)multiSession.enabled=falsedefault로 collapse,scope.ts:52)단, 이건 전역 천장이 아니다 (감사 정정)
Telegram·Discord·heartbeat 턴은
sessionLanes를 거치지 않는다. 레인에 계수되지도, 제한되지도 않는다.src/telegram/bot.ts:1073—await orchestrateAndCollectData(...)직접 호출src/discord/bot.ts:554—await orchestrateAndCollect(...)직접 호출src/memory/heartbeat.ts:225— 동일Slack만
admitSlackRun이 감싼다 (src/slack/ingress.ts:249).재진입 예외도 있다 —
session-lanes.ts:27의if (this.runningScope.getStore() === scopeKey) return Promise.resolve().then(task);는active를 올리지 않고 실행된다.답
Slack 단독 활성 호스트에서 레인 경유 턴의 동시 실행 상한은 2. (이 조건은
messaging.enabledChannels: ["slack"]인 현재 배포 형태와 일치한다.) Telegram/Discord/heartbeat를 함께 켜면 그 경로는 이 상한 밖에서 추가로 돈다.인그레스 레인(
src/slack/ingress.ts:180-205)은 동시성 상한이 아니다 — 같은 키의 이벤트를 순서대로 흘리는 직렬화일 뿐이고, 키가 다르면 무제한 진입한다.즉 스레드가 30개여도 Slack 동시 응답은 2개이고 나머지는 대기한다.
3. 선택지
A안 — 현행 유지 (스레드마다 세션)
얻는 것: 스레드별 독립 실행(상한 내), 스레드별 CLI 컨텍스트 격리, 작은 오배송 표면.
잃는 것: 세션 수가 스레드 수만큼 증가. vendor 세션도 같이 증가. 스레드 간 맥락 공유 불가.
B안 — 채널 단위로 접기
얻는 것: 세션 수 = 채널 수. 채널 내 스레드가 하나의 대화 맥락 공유.
잃는 것: 같은 채널 안에서 병렬 응답 불가.
scope가 채널 단위가 되고sessionLanes는 스코프별로 tail 을 체이닝하므로(session-lanes.ts:39-51) 같은 채널의 턴은 무조건 순차다. 스레드1이 3분 작업을 돌면 스레드2는 3분 뒤에 답을 받는다.인그레스 레인 키만 스레드로 남겨도 해소되지 않는다 —
sessionLanes가 별도 레이어다. (slackIngressLaneKey가buildRemoteBindingKey를 재사용하므로, 바인딩을 접으면 인그레스 레인도 같이 접힌다.)C안 — 세션은 채널, 실행은 스레드 (감사 결과 재평가: 유력 후보)
초안은 이 안을 "세션 목록만 예뻐지는 절충"으로 기각했으나, 감사 결과 그 기각이 틀렸다.
resolveOrcScope(scope.ts:57-61)가 둘을 따로 받고, Slack은preResolvedScope로 이미 독립 주입한다(src/slack/ingress.ts:239-241).chatSessionId로 키잉된다 —buildHistoryBlock(currentPrompt, workingDir, chatSessionId, ...)(src/agent/spawn.ts:791, 호출:1351). 따라서 C안에서 새 스레드의 첫 턴은 resume가 아니므로shouldBuildHistoryBlock이 true가 되고(src/agent/prompt-context.ts:21-22), 에이전트는 채널의 스레드 간 히스토리를 받는다.src/agent/args.ts:194) 동시 스레드는 서로 다른 버킷을 쓴다 — "같은 vendor 세션에 두 턴" 문제가 발생하지 않는다.즉 C안은 채널 단위 세션 수 + 스레드별 병렬성 + 스레드 진입 시 교차 맥락을 동시에 준다.
진짜 대가: 첫 턴 이후에는 vendor 측 컨텍스트가 스레드별로 갈린다(교차 맥락은 DB 히스토리 경유로만).
설계 제약: 스레드 scope가
isRemoteBindingScope(scope.ts:29-31,startsWith('jaw:'))와 충돌하면 안 된다 — 충돌 시 바인딩 키로 오인된다.D안 — A안 유지 + 유휴 스레드 세션 회수
초안의 설계는 틀렸다. 감사가 두 가지를 지적했다.
chat_sessions기준이고 바인딩은 LEFT JOIN 이다(chat-sessions.ts:45-55). 바인딩 없는 세션도 그대로 나온다. CASCADE 는 반대 방향으로만 걸려 있다(db.ts:110). →deleteChatSession(chat-sessions.ts:188)을 호출해야 하고, 그러면deleteMessagesStmt.run(sessionId)(:195)로 해당 스레드의 DB 대화 기록이 삭제된다. "CLI 컨텍스트 손실"이 아니라 기록 파괴다.src/slack/thread-tracker.ts:158-160). 바인딩 회수는scopeGenerations를 올리지 않으므로 클레임이 살아남아 재주입이 안 된다(src/slack/bot.ts:816). → 회수 시bumpScopeSessionGeneration(scope)(src/agent/session-persistence.ts:46)를 같이 호출해야 한다.수정하면 여전히 유효한 안이다: TTL 초과 +
isAgentBusy(scope)false 인jaw:slack:%:thread:%만 회수, 채널 최상위는 제외.4. B안 선택 시 같이 봐야 하는 것 — mid-run 주입 (런타임 조건부)
초안은 이를 "기본값"이라 했으나 틀렸다. 주입은 활성 런타임이
jwc일 때만 일어난다.기본 CLI 는
codex-app이다(src/cli/registry.ts:271, 이 호스트 설정도"cli": "codex-app").canSteerAgent가 false 면gateway.ts:95의if (!canSteerAgent(ctx.scopeKey)) return queue();로 떨어지고, 큐 항목은 자기 target 을 들고 있으므로(src/agent/spawn/queue.ts:531) 자기 스레드로 답한다.jwc 런타임에서는 실재하는 위험이다:
spawn.ts:716→jwc-runtime.ts:174(streamingBehavior:'steer')로 돌고 있는 턴에 주입되고,settleOnce(meta?.requestId, 'steered')(spawn.ts:720)라 주입된 메시지는 자기 답을 받지 못한다. collect 병합도runItems[0].target으로만 답한다(queue.ts:511-512, 522-527).→ B안의 선행 조건은 런타임 조건부이며,
codex-app호스트에서는 순차 큐잉으로 degrade 되어 라우팅은 정확하다.5. 인접 이슈 (중복 방지)
sendChannelOutput의 전역 폴백은 그쪽 소관이다. 턴 주소 고정은 이미 랜딩했다(src/messaging/send.ts:360-363, commitab33fc74).allowActiveFallback).devlog/260825_lastactive_misrouting/(status: active) 가 이 계열을 담당한다.본 이슈는 세션 키 단위 결정만 다루며 위 폴백 설계를 중복하지 않는다.
한편 답장 target 자체는 안전하다 — 이벤트에서 만들어 클로저로 유지되고(
src/slack/bot.ts:409-490), 결과 수집은requestId로 필터된다(src/orchestrator/collect.ts:55). 다만 이 필터는 양쪽 값이 모두 있을 때만 동작하며, Slack 은 항상 공급하므로 현재는 성립한다.6. 권고 (감사 반영 후 수정됨)
C안과 D안(수정본)을 동급 후보로 놓고, C안을 우선 검토한다.
7. 결정 요청
multiSession.maxConcurrent를 2에서 올릴 것인가? (별개 축이지만 체감 병렬성에 직결)부록: 검증 환경
npm run typecheck→ exit 0 (실측).npm test→ 작업트리 clean 상태에서 고유 실패 25건 + 요약줄 1 (PRD-* 10, P3-* 5, dispatch 6, sidecar-prune 2, 로드 실패 3파일 등). 회귀 판정은 이 baseline 대비comm -13비교로만 가능하며 "전체 통과"를 수용 기준으로 쓸 수 없다.npm test는tests/integration/을 제외한다.감사: 독립 서브에이전트 1회(GO-WITH-FIXES, blockers=5). 지적 5건 모두 main 세션이 코드로 재확인 후 본문에 반영했다.