Skip to content

[Design] Slack 세션 단위 결정: 스레드별 유지 vs 채널 단위 통합 — 채널 내 병렬성과 맞바꾸는 문제 #489

Description

@parkjs101

요약

현재 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:1073await orchestrateAndCollectData(...) 직접 호출
  • src/discord/bot.ts:554await orchestrateAndCollect(...) 직접 호출
  • src/memory/heartbeat.ts:225 — 동일

Slack만 admitSlackRun 이 감싼다 (src/slack/ingress.ts:249).
재진입 예외도 있다 — session-lanes.ts:27if (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 가 별도 레이어다. (slackIngressLaneKeybuildRemoteBindingKey 를 재사용하므로, 바인딩을 접으면 인그레스 레인도 같이 접힌다.)

C안 — 세션은 채널, 실행은 스레드 (감사 결과 재평가: 유력 후보)

초안은 이 안을 "세션 목록만 예뻐지는 절충"으로 기각했으나, 감사 결과 그 기각이 틀렸다.

  1. scope와 session은 이미 별개 파라미터다 — resolveOrcScope (scope.ts:57-61)가 둘을 따로 받고, Slack은 preResolvedScope 로 이미 독립 주입한다(src/slack/ingress.ts:239-241).
  2. DB 히스토리 블록은 scope가 아니라 chatSessionId 로 키잉된다buildHistoryBlock(currentPrompt, workingDir, chatSessionId, ...) (src/agent/spawn.ts:791, 호출 :1351). 따라서 C안에서 새 스레드의 첫 턴은 resume가 아니므로 shouldBuildHistoryBlock 이 true가 되고(src/agent/prompt-context.ts:21-22), 에이전트는 채널의 스레드 간 히스토리를 받는다.
  3. 버킷은 scope로 키잉되므로(src/agent/args.ts:194) 동시 스레드는 서로 다른 버킷을 쓴다 — "같은 vendor 세션에 두 턴" 문제가 발생하지 않는다.

즉 C안은 채널 단위 세션 수 + 스레드별 병렬성 + 스레드 진입 시 교차 맥락을 동시에 준다.

진짜 대가: 첫 턴 이후에는 vendor 측 컨텍스트가 스레드별로 갈린다(교차 맥락은 DB 히스토리 경유로만).
설계 제약: 스레드 scope가 isRemoteBindingScope (scope.ts:29-31, startsWith('jaw:'))와 충돌하면 안 된다 — 충돌 시 바인딩 키로 오인된다.

D안 — A안 유지 + 유휴 스레드 세션 회수

초안의 설계는 틀렸다. 감사가 두 가지를 지적했다.

  1. 바인딩만 지우면 세션 목록이 줄지 않는다. 목록은 chat_sessions 기준이고 바인딩은 LEFT JOIN 이다(chat-sessions.ts:45-55). 바인딩 없는 세션도 그대로 나온다. CASCADE 는 반대 방향으로만 걸려 있다(db.ts:110). → deleteChatSession (chat-sessions.ts:188)을 호출해야 하고, 그러면 deleteMessagesStmt.run(sessionId) (:195)로 해당 스레드의 DB 대화 기록이 삭제된다. "CLI 컨텍스트 손실"이 아니라 기록 파괴다.
  2. 스레드 히스토리 프리페치가 맥락을 복구하지 못한다. 프리페치 클레임은 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:95if (!canSteerAgent(ctx.scopeKey)) return queue(); 로 떨어지고, 큐 항목은 자기 target 을 들고 있으므로(src/agent/spawn/queue.ts:531) 자기 스레드로 답한다.

jwc 런타임에서는 실재하는 위험이다: spawn.ts:716jwc-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. 결정 요청

  1. 세션 단위: A(현행) / B(채널) / C(세션=채널, 실행=스레드) / D(현행+회수, 수정본) 중 택 1
  2. B안 선택 시 — 채널 내 순차 응답을 수용하는가?
  3. D안 선택 시 — 회수 TTL 기본값(제안 72시간)과 대화 기록 삭제를 수용하는가?
  4. 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 testtests/integration/ 을 제외한다.

감사: 독립 서브에이전트 1회(GO-WITH-FIXES, blockers=5). 지적 5건 모두 main 세션이 코드로 재확인 후 본문에 반영했다.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:messagingCross-channel messaging runtime and adaptersarea:orchestratorOrchestration, workers, queue, lifecyclequestionFurther information is requested

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions