From d7598a1f8f755af62c776e5b810e817b76051d06 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 13 Sep 2026 11:39:30 +0900 Subject: [PATCH 1/2] docs(devlog): plan the Devin landing, TTFB deadline and prompt-cache work Five work phases with diff-level designs, derived from five parallel read-only research lanes and audited by three independent reviewers. The headline finding is that cloud-direct kills live turns. The comment at chat.ts:1118 claims the TTFB timer is cancelled once any byte arrives, but the timer is cleared in the finally that runs when await fetch() resolves with HEADERS. Cognition holds the headers until the first token, so a 60s budget guillotines a high-effort swe-2 turn that is still alive. Three live 504s at ~60s with no first output are recorded in 030. The second finding is that we never request prompt caching at all. The request encoder emits fields 1, 2, 3, 7, 8, 10, 15, 16, 20, 21 and omits field 13, PromptCacheOptions{EPHEMERAL}, which CLIProxyAPIPlus sends unconditionally and the native CLI relies on. The proposed encoding was checked byte-for-byte against the Go encoder: 6a 02 08 01. The three implementations are each half a solution. CLIProxyAPIPlus sends the cache option but mints a new session per request; we reuse sessions but send no cache option; omp/omo is not a Devin transport at all. The target is our session reuse plus their cache option plus an identity boundary neither has. Audit folded four blockers: the effort-suffix token sets have drifted so -priority UIDs get a second suffix (now wp5), wrapping an abort reason in CloudChatError is unsafe because fetch may throw AbortError instead, and the exported clearSessionIds is a global Map.clear that would drop other accounts in-flight sessions, so it is removed rather than documented. The CLIProxyAPIPlus reference clone joins the ignored-clone allowlist and its hygiene guard, following the DSCodex precedent. Planning only; no product code changes. Local product suite, typecheck, build and install: NOT RUN. --- .gitignore | 1 + .../000_plan.md | 125 ++++++++++ .../010_wp1_swe2_effort_carry.md | 116 ++++++++++ .../020_wp2_devin_cli_fixture_carry.md | 66 ++++++ .../030_wp3_ttfb_generation_deadline.md | 185 +++++++++++++++ ...040_wp4_devin_prompt_cache_and_identity.md | 215 ++++++++++++++++++ .../050_wp5_effort_suffix_unification.md | 79 +++++++ tests/ci-workflows/repo-hygiene.test.ts | 1 + 8 files changed, 788 insertions(+) create mode 100644 devlog/_plan/260913_devin_landing_and_caching/000_plan.md create mode 100644 devlog/_plan/260913_devin_landing_and_caching/010_wp1_swe2_effort_carry.md create mode 100644 devlog/_plan/260913_devin_landing_and_caching/020_wp2_devin_cli_fixture_carry.md create mode 100644 devlog/_plan/260913_devin_landing_and_caching/030_wp3_ttfb_generation_deadline.md create mode 100644 devlog/_plan/260913_devin_landing_and_caching/040_wp4_devin_prompt_cache_and_identity.md create mode 100644 devlog/_plan/260913_devin_landing_and_caching/050_wp5_effort_suffix_unification.md diff --git a/.gitignore b/.gitignore index ce10233dcc..00394f7dee 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ src/generated/compatibility-version.json devlog/_chase/_cca/ devlog/_chase/_litellm/ devlog/_chase/DSCodex/ +devlog/_chase/CLIProxyAPIPlus/ devlog/_fin/opencode-cursor/ devlog/_plan/*/_ref_*/ devlog/**/*-security-redaction/ diff --git a/devlog/_plan/260913_devin_landing_and_caching/000_plan.md b/devlog/_plan/260913_devin_landing_and_caching/000_plan.md new file mode 100644 index 0000000000..410b0720e0 --- /dev/null +++ b/devlog/_plan/260913_devin_landing_and_caching/000_plan.md @@ -0,0 +1,125 @@ +# 000 — Devin 착지와 캐싱 개선 (계획) + +- 단위 슬러그: `260913_devin_landing_and_caching` +- 세션: `01a0985e-ce1a-7d12-81b9-c2e93a2bce67` (HOTL, cxc-loop) +- 기준 HEAD: `7ca00ffe7c1299e80d650a3243b2bc7cf09109ad` (= `origin/dev`, 확인 시각 2026-09-13) +- 워크트리: `/Users/jun/.codex/worktrees/8513/opencodex` (detached, app-managed) + +## 목적 + +열려 있는 Devin 관련 draft 두 건을 현재 `dev`에 착지시키고, 그 위에서 Devin +트랜스포트의 캐싱을 CLIProxyAPIPlus와 omp/omo보다 낫게 만든다. 작업 중 사용자가 +실제로 맞은 런타임 오류(`stream disconnected before completion: cloud-direct: +time-to-first-byte timeout (60000ms)`)도 같은 단위에서 근본 원인까지 고친다. + +그 오류는 부수적인 잡음이 아니라 이 단위의 핵심이다. Devin에게 직접 코드를 +수정시키려던 시도가 실패한 이유가 바로 이것이고, 아래 wp3에서 보듯 프록시가 +살아 있는 업스트림을 스스로 끊고 있었다. + +## 제약 (사용자 지시 + AGENTS.md) + +| 제약 | 내용 | +|---|---| +| 로컬 스위트 금지 | `bun run test` / `typecheck` / `build` / `install` / `structure:check` / `privacy:scan` 모두 **NOT RUN**. 증거는 carry PR의 exact-final-head hosted CI. | +| 푸시 경로 | `dev`/`main`/`preview` 직접 푸시 금지. 전부 PR 경유. | +| 머지 권한 | `lidge-jun`은 `admin`. MAINTAINERS.md의 maintainer integration 조항으로 `dev` 한정 단독 통합 가능. 결정과 exact-head 검증을 PR에 기록해야 한다. | +| 저작자 보존 | 남의 PR을 carry하면 `Co-authored-by` 트레일러 필수 (AGENTS.md, CREDITS.md). 산문 언급은 무효. | +| 보안 노트 | 미공개 취약점 분석은 `.tmp/`에만. `devlog/`는 공개 디렉터리다. | +| 서브에이전트 | `xai/grok-4.6` 무제한 병렬 파견 허용 (사용자 명시). 리프는 쓰기 범위가 서로 겹치지 않아야 한다. | + +## 조사 산출물 (이 계획의 근거) + +5개 레인을 `xai/grok-4.6`으로 병렬 파견해 얻은 read-only 리포트. 전부 `.tmp/`에 있고 +추적되지 않는다. + +| 레인 | 산출물 | 핵심 결론 | +|---|---|---| +| A | `.tmp/research/laneA-devin-binary.md` | 로컬 Devin CLI `3000.10.21 (611c1cba)` 해부 | +| B | `.tmp/research/laneB-cliproxyapiplus.md` | Plus vs omp/omo vs opencodex 3자 대조 | +| C | `.tmp/research/laneC-our-devin.md` | 자사 devin/devin-cli 캐싱 전수 인벤토리 | +| D | `.tmp/research/laneD-ttfb.md` | TTFB 504 근본 원인 + 라이브 로그 3건 | +| E | `.tmp/research/laneE-carry-prs.md` | #4420/#4384 patch, 트레일러, apply 검증 | + +참조 클론: `devlog/_chase/CLIProxyAPIPlus/` (gitignored, AGENTS.md `_chase` 규약). + +## 3자 대조 요약 + +가장 중요한 발견은 두 구현이 정확히 반대 방향으로 반쪽이라는 것이다. + +| 능력 | CLIProxyAPIPlus | omp/omo | opencodex (오늘) | 판정 | +|---|---|---|---|---| +| 세션/캐스케이드 재사용 | 매 요청 새로 생성 (`devin_executor.go:626-637`) | 해당 없음 | `(host, apiKey)` 재사용 (`chat.ts:72-91`) | **OCX 우위** | +| 프롬프트 캐시 옵션 f13 | 항상 전송 (`devin_request.go:335`, `devinEncodeCacheOptions`) | 해당 없음 | **없음** (`chat.ts:650-673`) | **OCX 결손** | +| 카탈로그 TTL 캐시 | 없음 | 없음 | 10분 (`catalog.ts:54`) | OCX 우위 | +| `invalid_argument` cooldown 회피 | HTTP 400 재분류 (`devin_executor.go:959-983`) | 해당 없음 | 없음 (`devin.ts:53-64`) | **OCX 결손** | +| tool 설명 절단 | 1024B rune-safe (`devin_tools.go:125-151`) | 해당 없음 | 6998 JS `slice` (`chat.ts:586-587`) | **OCX 결손** (한글 중간 절단) | +| 자격증명 identity 분리 | 요청 스코프 | 해당 없음 | `(host, apiKey)` 싱글톤, 계정 전환 시 미소거 | **OCX 결손** | + +`omp`/`omo`는 Devin 트랜스포트가 아니다. `omp.ts`는 Oh My Pi YAML, `omo`는 senpi +`models.json` + `sendSessionAffinityHeaders`다. 캐싱 비교 대상은 실질적으로 Plus 하나이며, +"Plus보다 낫게"의 정의는 **OCX의 세션 재사용 + Plus의 f13 + Plus에 없는 identity 분리**다. + +## 작업 단계 지도 (의존 순) + +``` +wp0 (이 문서) ──┬── wp1 #4420 carry (독립) + ├── wp2 #4384 carry (독립) + ├── wp3 TTFB 생성 데드라인 (독립, 사용자 실측 버그) + └── wp4 Devin 캐싱/identity (wp3와 같은 파일 → wp3 다음) +``` + +| wp | 문서 | 산출물 | 쓰기 범위 | +|---|---|---|---| +| wp0 | 이 문서 + 010/020/030/040 | 로드맵 | `devlog/_plan/260913_devin_landing_and_caching/` | +| wp1 | `010_wp1_swe2_effort_carry.md` | carry PR → merge | `src/adapters/devin.ts`, `tests/providers/devin-adapter.test.ts`, docs/structure | +| wp2 | `020_wp2_devin_cli_fixture_carry.md` | carry PR → merge | `tests/providers/devin-cli-login.test.ts` | +| wp3 | `030_wp3_ttfb_generation_deadline.md` | 버그픽스 PR → merge | `src/adapters/devin/cloud-direct/chat.ts`, `src/adapters/devin.ts`, 신규 테스트 | +| wp4 | `040_wp4_devin_prompt_cache_and_identity.md` | 기능 PR → merge | `chat.ts` 인코더/세션, `catalog.ts`, `auth.ts`, 신규 테스트 | + +wp1과 wp2는 파일이 겹치지 않는다 (레인 E 확인). wp3과 wp4는 둘 다 `chat.ts`를 +만지므로 순차로 간다. + +## 완료 기준 + +| id | 기준 | 증거 | +|---|---|---| +| c-1 | 이 단위가 000 + 단계별 decade 문서를 diff 수준으로 보유 | 파일 목록 | +| c-2 | #4420 수정이 `dev`에 merge | merge SHA + 트레일러 + CI run id | +| c-3 | #4384 수정이 `dev`에 merge | merge SHA + 트레일러 + CI run id | +| c-4 | TTFB 504가 사라지고 회귀 테스트 존재 | merge SHA + CI run id | +| c-5 | 캐싱 개선이 merge되고 Plus/omp 대조표가 문서화 | merge SHA + 이 문서의 대조표 | +| c-6 | 모든 merge가 exact-final-head hosted CI 성공 | PR별 run id, cancelled/skipped는 성공으로 세지 않음 | + +## 종료 조건 + +- `DONE`: c-1..c-6 전부 충족, 이 단위를 `_fin/`으로 이동. +- `BLOCKED`: fork 푸시 거부로 carry 불가, 또는 동일 head에서 CI 2회 연속 red. + + +## A 단계 감사 결과 (2026-09-13) + +`xai/grok-4.6` 리뷰어 2명을 병렬로 붙여 로드맵 전체를 트리와 대조했다. 두 감사 모두 +`VERDICT: fail`로 돌아왔고, 블로커 4건은 아래처럼 반영했다. + +| 블로커 | 내용 | 반영 | +|---|---|---| +| A-1 | `EFFORT_SUFFIXES`에 `priority` 누락 → `-priority` UID에 접미사 이중 부착 | **wp5 신설** (`050_...md`) | +| A-2 | abort 사유를 `CloudChatError`로 감싸도 `AbortError`에 먹힐 수 있음 | 030 감사 반영 절 (catch에서 명시 throw) | +| B-1 | `clearSessionIds()`가 전역 `Map.clear()`라 타 계정 진행 턴을 끊음 | 040 감사 반영 절 (identity 스코프 + epoch) | +| B-2 | (통과) 필드 13 인코딩 `6a 02 08 01` Plus와 바이트 동일 | 변경 없음 | + +함께 확인된 것: 여섯 개 structure 복붙 hunk를 빼도 `structure:check`는 깨지지 않는다 +(게이트는 경로 존재만 본다). sha256 캐시 키 전환을 깨뜨릴 호출자나 테스트는 없다. + +감사 원문: `.tmp/research/audit-a-facts.md`, `.tmp/research/audit-b-cache.md`. + +## 갱신된 작업 단계 지도 + +``` +wp0 ──┬── wp1 #4420 carry + ├── wp2 #4384 carry + ├── wp3 TTFB 생성 데드라인 + ├── wp4 Devin 캐싱/identity (wp3 다음, 같은 파일) + └── wp5 effort 접미사 통합 (wp1 다음, 같은 함수) +``` + diff --git a/devlog/_plan/260913_devin_landing_and_caching/010_wp1_swe2_effort_carry.md b/devlog/_plan/260913_devin_landing_and_caching/010_wp1_swe2_effort_carry.md new file mode 100644 index 0000000000..de58567470 --- /dev/null +++ b/devlog/_plan/260913_devin_landing_and_caching/010_wp1_swe2_effort_carry.md @@ -0,0 +1,116 @@ +# 010 — wp1: #4420 carry (SWE-2 명시 effort가 접미사를 이긴다) + +- 원 PR: https://github.com/lidge-jun/opencodex/pull/4420 (`Smartnewb`, draft) +- 원 head: `6a456fb2af306a2d30a36e2f884c75318d3dd18b`, base `f5b2a0d00` (현재 `dev`보다 27커밋 뒤) +- patch: `.tmp/research/4420.patch` (sha256 `3a03dda5064298c5c156dbed0b14865451967f2f9bbba04f2dacd8d86ae93227`) +- apply 검증: `git apply --check` / `--3way --check` 둘 다 EXIT 0, reject 없음 (레인 E) + +## 왜 아직 필요한가 + +현재 `dev`(`7ca00ffe7`)의 `src/adapters/devin.ts:99-106`: + +```ts +async function resolveWireModelUid( + rawModelId: string, + apiKey: string, + host: string, + reasoningEffort?: string, +): Promise { + const modelId = normalizeDevinModelId(rawModelId); + if (hasEffortSuffix(modelId)) return modelId; // <- 여기서 끝난다 +``` + +`swe-2-high`를 명시 effort `medium`으로 부르면 `hasEffortSuffix`가 참이라 즉시 반환되고, +호출자가 지정한 `medium`은 버려진다. `#4415`가 ACP를 걷어낸 뒤 이 공유 어댑터에는 +SWE-2 재작성 경로가 없다. `rg` 결과 `swe-2` 정규식도, `SWE-2 wire effort selection` +describe도 트리에 없다. + +## MODIFY: src/adapters/devin.ts + +`resolveWireModelUid`를 export하고, `hasEffortSuffix` 조기 반환 **앞에** SWE-2 분기를 넣는다. + +```ts +// before + const modelId = normalizeDevinModelId(rawModelId); + if (hasEffortSuffix(modelId)) return modelId; + +// after + const modelId = normalizeDevinModelId(rawModelId); + const swe2 = resolveSwe2Variant(modelId, reasoningEffort); + if (swe2) return swe2; + if (hasEffortSuffix(modelId)) return modelId; +``` + +신규 헬퍼 (원 PR은 인라인이었다 — grok-bot이 지적한 이중 유지보수를 피해 분리한다): + +```ts +const SWE2_EFFORT: Record = { + none: "medium", off: "medium", minimal: "medium", low: "medium", medium: "medium", + high: "high", + xhigh: "max", ultra: "max", max: "max", +}; + +function resolveSwe2Variant(modelId: string, reasoningEffort?: string): string | undefined { + if (!/^swe-2(?:-(?:medium|high|max))?$/.test(modelId)) return undefined; + const mapped = reasoningEffort ? SWE2_EFFORT[reasoningEffort.toLowerCase()] : undefined; + return mapped ? `swe-2-${mapped}` : undefined; +} +``` + +effort를 안 줬거나 모르는 값이면 `undefined`를 돌려 기존 경로가 그대로 돈다. +`EFFORT_SUFFIXES`(`devin.ts:69`)에 `ultra`/`off`/`minimal`이 없다는 사실은 이 표가 +별도로 필요한 이유이자, 표를 한 곳에 모아야 하는 이유다. + +## MODIFY: tests/providers/devin-adapter.test.ts + +`SWE-2 wire effort selection` describe를 추가한다. 원 PR의 4케이스에 회귀 2건을 더한다. + +| 입력 modelId | reasoningEffort | 기대 UID | +|---|---|---| +| `swe-2-high` | `medium` | `swe-2-medium` | +| `swe-2` | `xhigh` | `swe-2-max` | +| `swe-2-medium` | `high` | `swe-2-high` | +| `swe-2-high` | (없음) | `swe-2-high` | +| `swe-2-high` | `bogus` | `swe-2-high` | +| `gpt-5-6-sol-high` | `medium` | `gpt-5-6-sol-high` (타 계열 불변) | + +import 라인 2를 `resolveWireModelUid` 포함으로 바꾼다. + +## MODIFY: 문서 2개만 + +- `docs-site/src/content/docs/reference/adapters.md` — `devin` 절에 SWE-2 effort 문단 1개 +- `structure/adapters/registry.md` — 소유권 문장 1줄 + +원 PR이 같은 문장을 `structure/data-planes/inbound-compat.md`, +`structure/providers/chat-compat.md`, `structure/providers/cursor.md`, +`structure/runtime.md`, `structure/transports/inventory.md`, +`structure/transports/responses.md` 6곳에 복붙했다. grok-bot 리뷰가 지적한 대로 +structure-gate 인접성을 통과하려는 잡음이므로 **omit**한다. 만약 `structure:check`가 +hosted CI에서 이를 요구하면 그때 되살린다 (CI가 판정자). + +## 커밋 메시지 + +```text +fix(devin): apply explicit SWE-2 effort before model suffix + +Carry #4420 from 6a456fb2af306a2d30a36e2f884c75318d3dd18b onto 7ca00ffe7. +An explicit SWE-2 reasoning effort must win over a picker suffix, so +swe-2-high + medium becomes swe-2-medium before hasEffortSuffix +short-circuits. Omitted or unknown effort keeps the variant; other +families keep suffix precedence. + +The effort map is a named table rather than an inline branch, because +EFFORT_SUFFIXES does not carry ultra/off/minimal and the two would drift. +The six copy-paste structure hunks from the source PR are omitted. + +Local product tests / typecheck / build / install: NOT RUN. +Hosted exact-head CI on this PR is the merge proof. + +Co-authored-by: Smartnewb <159137930+Smartnewb@users.noreply.github.com> +``` + +## 착지 후 + +- #4420을 close하고 carry PR을 가리키는 코멘트를 남긴다 (`dev` 타깃이라 자동 close 안 됨). +- #4416은 이미 closed. ACP는 되살리지 않는다. + diff --git a/devlog/_plan/260913_devin_landing_and_caching/020_wp2_devin_cli_fixture_carry.md b/devlog/_plan/260913_devin_landing_and_caching/020_wp2_devin_cli_fixture_carry.md new file mode 100644 index 0000000000..5497dbb408 --- /dev/null +++ b/devlog/_plan/260913_devin_landing_and_caching/020_wp2_devin_cli_fixture_carry.md @@ -0,0 +1,66 @@ +# 020 — wp2: #4384 carry (빈 XDG_DATA_HOME 폴백을 호스트 홈에 고정) + +- 원 PR: https://github.com/lidge-jun/opencodex/pull/4384 (`luvs01`, draft) +- 원 head: `fdba29bc1ae1cf262430764221312d729476a551`, base `dcd13b435` (현재 `dev`보다 1커밋 뒤) +- patch: `.tmp/research/4384.patch` (sha256 `662d9993bb262009accc93248338e40960c301a81b8d221656fb0d813f265eaf`) +- apply 검증: `git apply --check` EXIT 0, offset 0, 1파일 + +## 왜 아직 필요한가 + +프로덕션은 이미 맞다. `src/oauth/devin-cli.ts:80`이 빈 `XDG_DATA_HOME`일 때 +`homedir()`로 폴백한다. 깨지는 건 테스트뿐이다. + +`tests/providers/devin-cli-login.test.ts:155`가 결과 경로에 `startsWith("/")`를 건다. +Windows 러너에서 폴백 경로는 `C:\Users\runneradmin\...`이므로 항상 false다. +실제 실패 로그 (fork run, Windows job): + +```text +D:\a\opencodex\opencodex\tests\providers\devin-cli-login.test.ts:155:40 +Expected: true +Received: false +``` + +## MODIFY: tests/providers/devin-cli-login.test.ts + +플랫폼 무관 단언으로 바꾼다. 경로 접두사를 문자열로 추측하지 말고 호스트 홈에 고정한다. + +```ts +// before +expect(resolved.startsWith("/")).toBe(true); + +// after +expect(resolved.startsWith(homedir())).toBe(true); +``` + +`homedir`는 `node:os`에서 import한다. 이것이 프로덕션 코드가 실제로 하는 일 +(`src/oauth/devin-cli.ts:80`)과 정확히 같은 계약이므로, 테스트가 구현을 복제하는 것이 +아니라 계약을 검증하게 된다. + +## 범위 밖 + +같은 Windows job에 quota-policy 실패 2건이 함께 있었다. `#4384`의 범위가 아니며 +이 carry에서 건드리지 않는다. 별도 단위로 남긴다. + +## 커밋 메시지 + +```text +test(devin-cli): anchor the empty-data-dir fallback at the host home + +Carry #4384 from fdba29bc1ae1cf262430764221312d729476a551 onto 7ca00ffe7. +The empty-XDG_DATA_HOME case asserted the resolved path starts with "/", +which is false on Windows where the fallback is C:\Users\\... +Anchor the assertion at homedir() instead, which is the contract +src/oauth/devin-cli.ts actually implements. + +Production behavior is unchanged; this is a test-only fix. + +Local product tests / typecheck / build / install: NOT RUN. +Hosted exact-head CI on this PR is the merge proof. + +Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> +``` + +## 착지 후 + +- #4384를 close하고 carry PR을 가리키는 코멘트를 남긴다. + diff --git a/devlog/_plan/260913_devin_landing_and_caching/030_wp3_ttfb_generation_deadline.md b/devlog/_plan/260913_devin_landing_and_caching/030_wp3_ttfb_generation_deadline.md new file mode 100644 index 0000000000..601ef606c7 --- /dev/null +++ b/devlog/_plan/260913_devin_landing_and_caching/030_wp3_ttfb_generation_deadline.md @@ -0,0 +1,185 @@ +# 030 — wp3: TTFB 타임아웃이 살아 있는 업스트림을 죽인다 + +사용자가 실제로 맞은 오류다. + +```text +stream disconnected before completion: cloud-direct: time-to-first-byte timeout (60000ms) +``` + +## 근본 원인 + +`src/adapters/devin/cloud-direct/chat.ts:50`: + +```ts +/** Time-to-first-byte timeout. */ +const CLOUD_STREAM_TTFB_MS = 60_000; +``` + +`chat.ts:1118-1131`의 주석은 이렇게 주장한다. + +> Once any byte arrives we cancel the TTFB timer and start the per-chunk idle timer + +그런데 실제 코드는 그렇지 않다. 타이머는 `chat.ts:1152-1153`의 `finally`에서 지워지고, +그 `finally`는 `await fetch(...)`가 **응답 헤더**로 resolve될 때 실행된다. 즉 이 60초는 +"첫 바이트"가 아니라 "헤더 도착"까지의 예산이다. 본문 첫 토큰은 보지 않는다. + +본문 무응답은 이미 별도 예산이 있다 — `CLOUD_STREAM_IDLE_MS = 120_000` (`chat.ts:48`, +`chat.ts:1246`). 그래서 현재 구조는 **헤더 60초 < 본문 idle 120초**로, 오래 생각하는 +모델일수록 관대해야 할 구간이 더 빡빡하다. + +Cognition은 SWE-2에서 첫 토큰이 나올 때까지 헤더를 붙들어 둔다. 그래서 effort가 높을수록 +헤더가 늦고, 우리가 스스로 끊는다. + +## 라이브 증거 + +사용자 로그에서 3건, 전부 같은 모양이다. + +| # | provider | model | effort | status | durationMs | firstOutputMs | attempts | +|---|---|---|---|---|---|---|---| +| 1 | `devin-cli` | `swe-2` | high | 504 | 60000 | 없음 | 1 | +| 2 | `devin-cli` | `swe-2` | high | 504 | 60024 | 없음 | 1 | +| 3 | `devin-cli` | `swe-2` | high | 504 | 55968 | 없음 | 1 | + +`service.log:66219`에 REJECTED `GetChatMessage` `ageMs=137197`이 있고, 같은 시각 형제 +호출은 76초까지 살아남았다. 업스트림은 죽지 않았다. 우리가 먼저 끊었다. + +## 두 번째 결함: 오류 분류가 비어 있다 + +abort 사유는 raw `Error`다 (`chat.ts:1123`). `CloudChatError`가 아니므로 +`devinErrorClassification`(`src/adapters/devin.ts:53-55`)이 `status === undefined`로 +`{}`를 반환하고, 분류가 `src/lib/errors.ts:429-435`의 문자열 추론으로 떨어져 +`504 upstream_server_error`가 된다. 우리 쪽 데드라인인데 업스트림 장애로 보고된다. + +## 세 번째 결함: `timeout: 0`이 없다 + +`chat.ts:1135`의 raw `fetch`에는 Bun 자체 fetch 타임아웃을 끄는 `timeout: 0`이 없다. +하우스 스타일은 `src/server/responses/fetch-helpers.ts:87`이다. + +```ts +const dispatchInit = { ...withUpstreamHttpVersion(input, init, provider), timeout: 0 }; +``` + +## MODIFY: src/adapters/devin/cloud-direct/chat.ts + +### 1) 헤더 예산을 생성 데드라인과 분리하고 설정 가능하게 + +```ts +// before +/** Time-to-first-byte timeout. */ +const CLOUD_STREAM_TTFB_MS = 60_000; + +// after +/** + * Time-to-response-headers budget. Cognition holds the response headers until + * the model produces its first token, so on a high-effort reasoning model this + * is a generation deadline, not a connect timeout. It must therefore be at + * least as generous as the idle budget below; a 60s value guillotined live + * swe-2 high turns at 60000ms with no output (three logged 504s, 2026-09-13). + * Override with OPENCODEX_DEVIN_TTFB_MS. + */ +const CLOUD_STREAM_TTFB_DEFAULT_MS = 300_000; +function cloudStreamTtfbMs(): number { + const raw = process.env.OPENCODEX_DEVIN_TTFB_MS?.trim(); + if (!raw) return CLOUD_STREAM_TTFB_DEFAULT_MS; + const parsed = Number(raw); + return Number.isFinite(parsed) && parsed > 0 ? parsed : CLOUD_STREAM_TTFB_DEFAULT_MS; +} +``` + +`OPENCODEX_ACL_TIMEOUT_MS`(`src/lib/windows-secret-acl.ts:271`)와 같은 하우스 패턴이다. + +### 2) abort를 분류 가능한 오류로 감싼다 + +```ts +// before +const ttfbTimer = setTimeout(() => ttfbController.abort(new Error(`cloud-direct: time-to-first-byte timeout (${CLOUD_STREAM_TTFB_MS}ms)`)), CLOUD_STREAM_TTFB_MS); + +// after +const ttfbMs = cloudStreamTtfbMs(); +const ttfbTimer = setTimeout( + () => ttfbController.abort(new CloudChatError(`cloud-direct: no response headers within ${ttfbMs}ms`, undefined, undefined, 504)), + ttfbMs, +); +``` + +메시지에서 "time-to-first-byte"라는 말을 뺀다. 헤더를 기다린 것이지 바이트가 아니다. + +### 3) `timeout: 0` 추가 + +```ts + resp = await fetch(url, { + method: "POST", + headers: { /* unchanged */ }, + body, + redirect: "error", + signal: initialSignal, + timeout: 0, + } as RequestInit); +``` + +### 4) 주석의 거짓말을 고친다 + +`chat.ts:1118-1121`의 "Once any byte arrives"는 사실이 아니다. "Once the response +headers arrive"로 정정한다. 이 주석이 이 버그를 가려 왔다. + +## NEW: tests/adapters/devin/cloud-direct-stream-deadline.test.ts + +fake `fetch` + fake timer로 시간을 실제로 흘려보내지 않는다. + +| 케이스 | 시나리오 | 기대 | +|---|---|---| +| A | 헤더가 기본 예산을 넘겨 안 옴 | `CloudChatError`, `status === 504`, 메시지에 `no response headers` | +| B | 헤더 90초 뒤 도착, 본문 정상 | **성공** (구 코드에서는 60초에 죽음) — 사용자 버그의 회귀 | +| C | 헤더 즉시, 본문 idle 초과 | 기존 idle 오류 유지 | +| D | `OPENCODEX_DEVIN_TTFB_MS=1000` | 1초에 abort (설정 반영) | +| E | fetch init에 `timeout: 0` 포함 | 캡처한 init 단언 | +| F | abort 오류가 `devinErrorClassification`에서 `status:504`, `retryable:true` | 분류 회귀 | + +케이스 B가 이 단계의 존재 이유다. + +## 하지 않는 것 + +프록시 레벨 자동 재시도는 넣지 않는다. Codex가 이미 같은 conversation으로 재시도했고, +생성 데드라인을 늘리는 것과 재시도는 다른 문제다. 재시도를 넣으면 토큰을 두 번 태운다. + + +## 감사 반영 (A 단계, BLOCKER-2) + +`ttfbController.abort(new CloudChatError(...))`로 abort 사유를 감싸는 설계는 취소한다. +`fetch`가 `signal.reason`을 그대로 던진다는 보장이 없다. 런타임에 따라 `AbortError`로 +감싸서 던지면 우리 `CloudChatError`는 사라지고 030 케이스 F가 실패한다. + +대신 abort 사유는 평범하게 두고, `fetch`를 감싼 `catch`에서 **우리 타이머가 발화했는지**를 +보고 명시적으로 던진다. 그래야 런타임 동작에 의존하지 않는다. + +```ts +let ttfbFired = false; +const ttfbMs = cloudStreamTtfbMs(); +const ttfbTimer = setTimeout(() => { + ttfbFired = true; + ttfbController.abort(); +}, ttfbMs); + +let resp: Response; +try { + resp = await fetch(url, { /* ... */ signal: initialSignal, timeout: 0 } as RequestInit); +} catch (err) { + if (ttfbFired) { + // Our deadline, not the upstream failing. Classify it as ours so + // devinErrorClassification sees a status instead of returning {}. + throw new CloudChatError(`cloud-direct: no response headers within ${ttfbMs}ms`, undefined, undefined, 504); + } + throw err; +} finally { + clearTimeout(ttfbTimer); + composed?.cleanup(); +} +``` + +케이스 F는 이 `catch` 경로를 직접 겨냥한다. 케이스 G를 추가한다: 호출자가 자기 +`req.signal`로 취소했을 때는 `ttfbFired`가 false라 원래 abort가 그대로 전파된다. + +감사에서 함께 확인된 것: 기본값 300000ms는 `timeout: 0`과 같이 가면 안전하고, +본문 침묵은 그대로 120초 idle이 잡는다. 라이브 `ageMs=137197` 사례가 있으므로 +120000은 헤더 예산으로 부족하다. + diff --git a/devlog/_plan/260913_devin_landing_and_caching/040_wp4_devin_prompt_cache_and_identity.md b/devlog/_plan/260913_devin_landing_and_caching/040_wp4_devin_prompt_cache_and_identity.md new file mode 100644 index 0000000000..fef239d0aa --- /dev/null +++ b/devlog/_plan/260913_devin_landing_and_caching/040_wp4_devin_prompt_cache_and_identity.md @@ -0,0 +1,215 @@ +# 040 — wp4: Devin 프롬프트 캐시와 자격증명 identity + +목표는 "Plus와 omp보다 훨씬 좋게"다. 조사 결과 그 목표가 구체적으로 무엇인지가 +분명해졌다: 세 구현이 각자 다른 반쪽을 갖고 있고, 아무도 전부를 갖고 있지 않다. + +| 능력 | 네이티브 CLI | Plus | opencodex 오늘 | wp4 이후 | +|---|---|---|---|---| +| 세션/캐스케이드 재사용 | 있음 (`sessions.db`) | **없음** (매 요청 새로) | 있음 | 있음 | +| 프롬프트 캐시 옵션 f13 | 있음 (원격) | 있음 (항상) | **없음** | 있음 | +| 캐시 identity 격리 | 있음 (`identity_digest`) | 요청 스코프라 무관 | **없음** (계정 누수) | 있음 | +| 카탈로그 TTL | 없음 | 없음 | 있음 (10분) | 있음 | +| `invalid_argument` cooldown 회피 | 해당 없음 | 있음 | **없음** | 있음 | +| rune-safe tool 절단 | 해당 없음 | 있음 (1024B) | **없음** (JS slice) | 있음 | + +## 근거 1 — 우리는 프롬프트 캐시를 아예 요청하지 않는다 + +`chat.ts:54-60`의 주석은 세션 재사용이 "prompt-cache hit ratio"를 살린다고 말한다. +그런데 요청 인코더(`chat.ts:650-673`)가 실제로 쓰는 필드는 1, 2, 3, 7, 8, 10, 15, 16, +20, 21뿐이다. 캐시 옵션 필드가 없다. + +Plus는 매 요청에 넣는다 (`internal/runtime/executor/devin_request.go:23,35,335`): + +```go +devinReqCacheOptionsField = 13 +devinCacheControlEphemeral = 1 + +// devinEncodeCacheOptions encodes PromptCacheOptions{type: EPHEMERAL}. +// +// The native client marks the system prompt as an ephemeral cache entry, which +// is what makes prompt caching effective across turns. +func devinEncodeCacheOptions() []byte { + return devinEncodeField(nil, 1, 0, devinEncodeVarint(nil, devinCacheControlEphemeral)) +} +``` + +네이티브 바이너리도 원격 프롬프트 캐시를 쓴다 — `prompt_cache_key_base`, +`disable_prompt_cache_writes`, `system_prefix_len`, `append_only_history` 필드와 +`affogato/src/cache_keepalive.rs`의 `[CACHE_KEEPALIVE] Ping sent`(TTL clamp 1–60s). +`~/.local/share/devin/cli/sessions.db`의 assistant 지표에 `cache_read_tokens`가 +42808, 59704처럼 실제로 찍혀 있다. 캐시는 동작하고, 값이 크다. + +즉 Plus는 캐시 옵션은 보내지만 세션을 매번 버려서(`devin_executor.go:626-637`) 캐시 +키가 흩어지고, 우리는 세션은 지키는데 캐시 옵션을 안 보낸다. 둘 다 반쪽이다. + +## 근거 2 — 캐시가 계정 사이로 샌다 + +`chat.ts:73`의 키는 `${host}\x1f${apiKey}`다. JWT 캐시(`auth.ts:191-214`)와 +카탈로그 캐시(`catalog.ts:54`)도 같은 `(host, apiKey)` 싱글톤이다. 그런데: + +- `clearSessionIds`(`chat.ts:94`)는 export되어 있지만 **호출하는 곳이 없다**. +- `src/server/management/oauth-account-routes.ts:349-374`의 logout/remove만 JWT와 + 카탈로그를 지우고, 계정 **전환**은 cloud-direct 캐시를 건드리지 않는다. +- `devin.ts:273-318`의 cascade-id Map은 스레드 키라 logout 후 새 계정에 이전 + cascade가 그대로 붙는다. + +네이티브 CLI는 이 문제를 이미 풀어 뒀다. `~/.cache/devin/cli/*.bin` 봉투가 +`identity_digest`를 갖고, 다른 identity로 쓰인 캐시는 거부한다: +`Ignoring cache file : written under a different identity`. + +## MODIFY: src/adapters/devin/cloud-direct/chat.ts — 캐시 옵션 필드 + +`buildChatRequest`의 `Buffer.concat` 배열에 필드 13을 추가한다. 필드 번호 순서상 +`encodeMessage(10, ...)` 토큰들과 `encodeMessage(15, ...)` 사이다. + +```ts +// after ...toolParts, + // #13 prompt_cache_options: { type: EPHEMERAL }. The native client marks the + // system prefix as an ephemeral cache entry; without it the server does not + // create a cache entry at all and every turn re-reads the full prefix. + // Verified against the native CLI (cache_read_tokens 42808/59704 in + // ~/.local/share/devin/cli/sessions.db) and CLIProxyAPIPlus + // devin_request.go:335 devinEncodeCacheOptions. + encodeMessage(13, encodeVarintField(1, PROMPT_CACHE_EPHEMERAL)), +``` + +`const PROMPT_CACHE_EPHEMERAL = 1;`를 파일 상단 상수와 함께 둔다. + +## MODIFY: chat.ts / auth.ts / catalog.ts — identity 키 + +세 캐시가 같은 identity 개념을 공유하게 한다. 자격증명 원문을 키로 쓰지 않는다. + +```ts +/** + * Cache identity for a Devin credential. The native CLI stores an + * identity_digest beside every cache envelope and refuses an entry written + * under a different identity; without that, switching accounts silently + * serves the previous account cached session, catalog and JWT. + * The digest never contains the credential itself. + */ +export function devinCacheIdentity(apiKey: string, host: string): string { + const digest = createHash("sha256").update(`${host}\x1f${apiKey}`).digest("hex"); + return digest.slice(0, 16); +} +``` + +`getOrAllocateSessionIds`, JWT 캐시, 카탈로그 캐시가 모두 이 값을 키로 쓴다. +해시로 바꾸는 것 자체가 부수 이득이다 — 지금은 Map 키에 API 키 원문이 들어 있고, +힙 덤프나 디버거에 그대로 노출된다. + +## MODIFY: 계정 전환 시 소거 (identity 스코프 전용) + +전역 `clearSessionIds`는 **삭제한다**. 남겨 두면 함정이다. +`oauth-account-routes.ts:281`의 per-provider logout이 그것을 부르는 순간 다른 계정의 +진행 중인 턴까지 session/cascade를 잃는다. 지금까지 호출자가 0건이었던 이유가 +그것이며, 안전하게 부를 수 있는 자리가 애초에 없다. + +대신 identity 스코프 무효화 하나만 남긴다. + +| 경로 | 호출 | +|---|---| +| per-provider logout / remove | `invalidateSessionIdentity(devinCacheIdentity(apiKey, host))` | +| 계정 전환 (activate/select) | 같음, 떠나는 identity에 대해 | +| 전체 종료 | 없음 — 프로세스가 사라지면 Map도 사라진다 | + +`devin.ts:273`의 cascade Map도 같은 identity 기준으로 해당 항목만 버린다. + +단위 테스트로 고정한다: + +- 계정 A로 한 턴 → 계정 B로 전환 → B의 요청이 A의 sessionId/cascadeId를 재사용하지 않는다. +- 계정 A의 턴이 **진행 중**일 때 B를 로그아웃해도 A의 턴은 자기 sessionId를 유지한다. +- 전역 소거 함수가 존재하지 않는다 (export 표면 회귀). + +## MODIFY: src/adapters/devin.ts — `invalid_argument`가 cooldown을 태우지 않게 + +Plus는 `invalid_argument`를 HTTP 400으로 재분류해 자격증명 cooldown을 건너뛴다 +(`devin_executor.go:959-983`, `devin_cooldown_test.go:9-15`). 우리 `devinErrorClassification` +(`devin.ts:53-64`)에는 그 분기가 없어서, 우리가 만든 잘못된 요청 하나가 멀쩡한 +자격증명을 식힌다. + +```ts +if (status === 400) return { status, errorType: "invalid_request_error", retryable: false }; +``` + +와 함께 Connect trailer `invalid_argument`를 400으로 매핑한다. + +## MODIFY: rune-safe tool 설명 절단 + +`chat.ts:586-587`은 JS `slice(0, 6998)`이다. UTF-16 코드 유닛 기준이라 한글이나 +이모지 중간에서 잘리고, 그 결과가 `invalid_argument: an internal error occurred`다. +Plus는 1024바이트 rune-safe 절단을 쓴다(`devin_tools.go:125-151`). + +바이트 예산으로 바꾸고 코드포인트 경계에서 자른다. 한국어로 도구를 설명하는 +사용자에게 직접 영향이 있다. + +## MODIFY: usage 회계에 캐시 읽기를 노출 + +field 7 `ModelUsageStats`가 과금 권위다(`chat.ts:927-959`). 네이티브가 +`cache_read_tokens`를 기록하므로 우리도 파싱해 usage에 싣는다. `cache_creation_tokens`는 +네이티브 실측에서 전부 null이라 기대하지 않는다. + +## NEW: tests/adapters/devin/cloud-direct-prompt-cache.test.ts + +| 케이스 | 기대 | +|---|---| +| 인코딩된 요청에 필드 13이 존재하고 값이 EPHEMERAL | 바이트 단언 | +| 같은 identity의 두 턴이 같은 sessionId/cascadeId | 재사용 회귀 | +| identity가 다르면 새 sessionId | 계정 누수 회귀 | +| logout 후 해당 identity만 소거 | `invalidateSessionIdentity` 회귀, 타 identity 생존 | +| 400/`invalid_argument`가 `retryable:false`, cooldown 없음 | 분류 회귀 | +| 7000바이트 한글 도구 설명이 유효한 UTF-8로 절단 | rune-safe 회귀 | + +## 하지 않는 것 + +- 추론 토큰이나 응답 본문을 로컬 디스크에 캐시하지 않는다 (레인 A 권고). +- 캐시 keepalive ping은 이번 범위에서 제외한다. 네이티브는 하지만 프록시가 사용자 + 턴 밖에서 업스트림을 두드리는 것은 별도 결정이 필요하다. 후속 단위로 남긴다. +- `sessions.db` 같은 로컬 SQLite 세션 저장소는 만들지 않는다. 프로세스 내 Map으로 + 충분하고, 디스크 상태는 계정 누수 표면을 넓힌다. + + +## 감사 반영 (A 단계, BLOCKER-3) + +`clearSessionIds()`를 그대로 부르면 안 된다. 구현이 `sessionCache.clear()`(`chat.ts:95`)라 +전역 소거다. 계정을 전환하는 순간 **다른 계정의 진행 중인 턴**까지 session/cascade를 +잃는다. 프록시는 멀티테넌트이므로 이건 새 버그를 만드는 수정이다. + +identity 스코프 삭제로 바꾼다. + +```ts +/** + * Drop cached IDs for ONE identity. A global clear() would strip the session + * and cascade of every other account mid-turn, which is why the old exported + * clearSessionIds() was never safe to call and consequently never called. + */ +export function invalidateSessionIdentity(identity: string): void { + sessionCache.delete(identity); +} +``` + +동시성은 epoch로 막는다. 캐시 엔트리에 `epoch`를 달고, 요청 시작 시 읽은 epoch와 +응답 조립 시점의 epoch가 다르면 그 턴은 캐시를 갱신하지 않는다. 진행 중인 턴은 +자기 sessionId로 끝까지 가고, 다음 턴부터 새 identity를 쓴다. + +```ts +interface SessionIds { sessionId: string; cascadeId: string; epoch: number; } +``` + +재감사(near-pass)가 남긴 잔여 지적을 반영해, 기존 `clearSessionIds`는 남기지 않고 +**제거한다**. "전체 로그아웃 전용"으로 문서화만 하는 안은 함정이 그대로 남는다 — +`oauth-account-routes.ts:281`의 per-provider logout이 그것을 부르면 타 계정의 진행 중인 +턴이 끊긴다. 안전한 호출 지점이 없는 함수는 export 표면에서 없애는 것이 맞다. +계정 전환과 로그아웃 모두 `invalidateSessionIdentity` 하나만 쓴다. + +테스트에 케이스를 하나 더 넣는다: 계정 A의 턴이 진행 중일 때 계정 B로 전환해도 +A의 턴은 자기 sessionId를 유지한다. + +## 감사에서 통과한 항목 + +가장 위험했던 와이어 포맷은 확인됐다. `encodeMessage(13, encodeVarintField(1, 1))`은 +Plus의 Go 인코더와 바이트가 같다 — `6a 02 08 01`. 필드 13, wire type 2(length-delimited), +길이 2, 내부 필드 1 varint 1. `PromptCacheOptions{type: EPHEMERAL}`에 정확히 맞는다. + +sha256 캐시 키 전환도 안전하다. 그 키를 파싱하거나 재구성하는 호출자나 테스트가 없다. +이 계획에 자격증명을 로그·파일명·오류 메시지에 넣는 단계도 없다. + diff --git a/devlog/_plan/260913_devin_landing_and_caching/050_wp5_effort_suffix_unification.md b/devlog/_plan/260913_devin_landing_and_caching/050_wp5_effort_suffix_unification.md new file mode 100644 index 0000000000..e7e03c6e2c --- /dev/null +++ b/devlog/_plan/260913_devin_landing_and_caching/050_wp5_effort_suffix_unification.md @@ -0,0 +1,79 @@ +# 050 — wp5: effort 접미사 집합이 두 벌로 갈라져 있다 + +A 단계 감사(BLOCKER-1)가 찾아낸, 로드맵이 놓쳤던 버그다. wp1과 같은 함수를 건드리지만 +별개 결함이라 별도 PR로 간다. + +## 증상 + +같은 저장소에 effort 접미사 목록이 두 벌 있고, 서로 다르다. + +`src/adapters/devin.ts:69` — 요청 경로: + +```ts +const EFFORT_SUFFIXES = new Set(["low", "medium", "high", "xhigh", "max", "none", "1m", "max-1m", "none-1m", "fast"]); +``` + +`src/adapters/devin/live-models.ts:75-77` — 카탈로그/피커 경로: + +```ts +const EFFORT_TOKENS = new Set([ + "low", "medium", "high", "xhigh", "max", "none", "fast", "priority", "1m", +]); +``` + +`priority`가 한쪽에만 있다. 레인 A가 네이티브 바이너리에서 확인한 실제 카탈로그 +접미사에는 `-priority`가 있고(`gpt-5-6-sol-medium-priority`), `collapseDevinModelUid`는 +이미 그것을 접미사로 취급해 벗겨낸다. + +## 결과 + +`hasEffortSuffix("gpt-5-6-sol-medium-priority")`는 마지막 토큰 `priority`가 +`EFFORT_SUFFIXES`에 없으므로 **false**다. 그러면 `resolveWireModelUid`가 이미 완전한 +UID에 또 접미사를 붙인다. 카탈로그가 없는 degraded 모드에서는 +`gpt-5-6-sol-medium-priority-medium`이 되고, Cognition은 이를 opaque한 +`permission_denied`로 거절한다 — `normalizeDevinModelId` 주석(`devin.ts:71-77`)이 +경고하는 바로 그 실패 모양이다. + +`max-1m` / `none-1m`은 `EFFORT_SUFFIXES`에만 있는데, 이들은 하이픈을 포함하므로 +마지막 토큰만 보는 `hasEffortSuffix`로는 애초에 매칭되지 않는다. 죽은 항목이다. + +## MODIFY: 집합을 하나로 + +`EFFORT_TOKENS`를 단일 출처로 삼고 `devin.ts`가 그것을 import한다. 두 벌을 유지하는 한 +다음 접미사가 추가될 때 같은 드리프트가 반복된다. + +```ts +// src/adapters/devin/live-models.ts +export const EFFORT_TOKENS = new Set([ + "low", "medium", "high", "xhigh", "max", "none", "fast", "priority", "1m", +]); + +// src/adapters/devin.ts +import { EFFORT_TOKENS, collapseDevinModelUid } from "./devin/live-models.js"; + +function hasEffortSuffix(modelId: string): boolean { + return collapseDevinModelUid(modelId) !== modelId; +} +``` + +`collapseDevinModelUid`로 위임하면 다중 접미사(`-medium-priority`)도 자동으로 맞는다. +마지막 토큰 하나만 보는 현재 구현의 한계가 사라진다. + +`resolveWireModelUid`의 effort 검증(`devin.ts:110,119`)도 `EFFORT_TOKENS`를 쓴다. +`max-1m`/`none-1m`은 죽은 항목이므로 제거하되, 커밋 본문에 왜 죽었는지 남긴다. + +## NEW 테스트 + +| 입력 | 기대 | +|---|---| +| `gpt-5-6-sol-medium-priority` | 그대로 (접미사 재부착 없음) | +| `gpt-5-6-sol-priority` | 그대로 | +| `gpt-5-6-sol` + effort `medium` | `gpt-5-6-sol-medium` (기존 동작 불변) | +| `swe-2-high` | wp1의 SWE-2 경로와 충돌 없음 | +| degraded 모드에서 `...-priority` | 이중 접미사 없음 (회귀) | + +## 순서 + +wp1이 먼저 착지한 뒤에 간다. 둘 다 `resolveWireModelUid`를 건드리므로 순차로 처리해 +충돌을 피한다. + diff --git a/tests/ci-workflows/repo-hygiene.test.ts b/tests/ci-workflows/repo-hygiene.test.ts index 6e1733384d..21c3df6423 100644 --- a/tests/ci-workflows/repo-hygiene.test.ts +++ b/tests/ci-workflows/repo-hygiene.test.ts @@ -133,6 +133,7 @@ describe("devlog is tracked, with no submodule left behind", () => { path.startsWith("devlog/_chase/_litellm/") || path.startsWith("devlog/_chase/_cca/") || path.startsWith("devlog/_chase/DSCodex/") + || path.startsWith("devlog/_chase/CLIProxyAPIPlus/") || path.startsWith("devlog/_fin/opencode-cursor/"), ); From a27036d8e6544e375f7abc84746143663c875b62 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 13 Sep 2026 11:44:48 +0900 Subject: [PATCH 2/2] docs(devlog): fold the AssignModel finding into the Devin roadmap A review of the plan asked why we alone skip the AssignModel RPC that the native CLI and CLIProxyAPIPlus both call, and whether the routing cost is hiding inside our header wait. Half of that holds. We do not call it: rg finds no AssignModel or assignment_jwt anywhere in src/ or tests/. But Plus guards the call behind devinIsRouterModel, which matches only a -router suffix or model-router, and its comment says thinking-effort suffixes are resolved server side. swe-2-high matches neither, so Plus goes straight to GetChatMessage exactly as we do. The missing handshake is therefore not the cause of the observed 504s, and the wp3 header-budget fix stands unchanged. The check did surface a separate confirmed gap: with no AssignModel we cannot resolve a router uid at all, and the catalog carries adaptive. That is now wp6, with the request and response field layout mapped against the Go encoder and a regression test asserting no extra roundtrip for plain effort-suffixed models. Two other points from the same review are recorded in 030. Reasoning frames already keep the post-header stream alive because resetIdle rearms on any read, so only the pre-header window is fatal. And Plus sets http.Client{Timeout: 120s}, which in Go bounds the whole request including body read and would cut a healthy three-minute stream; our split of header budget from body idle is the better shape and should not be traded for it. Planning only. Local product suite, typecheck, build and install: NOT RUN. --- .../000_plan.md | 29 +++++ .../030_wp3_ttfb_generation_deadline.md | 68 ++++++++++++ .../060_wp6_assign_model_router.md | 100 ++++++++++++++++++ 3 files changed, 197 insertions(+) create mode 100644 devlog/_plan/260913_devin_landing_and_caching/060_wp6_assign_model_router.md diff --git a/devlog/_plan/260913_devin_landing_and_caching/000_plan.md b/devlog/_plan/260913_devin_landing_and_caching/000_plan.md index 410b0720e0..e0af11ea20 100644 --- a/devlog/_plan/260913_devin_landing_and_caching/000_plan.md +++ b/devlog/_plan/260913_devin_landing_and_caching/000_plan.md @@ -123,3 +123,32 @@ wp0 ──┬── wp1 #4420 carry └── wp5 effort 접미사 통합 (wp1 다음, 같은 함수) ``` + +## P 단계 수정 — wp6 추가 (2026-09-13, wp1 사이클 진입 시) + +사용자가 `AssignModel` 누락을 지적했다. TTFB 원인으로는 기각됐지만(030 말미 참조 — +Plus도 `devinIsRouterModel` 가드 뒤에서만 부르고 `swe-2-high`는 걸리지 않는다), +라우터 uid를 아예 처리 못 한다는 별개 결손이 확인되어 wp6으로 세웠다. + +| wp | 문서 | 산출물 | +|---|---|---| +| wp6 | `060_wp6_assign_model_router.md` | 라우터 uid용 AssignModel 선행 호출 + 필드 26 | + +``` +wp0 ──┬── wp1 #4420 carry + │ └── wp5 effort 접미사 통합 + ├── wp2 #4384 carry + └── wp3 TTFB 헤더 예산 + └── wp4 프롬프트 캐시 / identity + └── wp6 AssignModel 라우터 +``` + +같은 검증에서 확정된 두 가지도 030에 기록했다: 헤더 이후 구간은 추론 프레임이 +`resetIdle()`을 재무장시켜 이미 안전하고, Plus의 `http.Client{Timeout: 120s}`는 +Go에서 전체 요청 예산이라 정상적인 3분 턴도 자른다 — 따라가지 않는다. + +| id | 기준 | 증거 | +|---|---|---| +| c-7 | effort 접미사 통합 merge | merge SHA + CI run id | +| c-8 | AssignModel 라우터 지원 merge | merge SHA + CI run id | + diff --git a/devlog/_plan/260913_devin_landing_and_caching/030_wp3_ttfb_generation_deadline.md b/devlog/_plan/260913_devin_landing_and_caching/030_wp3_ttfb_generation_deadline.md index 601ef606c7..62507f04c3 100644 --- a/devlog/_plan/260913_devin_landing_and_caching/030_wp3_ttfb_generation_deadline.md +++ b/devlog/_plan/260913_devin_landing_and_caching/030_wp3_ttfb_generation_deadline.md @@ -183,3 +183,71 @@ try { 본문 침묵은 그대로 120초 idle이 잡는다. 라이브 `ageMs=137197` 사례가 있으므로 120000은 헤더 예산으로 부족하다. + +## 사용자 지적 반영 — AssignModel 가설 검증 (2026-09-13, wp1 P 시점) + +사용자가 계획의 약한 곳을 짚었다: 네이티브 CLI와 Plus는 `AssignModel` RPC를 먼저 +부르는데 우리만 안 부른다. 라우팅 비용을 짧은 별도 호출로 치르지 않고 생성 요청에 +묻어버려서 헤더가 늦는 것 아니냐는 가설이다. + +절반은 사실이고, TTFB 원인으로는 **기각된다**. + +### 사실인 부분 + +우리 트리에 `AssignModel`이 없다. `rg -in "assignmodel|assignment_jwt" src/ tests/` 결과가 +0건이다. Plus는 `devin_request.go:376`에 +`devinAssignModelPath = "/exa.api_server_pb.ApiServerService/AssignModel"`를 두고 +`devin_executor.go:641,765`에서 부른 뒤 결과 `ModelUID`와 `AssignmentJWT`(필드 26)를 +`GetChatMessage`에 싣는다. + +### 기각되는 부분 + +Plus의 호출은 무조건이 아니라 가드 뒤에 있다 (`devin_executor.go:883-885`): + +```go +// devinIsRouterModel reports whether a model id routes through AssignModel. +// Thinking-effort suffixes are resolved server side. +func devinIsRouterModel(model string) bool { + return strings.HasSuffix(model, "-router") || strings.Contains(model, "model-router") +} +``` + +`swe-2-high`는 `-router`로 끝나지도, `model-router`를 포함하지도 않는다. 그러니 Plus도 +이 모델에서는 `AssignModel`을 부르지 않고 곧장 `GetChatMessage`로 간다 — 우리와 같다. +주석이 직접 못을 박는다: **thinking-effort 접미사는 서버가 푼다.** + +따라서 `AssignModel` 누락은 사용자가 실제로 맞은 `swe-2-high` 504의 원인이 아니다. +wp3의 헤더 예산 수정은 그대로 간다. + +### 그래도 남는 진짜 결손 → wp6 + +기각됐다고 가치가 없는 건 아니다. `AssignModel`이 없으면 **라우터 uid를 아예 못 쓴다.** +레인 A가 카탈로그에서 `adaptive`를 확인했고, 우리 `src/`에는 `adaptive`도 `router`도 +0건이다(`live-models.ts`, `devin.ts` 검색). 사용자가 라우터 모델을 고르면 우리는 그것을 +구체 uid로 바꾸지 못한 채 원시 문자열로 보낸다. + +이것은 TTFB와 무관한 별개 기능 결손이므로 **wp6**으로 세운다. 측정이 필요한 가설 +(핸드셰이크가 헤더 지연을 줄이는가)이 아니라, 확인된 기능 공백이다. + +### 함께 확정된 것 두 가지 + +**헤더 이후는 이미 안전하다.** 추론 프레임이 생존 신호로 동작한다. 파서가 추론을 +`kind: reasoning`으로 분리하고(`chat.ts:427,753`), `resetIdle()`이 `reader.read()`가 +무엇이든 돌려주면 재무장한다. 헤더만 도착하면 그 뒤 90초를 생각해도 죽지 않는다. +죽는 구간은 오직 헤더 이전이다. wp3가 그 한 구간만 건드리는 것이 맞다. + +**Plus의 타임아웃은 따라가면 안 된다.** `devin_executor.go:69,85`: + +```go +devinDefaultTimeout = 120 * time.Second +client: &http.Client{Timeout: devinDefaultTimeout}, +``` + +Go의 `http.Client.Timeout`은 헤더가 아니라 본문 읽기까지 포함한 **전체 요청** 예산이다. +3분짜리 정상 스트리밍 턴도 120초에 잘린다. 긴 턴에 대해서는 우리 구조(헤더 예산과 +본문 idle 분리)가 오히려 낫다. 고칠 곳은 헤더 구간 하나다. + +헤더 데드라인을 길게 잡는 것이 위험하지 않은 이유도 여기 있다. 업스트림이 죽으면 +TCP/HTTP2 레벨 오류가 즉시 올라와 `fetch`가 reject된다. 300초를 조용히 기다리는 +경우는 연결이 블랙홀이 된 때뿐이고, 그건 keepalive의 영역이다. + diff --git a/devlog/_plan/260913_devin_landing_and_caching/060_wp6_assign_model_router.md b/devlog/_plan/260913_devin_landing_and_caching/060_wp6_assign_model_router.md new file mode 100644 index 0000000000..a2e6ab368f --- /dev/null +++ b/devlog/_plan/260913_devin_landing_and_caching/060_wp6_assign_model_router.md @@ -0,0 +1,100 @@ +# 060 — wp6: 라우터 모델을 못 쓴다 (AssignModel 부재) + +사용자 지적에서 나온 단계다. 원래 가설은 "AssignModel을 안 불러서 헤더가 늦다"였고 +그건 기각됐지만(030 참조), 검증 과정에서 별개의 확정된 기능 결손이 드러났다. + +## 결손 + +우리 트리에 `AssignModel`이 없다. + +```text +$ rg -in "assignmodel|assignment_jwt|assignmentJwt" src/ tests/ +(0건) +$ rg -in "adaptive|router" src/adapters/devin/live-models.ts src/adapters/devin.ts +(0건) +``` + +레인 A가 네이티브 카탈로그에서 `adaptive`를 확인했다. 라우터 uid는 그 자체로 모델이 +아니라 "서버가 골라 달라"는 요청이며, `AssignModel`로 구체 uid를 받아 와야 한다. +우리는 그 문자열을 그대로 `GetChatMessage`의 필드 21에 실어 보내고, Cognition은 +모르는 모델로 취급한다. + +## 언제 부르는가 + +무조건이 아니다. Plus의 가드를 그대로 따른다 (`devin_executor.go:883-885`). + +```go +func devinIsRouterModel(model string) bool { + return strings.HasSuffix(model, "-router") || strings.Contains(model, "model-router") +} +``` + +주석이 명시한다: thinking-effort 접미사는 서버가 푼다. 그러니 `swe-2-high` 같은 평범한 +모델에 이 호출을 붙이면 **왕복만 하나 늘어난다.** 라우터 uid에서만 부른다. + +우리 판정에는 `adaptive`도 넣는다. 레인 A가 카탈로그에서 실제로 본 값이고, Plus의 +접미사 규칙만으로는 걸리지 않는다. + +## NEW: src/adapters/devin/cloud-direct/assign-model.ts + +와이어 포맷은 Plus의 인코더/파서와 1:1로 맞춘다. + +요청 `AssignModelRequest` — 경로 `/exa.api_server_pb.ApiServerService/AssignModel`: + +| 필드 | 내용 | 출처 | +|---|---|---| +| 1 | metadata (GetChatMessage와 동일 빌더) | `devinAssignMetadataField` | +| 2 | router uid (string) | `devinAssignRouterField` | +| 3 | cascade_id (string, 있을 때만) | `devinAssignCascadeField` | +| 5 | 마지막 turn의 prompt 하나만 | `devinAssignPromptField` | + +전체 히스토리가 아니라 **마지막 메시지 하나**만 보낸다는 점이 중요하다. 라우팅 결정에 +필요한 최소치이고, 이래야 이 호출이 짧게 끝난다. + +응답 `AssignModelResponse`: + +| 필드 | 내용 | +|---|---| +| 1 | assignment (sub-message) | +| 1.1 | assignment JWT (string) | +| 1.2 | 구체 model uid (string) | + +## MODIFY: src/adapters/devin/cloud-direct/chat.ts + +라우터 uid일 때만 선행 호출하고, 결과를 두 곳에 반영한다. + +```ts +if (isRouterModelUid(req.modelUid)) { + const assignment = await assignModel(req, sessionIds.cascadeId); + if (assignment?.modelUid) req = { ...req, modelUid: assignment.modelUid }; + if (assignment?.jwt) assignmentJwt = assignment.jwt; +} +``` + +인코더에 필드 26(`assignment_jwt`)을 추가한다. 있을 때만 쓴다. + +```ts +...(assignmentJwt ? [encodeString(26, assignmentJwt)] : []), +``` + +실패는 치명적이지 않다. Plus도 실패하면 요청받은 모델로 그냥 진행한다 +(`devin_executor.go:871` debug 로그 후 fallthrough). 같은 방식으로 degrade한다 — +라우팅을 못 받았다고 턴을 죽이지 않는다. + +## NEW: tests/adapters/devin/cloud-direct-assign-model.test.ts + +| 케이스 | 기대 | +|---|---| +| `swe-2-high` | `AssignModel` 호출 없음 (왕복 추가 금지 회귀) | +| `*-router` / `model-router` / `adaptive` | 호출 있음 | +| 응답의 uid가 필드 21에 반영 | 바이트 단언 | +| 응답의 JWT가 필드 26에 반영 | 바이트 단언 | +| JWT 없으면 필드 26 부재 | 바이트 단언 | +| `AssignModel`이 실패해도 원래 uid로 진행 | degrade 회귀 | +| 요청 필드 5에 마지막 turn 하나만 | 히스토리 유출 회귀 | + +## 순서 + +wp4 다음. 둘 다 `chat.ts` 인코더를 건드리고, wp4의 필드 13이 먼저 들어가는 편이 +필드 순서를 한 번만 정리한다. +