Cursor Composer 2.5 を Codens の executor lane に追加した話 (Opus の 1/10 コスト +
Cursor が Composer 2.5 を出した時に最初に目を留めたのは benchmark の絶対値ではなく、 per-attempt cost の桁が 1 つ違うという事実でした。SWE-Bench Multilingual で Anthropic Opus と同等水準を主張していて、 しかも 1 回の生成にかかる cost が概算で 1/10。Codens の fix_verify loop は task 1 つに対して何度も retry を打つ構造なので、 attempt 単価が桁で違うと retry cap × per-attempt cost で積分した時の総コストが 1 桁効いてきます。
benchmark 数字だけで動くのは早計、 と思いつつ「とりあえず実装と canary は走らせて production reliability は自分の目で見るしかない」 で着手した話。 結果から書くと、 runner_cursor.py という第 3 lane を Codens Purple に追加して社内 1 project だけ flip して shadow run 中。 v4-v17 で 16 failed / 9 done で production ready には程遠いですが、 bridge 周りの failure mode と operator UX は粗方つぶしたので書き残します。
Composer 2.5 を入れた経済性
Codens は元々 Anthropic API (per-token billing で raw API を直接叩く構成、 subscription ではない) と self-hosted Qwen の 2 lane で動いていました。各 task は PurpleTask.execute_model で model を持ち、 project 単位の default は PurpleProject.default_model で決まる。
動機は commit の short message にそのまま「Composer 2.5 is ~10x cheaper than Opus at comparable SWE-Bench scores」。 publish 値を全部真に受けるかは別ですが、 同水準を主張するケースを cost 1/10 で取れるなら無視できない。
retry cap × per-attempt cost の話は別記事 (CDTSK-1362) で書いた通り Codens は model ごとに cap を変えていて Claude 3 attempts / Qwen 6 attempts が現状。Composer 2.5 を Opus と同じ 3 attempts で運用しても per-attempt cost が 1/10 なら per-task 総コストが 1/10 に落ちる算数。 ただ SWE-Bench は public dataset で vendor は当然そこを最適化するし、 customer task は SWE-Bench と違う code base 分布なので、「Opus と同等」 は仮説扱いで production reliability は別で測る、 というスタンスで設計を始めました。
executor lane 追加 = 何をする作業か
Codens Purple は execute_model で runner を分岐させる構造を既に持っていたので、 新 lane 追加は厳密には runner_cursor.py を作って enum を 1 個増やすだけ。 既存 lane に変更を入れると production の 95% trafic が流れている既存 path に regression を入れるリスクが出るので触らない。
class ExecuteModel(str, Enum):
CLAUDE_OPUS_4 = "claude-opus-4"
QWEN_3_CODER = "qwen-3-coder"
COMPOSER_2_5 = "composer-2.5" # New lane
新 lane は opt-in only、 切替は SQL 1 行、 canary は社内 1 project 限定。 実装は 2 phase に分けました。 最初は 1 phase で full SDK wire まで一気にやろうとしてたんですが production deploy の rollback コストを考えて分けた。Phase 1 で skeleton と DB migration、 Phase 2 で実際の Cursor SDK を call。 こうすると Phase 1 rollback は alembic downgrade 1 個、 Phase 2 rollback は runner_cursor 本体を no-op に戻すだけ。 1 PR 1 機能の粒度より 1 PR 1 rollback unit の粒度で考える方が後で楽になる、 というのが学び。
Phase 1 (commit 5a575031) でやったのは runner_cursor.py を validation only で作る、 enum / DB CHECK constraint に composer-2.5 を追加、 dispatcher に case を 1 個追加、 の 3 点。 指定する task が存在しないので deploy しても production には影響なし、 万一 routing logic にバグを入れても既存 task は全部 Claude / Qwen に行くので blast radius ゼロ。
Phase 2: Cursor Python SDK の wire
Phase 2 (commit b1e7ebcd) で runner_cursor の中身を本物に置き換えました。Cursor の Python SDK は Bridge → Client → Agent → run.events() の event-stream 構造:
# infrastructure/runners/runner_cursor.py
async def _run_cursor_session(prompt, workspace_dir, model_id):
bridge = await Bridge.launch(api_key=settings.CURSOR_API_KEY)
client = Client(bridge=bridge)
agent = await client.agent.create(
model=ModelSelection(id=model_id),
local=LocalAgentOptions(cwd=workspace_dir),
)
run = await agent.send(prompt, SendOptions(max_executions=5))
async for event in run.events():
yield event
Bridge.launch で sidecar の Cursor process が立ち上がって、 そこを window にして agent と event stream で会話する。max_executions は agent 内部の tool call 上限で、 Codens Purple の retry cap (fix_verify で agent を何回 spawn するか) とは別 layer の cap。 これを超えると failure_reason="exceeded max executions" で run が終わり、 fix_verify 側で次 attempt が再 spawn される。
CURSOR_API_KEY は AWS Secrets Manager に置いて、 ECS Fargate task definition に secret ARN を env で渡し、 entrypoint script で resolve します:
# scripts/purple-job-entrypoint.sh
set -euo pipefail
unset AWS_PROFILE # Use task IAM role, not customer's profile
if [ -n "${CURSOR_API_KEY_SECRET_ARN:-}" ]; then
export CURSOR_API_KEY=$(
aws secretsmanager get-secret-value \
--secret-id "$CURSOR_API_KEY_SECRET_ARN" \
--query SecretString --output text
)
fi
exec python -m src.workers.purple_worker
最初の実装で失敗したのが、 aws secretsmanager get-secret-value が customer の AWS_PROFILE から credential を取ろうとしていた点。Codens Purple は task ごとに customer の AWS credential を持つ場面 (customer の repo に deploy する系) があるので環境に AWS_PROFILE が刺さっていることがある。 これだと「customer の IAM principal でうちの Secrets Manager を叩く」 になって 403。 修正 (commit 6210a052) では明示的に unset AWS_PROFILE して、 ECS Fargate の task IAM role が container metadata endpoint 経由で SDK に渡る挙動に任せた。 multi-tenant SaaS の credential isolation の典型例でした。
Canary: 1 project だけ flip
production rollout は Corevice org の dogfood project 1 つだけ flip する canary から始めました。 runbook は UPDATE purple_projects SET default_model = 'composer-2.5' WHERE slug = 'dogfood-composer-canary'; の 1 行。 他 org の customer project は従来通り Opus / Qwen に流れる。 即時 rollback も同じ形で 1 行戻すだけ。
観察指標は完走率、 1 attempt の verify pass 率、 wall time (Cursor SDK は bridge sidecar の overhead があるので Claude の HTTP API より遅いと cost で勝っても運用で負ける)、 actual の per-task cost。 判定基準は事前に切っておきました。「2 週間で完走率が Opus baseline の 70% を切ったら hold」 「per-task cost が Opus baseline の 30% 以下なら完走率が下がっていても他 project 拡大検討」。 事前に閾値を切らずに見始めると良い数字に引っ張られがちなので、 出発点で線を引いておく方が後で楽。
smoke で踏んだ failure と operator UX
実運用すると当然 benchmark 数字とは違う現実が見えます。v4-v17 で 16 failed / 9 done で半分以上 fail、 production ready には程遠い。
主な failure mode は 2 つで、 bridge drop (Bridge sidecar への connection が切れて run.events() が中途半端な状態で止まる。 workspace に途中までの diff が残っているのに status は failed で commit されず捨てられる) と max_executions 到達 (agent が tool を呼びまくって max_executions=5 で failure_reason="exceeded max executions (5)")。
commit 0f95f020 で 2 つ直しました。 bridge drop 検出時には workspace の git diff を salvage して PR draft として保存、 完全に失敗させずに「途中まで進んだ差分は救う」 挙動にして、 operator が後で有用と判断したら手で commit 昇格できるようにする。 max_executions は failure_reason の表示が問題で、 当初 Notion ticket と Slack 通知には "exceeded max executions (5)" だけが出ていて operator が「で、 何が原因で 5 回も tool を呼んだの?」 を追えなかった。 同じ commit で last tool call の error message を append し、「exceeded max executions (5) - last error: pytest exit 1 in tests/test_user.py::test_create」 形式に変えた。
あと 1be0614f で entrypoint script が secret resolve 失敗時に silent に die していた問題を直しました。set -e が効いておらず subshell が死んでも parent が気付かず CURSOR_API_KEY が unset のまま worker 起動、bridge.launch() が「API key 不正」 で死ぬが root cause は IAM permission 不足、 という辿りにくい連鎖。set -euo pipefail を入れて secret resolve 段階で即死する形に。
これらは全部「reliability の根本治療」 ではなく「failure mode を発見しやすくする operator UX」 の改善。 benchmark 数字が良くても production で落ちた時に operator が原因にたどり着けないと結局その lane は使い物にならない。 観察可能性が canary の最初の 1 週間で一番時間を取られた作業でした。
multi-model executor lane を持つ意味
Composer 2.5 を 3 つ目の lane として追加できたのは、 Codens が元から task ごとに model を切り替えられる構造を持っていたからで、 これがなければ作り直しに近い変更になっていました。 provider が 1 つだとその provider の pricing 改定や API 仕様変更や ratelimit policy 変更がそのまま product のリスクになる。 multi-model にしておくとそのリスクを分散できる。 benchmark の数字とは別の「optionality」 という効能で、 SaaS を運用していると 1 年に 1 回くらいその恩恵を実感する。Composer 2.5 lane はまだ smoke phase で reliability 数字は出揃っていませんが、「lane を 1 本足す」 という機械的な作業として実装できたこと自体が、 これまでの multi-model 投資の return だったと思っています。
Codens は https://www.codens.ai/ で公開しています。
Discussion