Skip to content

Commit ac715e0

Browse files
committed
絵を描かせている間の出力を、読みながらログへ流す
communicate() は相手が終わるまで 1 バイトも渡さないので、上限で打ち切って kill すると そこまでの出力ごと消えていた。実測で、20 分待って 504 になった回のログは「開始」と 「504」の 2 行だけで、描いていたのか・確認を待っていたのか・そもそも動いていなかったのかを 切り分けられなかった。 - 標準出力と標準エラーを読みながらログへ流し、同時に溜める(_pump)。止まっている 最中でも docker logs で追える - 打ち切るときも、そこまでの言い分を残す(504 の said とログ) - readline() は使わない。base64 を吐く相手がいるので、限度超えで落ちて取りこぼす。 塊で読んでこちらで行に割る - 標準入力へ渡すのは読み出しと同時に走らせる(_feed)。先に書き切ろうとすると、 相手が出力を吐き続けたときにパイプが詰まって双方進まなくなる - 起動のログにモデルを出す。指定しなければ CLI の既定(いちばん重いもの)で走るので、 遅かったときに何で走っていたのかが分からないと切り分けられない Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent eeb96fc commit ac715e0

4 files changed

Lines changed: 154 additions & 8 deletions

File tree

CLAUDE.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1460,6 +1460,14 @@ GeoNames 全世界地名辞典 = `geonames`(いずれも 348 言語版・195 か
14601460
(絵を描く口は最初から付けていて、そちらは通っていた)。
14611461
断って 413 を返していた頃は、pta の売買提案(候補ショートリスト込みで 120KiB 超)が
14621462
毎回失敗していた。**書き出したファイルは答えを返す前に必ず消す**(中身はプロンプトそのもの)
1463+
- **絵を描かせている間の出力は、読みながらログへ流す**(`_pump`)。
1464+
`communicate()` は相手が終わるまで 1 バイトも渡さないので、**上限で打ち切って
1465+
kill すると、そこまでの出力ごと消える** —— 実測で、20 分待って 504 になった回の
1466+
ログは「開始」と「504」の 2 行だけだった(描いていたのか、確認を待っていたのか、
1467+
そもそも動いていなかったのかを切り分けられない)。**打ち切るときも言い分を残す**
1468+
(504 の `said`)。**`readline()` は使わない** —— base64 を吐く相手がいるので
1469+
限度超えで落ちて取りこぼす。標準入力へ渡すのは読み出しと同時に走らせる
1470+
(先に書き切ろうとすると、相手が出力を吐き続けたときにパイプが詰まる)
14631471
- **CLI の出力は構造化して受け取る**(`_result_of`)。claude と antigravity は
14641472
`--output-format json`、codex は `--json`(JSONL。本文は今までどおり `-o`
14651473
ファイルから取り、使ったぶんだけ標準出力から読む)。素のテキストは最終的な答えしか

bridge/cli_bridge.py

Lines changed: 77 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2113,6 +2113,56 @@ async def images_generations(body: ImageRequest):
21132113
return await _generate_images(body)
21142114

21152115

2116+
# 進捗としてログへ流す 1 行の長さ。溢れた出力でログを埋めないための歯止め。
2117+
PROGRESS_LINE_MAX = 300
2118+
2119+
2120+
async def _pump(stream: asyncio.StreamReader, name: str, sink: list[bytes]) -> None:
2121+
"""CLI の出力を読みながらログへ流し、同時に溜める。
2122+
2123+
**`communicate()` では、固まったときに何も残らない。** あれは相手が終わるまで
2124+
1 バイトも渡してくれないので、上限で打ち切って kill すると、そこまでの出力ごと
2125+
消える —— 実測で、20 分待って 504 になった回のログは「開始」と「504」の 2 行
2126+
だけで、相手が何をしていたのか(描いていたのか、確認を待っていたのか、
2127+
そもそも動いていなかったのか)を後から一切たどれなかった。
2128+
2129+
読みながら流せば、**止まっている最中でも `docker logs` で追える**。
2130+
2131+
`readline()` は使わない。 長い行(base64 を吐く相手がいる)で
2132+
`ValueError: Separator is not found, and chunk exceed the limit` になり、
2133+
出力を取りこぼす。塊で読んで、こちらで行に割る。
2134+
"""
2135+
buf = b""
2136+
while chunk := await stream.read(8192):
2137+
sink.append(chunk)
2138+
buf += chunk
2139+
*lines, buf = buf.split(b"\n")
2140+
for raw in lines:
2141+
if text := raw.decode("utf-8", "replace").strip():
2142+
log.info("%s image %s| %s", CLI, name, _tail(text, PROGRESS_LINE_MAX))
2143+
# 改行を打たない相手で溜め込まない(溜めるのは sink の役目)
2144+
if len(buf) > PROGRESS_LINE_MAX * 4:
2145+
buf = buf[-PROGRESS_LINE_MAX:]
2146+
if text := buf.decode("utf-8", "replace").strip():
2147+
log.info("%s image %s| %s", CLI, name, _tail(text, PROGRESS_LINE_MAX))
2148+
2149+
2150+
async def _feed(proc: asyncio.subprocess.Process, payload: bytes) -> None:
2151+
"""標準入力へ渡して閉じる。**読まずに終える相手で落とさない**
2152+
(codex はプロンプトを標準入力から取るが、先に諦めると受け口が閉じている)。
2153+
2154+
読み出しと同時に走らせる。 先に書き切ろうとすると、相手が出力を吐き続けた
2155+
ときにパイプが詰まって、こちらも相手も進まなくなる。
2156+
"""
2157+
try:
2158+
if payload:
2159+
proc.stdin.write(payload)
2160+
await proc.stdin.drain()
2161+
proc.stdin.close()
2162+
except (BrokenPipeError, ConnectionResetError):
2163+
pass
2164+
2165+
21162166
async def _generate_images(body: ImageRequest) -> dict:
21172167
started = time.time()
21182168
seen = _existing(_shared_root())
@@ -2132,25 +2182,44 @@ async def _generate_images(body: ImageRequest) -> dict:
21322182
cmd = image_command(out_dir, body.model or MODEL)
21332183
payload = prompt.encode("utf-8")
21342184

2135-
log.info("running %s image tool (prompt %d bytes)", CLI, len(prompt.encode("utf-8")))
2185+
# **モデルもログに出す。** 指定しなければ CLI の既定(=一番重いもの)で走るので、
2186+
# 遅かったときに「何で走っていたのか」が分からないと切り分けられない。
2187+
log.info("running %s image tool (prompt %d bytes, model=%s)", CLI,
2188+
len(prompt.encode("utf-8")), body.model or MODEL or "(CLI の既定)")
21362189
proc = await asyncio.create_subprocess_exec(
21372190
*cmd,
21382191
cwd=out_dir,
21392192
stdin=asyncio.subprocess.PIPE,
21402193
stdout=asyncio.subprocess.PIPE,
21412194
stderr=asyncio.subprocess.PIPE,
21422195
)
2143-
try:
2144-
stdout, stderr = await asyncio.wait_for(
2145-
proc.communicate(payload),
2146-
timeout=IMAGE_TIMEOUT,
2196+
out_chunks: list[bytes] = []
2197+
err_chunks: list[bytes] = []
2198+
2199+
async def _run() -> None:
2200+
await asyncio.gather(
2201+
_feed(proc, payload),
2202+
_pump(proc.stdout, "out", out_chunks),
2203+
_pump(proc.stderr, "err", err_chunks),
21472204
)
2205+
await proc.wait()
2206+
2207+
try:
2208+
await asyncio.wait_for(_run(), timeout=IMAGE_TIMEOUT)
21482209
except TimeoutError:
21492210
proc.kill()
21502211
await proc.wait()
2151-
raise HTTPException(
2152-
504, {"error": f"{CLI}{IMAGE_TIMEOUT:.0f}s で終わりませんでした"}
2153-
) from None
2212+
# **打ち切るときこそ、言い分を残す。** ここを捨てていたせいで、
2213+
# 固まる相手の原因究明が一歩も進まなかった
2214+
said = failure_detail(b"".join(out_chunks), b"".join(err_chunks))
2215+
log.error("%s image tool timed out after %.0fs: %s",
2216+
CLI, IMAGE_TIMEOUT, said or "(何も言わなかった)")
2217+
raise HTTPException(504, {
2218+
"error": f"{CLI}{IMAGE_TIMEOUT:.0f}s で終わりませんでした",
2219+
"said": said or "(何も言わなかった)",
2220+
}) from None
2221+
2222+
stdout, stderr = b"".join(out_chunks), b"".join(err_chunks)
21542223

21552224
if proc.returncode != 0:
21562225
detail = failure_detail(stdout, stderr)

docs/ai.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,13 @@ platform.openai.com 側(従量課金)でクレジットが要ります ——
245245
覚えて除き、かつブリッジ側で直列化しています。1 枚 1 分以上かかる相手なので、
246246
並べても速くはなりません。
247247

248+
**戻ってこないときは、ブリッジのログに途中経過が出ます。** CLI の標準出力と標準エラーを
249+
読みながら流しているので、`docker logs chiezo-bridge-<CLI 名>`
250+
`<CLI 名> image out| …` の行が並びます —— 描いているのか、確認を待っているのか、
251+
そもそも動いていないのかが、止まっている最中に分かります。
252+
**打ち切ったときも、そこまでに言っていたことを捨てません**(504 の `said` に入ります)。
253+
上限は `CHIEZO_BRIDGE_IMAGE_TIMEOUT`(既定 1200 秒)。
254+
248255
gpt-image が **403** を返したら、OpenAI の開発者コンソールで**組織の本人確認**を求められて
249256
いることがあります —— API の話なので、ChatGPT や Codex のサブスクで使えているかは関係しません。
250257

tests/test_bridge.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -840,6 +840,68 @@ def test_text_files_are_not_mistaken_for_images(self, bridge, tmp_path):
840840
assert server._collect_images(str(tmp_path), _t.time()) == []
841841

842842

843+
class TestImageProgressIsVisible:
844+
"""**固まったときこそ、相手の言い分を残す。**
845+
846+
以前は `communicate()` で終わるまで溜め、上限で kill していたので、
847+
打ち切った回の出力がまるごと消えていた —— 20 分待って 504 になった回の
848+
ログは「開始」と「504」の 2 行だけで、描いていたのか・確認を待っていたのか・
849+
そもそも動いていなかったのかを後から切り分けられなかった。
850+
"""
851+
852+
def test_the_output_is_kept_while_it_runs(self, bridge):
853+
import asyncio
854+
855+
server = bridge(CHIEZO_BRIDGE_CLI="codex")
856+
857+
async def go() -> list[bytes]:
858+
# StreamReader は動いているループの中でしか作れない
859+
reader = asyncio.StreamReader()
860+
reader.feed_data("考えています\n描いています\n".encode())
861+
reader.feed_eof()
862+
sink: list[bytes] = []
863+
await server._pump(reader, "out", sink)
864+
return sink
865+
866+
assert "描いています" in b"".join(asyncio.run(go())).decode()
867+
868+
def test_a_long_line_does_not_lose_the_output(self, bridge):
869+
"""base64 を吐く相手がいる。`readline()` だと限度超えで落ちて取りこぼす。"""
870+
import asyncio
871+
872+
server = bridge(CHIEZO_BRIDGE_CLI="codex")
873+
874+
async def go() -> list[bytes]:
875+
reader = asyncio.StreamReader()
876+
reader.feed_data(b"A" * 200_000 + "\n終わりました\n".encode())
877+
reader.feed_eof()
878+
sink: list[bytes] = []
879+
await server._pump(reader, "out", sink)
880+
return sink
881+
882+
got = b"".join(asyncio.run(go()))
883+
assert len(got) > 200_000
884+
assert "終わりました" in got.decode()
885+
886+
def test_a_timeout_carries_what_the_cli_said(self, bridge, monkeypatch):
887+
import asyncio
888+
889+
import fastapi
890+
891+
server = bridge(CHIEZO_BRIDGE_CLI="codex")
892+
monkeypatch.setattr(server, "IMAGE_TIMEOUT", 0.5)
893+
monkeypatch.setattr(
894+
server, "image_command",
895+
lambda out_dir, model="": ["sh", "-c", "echo 権限の確認を待っています; sleep 2"],
896+
)
897+
898+
with pytest.raises(fastapi.HTTPException) as got:
899+
asyncio.run(server._generate_images(server.ImageRequest(prompt="剣")))
900+
901+
assert got.value.status_code == 504
902+
assert "権限の確認を待っています" in got.value.detail["said"]
903+
904+
843905
class TestCredentialFromAnotherApp:
844906
"""認証情報の置き場を共有すれば、Chiezo 以外のアプリからも使える。
845907

0 commit comments

Comments
 (0)