diff --git a/cloud/docker/Dockerfile b/cloud/docker/Dockerfile index 87df651..74bd3f2 100644 --- a/cloud/docker/Dockerfile +++ b/cloud/docker/Dockerfile @@ -24,9 +24,21 @@ # workspace-write 的控制面约束,见 cloud/README.md。 ARG PYTHON_VERSION=3.12-slim FROM python:${PYTHON_VERSION} +# 文档 / 图片工具(document-tools、image-workflow 技能的核查项): +# - pandoc:通用文档转换;poppler-utils:pdftotext(PDF 读回)。 +# - latexmk + texlive-latex-base:pdflatex 编译(英文 LaTeX; +# 中文 LaTeX 仍需 xelatex + CJK 字体,见下,本镜像故意不装: +# texlive-xetex + fonts-noto-cjk 会再增数百 MB,主机盘已 89%, +# 中文 LaTeX 缺 xelatex 时技能按规则明说,不伪造)。 +# - pip:python-docx / openpyxl / python-pptx / PyMuPDF(fitz)/ +# Pillow——技能核查的 Python 库与图片处理(Pillow 有其一即可 +# 做裁剪/缩放/转格式)。图片“生成”仍未配置:本镜像不装、 +# 不接任何生成端点,生成需求技能仍按规则明说无法生成。 +# - --no-cache-dir:pip 不留缓存层,镜像不虚胖。 RUN apt-get update \ - && apt-get install -y --no-install-recommends git \ - && rm -rf /var/lib/apt/lists/* + && apt-get install -y --no-install-recommends git pandoc poppler-utils latexmk texlive-latex-base \ + && rm -rf /var/lib/apt/lists/* \ + && pip install --no-cache-dir python-docx openpyxl python-pptx PyMuPDF Pillow ARG CODEX_BIN_DIR=bin COPY ${CODEX_BIN_DIR}/codex /usr/local/bin/codex COPY ${CODEX_BIN_DIR}/codex-code-mode-host /usr/local/bin/codex-code-mode-host diff --git a/cloud/sandbox.py b/cloud/sandbox.py index 1233ce0..59e8ec8 100644 --- a/cloud/sandbox.py +++ b/cloud/sandbox.py @@ -30,6 +30,15 @@ from cloud.marker_tools import extract_wait_threads as _extract_wait_threads from cloud.peer_messages import extract_peer_messages as _extract_peer_messages MAX_STDOUT_CHARS = 2000 +# 任务结果文本上限:2000 字截断的是 host.py 打印的整段 JSON, +# text 超过 2000 字时 JSON 本身被截断,worker 再解析 stdout +# 取 text 必然失败,存库的就变成截断的 JSON(2026-10-30 +# 生产审计任务 dbe0c39f… 正是这样在 "PI" 处断掉)。修法: +# 解析出的 text(或非 JSON 的原文本)单独截到 20000 字, +# 超限时带截断标记,绝不把截断的 JSON 当结果存库。 +# 状态 / usage / 标记块仍从完整 stdout 解析,不受此截断影响。 +MAX_RESULT_CHARS = 20000 +_RESULT_TRUNCATED_MARKER = "\n\n[结果已截断:原文超 20000 字,仅显示前 20000 字]" # 启动前分段日志(2026-10-29 任务 A):docker run 是阻塞调用, # 容器输出要等返回才回传,中途卡死时 worker 侧没有任何标记; @@ -223,6 +232,21 @@ def _truncate_stdout(text): return str(text)[:MAX_STDOUT_CHARS] +def _result_text(parsed, stdout_full): + """执行结果存库用的文本:优先取 host.py JSON 的 text 字段, + 取不到时用原文本;截到 MAX_RESULT_CHARS,超限带截断标记。 + 调用方必须在解析出 parsed 后再调本函数,绝不能先截断 + stdout 再解析 JSON(截断的 JSON 解析失败,text 全丢)。 + """ + if isinstance(parsed, dict) and parsed.get("text"): + text = str(parsed["text"]) + else: + text = str(stdout_full or "") + if len(text) > MAX_RESULT_CHARS: + return text[:MAX_RESULT_CHARS] + _RESULT_TRUNCATED_MARKER + return text + + def _default_runner(cmd, env=None): """subprocess.run 的薄封装:捕获输出、文本模式。 @@ -353,7 +377,6 @@ class LocalExecutor(Executor): """ returncode = getattr(result, "returncode", 1) stdout_full = getattr(result, "stdout", "") or "" - stdout = _truncate_stdout(stdout_full) status = "failed" usage = None task_messages = [] @@ -364,12 +387,14 @@ class LocalExecutor(Executor): task_retries = [] turn_id = None root_turn_id = None + parsed = None if returncode == 0: status = "completed" # JSON 解析必须用完整 stdout:截头后的前 2000 字在 # text 很长时根本不是合法 JSON,旧代码会把 host 的 # failed 误判成 completed;usage 在 JSON 末尾,截头 - # 后同样丢。存库的 stdout 仍用截头版,不变。 + # 后同样丢。存库的 stdout 是解析出的 text(见 + # _result_text),不再是截断的 JSON。 try: parsed = json.loads(stdout_full) except (TypeError, ValueError): @@ -404,6 +429,7 @@ class LocalExecutor(Executor): host_status = None else: host_status = None + stdout = _result_text(parsed, stdout_full) return { "executor": "local", "task_id": task_id, @@ -796,7 +822,12 @@ class LocalExecutor(Executor): raise ValueError("任务缺少 id") result = self.runner(self._command(task_id)) returncode = getattr(result, "returncode", 1) - stdout = _truncate_stdout(getattr(result, "stdout", "")) + stdout_full = getattr(result, "stdout", "") or "" + try: + parsed = json.loads(stdout_full) + except (TypeError, ValueError): + parsed = None + stdout = _result_text(parsed, stdout_full) return { "executor": "local", "task_id": task_id, @@ -1758,7 +1789,6 @@ def _parse_host_result(task_id, fake): """ returncode = getattr(fake, "returncode", 1) stdout_full = getattr(fake, "stdout", "") or "" - stdout = _truncate_stdout(stdout_full) status = "failed" host_status = None usage = None @@ -1770,12 +1800,14 @@ def _parse_host_result(task_id, fake): task_retries = [] turn_id = None root_turn_id = None + parsed = None if returncode == 0: status = "completed" # 与 _result_from_runner 同理:JSON 解析用完整 stdout, # 否则长 text 会把 host 的 failed 误判成 completed, # 也会丢掉 JSON 末尾的 usage。标记块同样从完整解析 - # 出的 text 里提取。 + # 出的 text 里提取。存库的 stdout 是解析出的 text, + # 不再是截断的 JSON(见 _result_text)。 try: parsed = json.loads(stdout_full) except (TypeError, ValueError): @@ -1797,6 +1829,7 @@ def _parse_host_result(task_id, fake): # 时保持 None,worker 写回时不覆盖旧值。 turn_id = parsed.get("turn_id") root_turn_id = parsed.get("root_turn_id") + stdout = _result_text(parsed, stdout_full) return { "executor": "docker", "task_id": task_id, diff --git a/cloud/tests/test_queue_sandbox.py b/cloud/tests/test_queue_sandbox.py index cdddcfa..5872122 100644 --- a/cloud/tests/test_queue_sandbox.py +++ b/cloud/tests/test_queue_sandbox.py @@ -20,7 +20,8 @@ sys.path.insert(0, os.path.dirname(os.path.dirname( from queue import TaskQueue, budget_exceeded, order_tasks, ready_tasks from sandbox import (CloudExecutor, DockerExecutor, Executor, - LocalExecutor, _parse_goal_output) + LocalExecutor, _parse_goal_output, + _parse_host_result, _result_text) def _task(task_id, depends_on=None, priority=0, status="queued"): @@ -177,12 +178,38 @@ class TestExecutors(unittest.TestCase): self.assertEqual(result["stdout"], "boom") def test_local_executor_truncates_stdout(self): + # 20000 字上限:5000 字不再截断,25000 字截断并带标记。 def fake_runner(cmd): return SimpleNamespace(returncode=0, stdout="x" * 5000) result = LocalExecutor("/fake/host.py", runner=fake_runner).execute({"id": "t3"}) - self.assertEqual(len(result["stdout"]), 2000) + self.assertEqual(len(result["stdout"]), 5000) + + def long_runner(cmd): + return SimpleNamespace(returncode=0, stdout="y" * 25000) + + result = LocalExecutor("/fake/host.py", + runner=long_runner).execute({"id": "t4"}) + self.assertIn("结果已截断", result["stdout"]) + self.assertTrue(result["stdout"].startswith("y" * 20000)) + + def test_result_text_extracts_long_json_text(self): + # 2026-10-30 生产审计任务的复发:host.py JSON 的 text + # 超过旧 2000 字截断时,存库的不能是截断的 JSON, + # 必须是完整提取出的 text(本例 5000 字,全部保留)。 + text = "z" * 5000 + payload = json.dumps({"status": "completed", "text": text}) + result = _parse_host_result( + "t5", SimpleNamespace(returncode=0, stdout=payload, + stderr="")) + self.assertEqual(result["stdout"], text) + self.assertEqual(result["status"], "completed") + + def test_result_text_marks_over_limit(self): + text = "w" * 21000 + self.assertIn("结果已截断", _result_text( + {"text": text}, "unused")) def test_docker_executor_no_docker_raises_not_local(self): with mock.patch("sandbox.shutil.which", return_value=None): diff --git a/cloud/worker.py b/cloud/worker.py index a995183..d99958e 100644 --- a/cloud/worker.py +++ b/cloud/worker.py @@ -714,7 +714,8 @@ class Worker: """pick_next 的输入:只取 queued/completed/merged 三类任务。 旧实现是 SELECT * 全表:prompt/result_text/error_text - (后两者各可达 4000 字)全量进内存,且每轮 poll 都来一次; + (result_text 可达 21000 字、error_text 可达 4000 字) + 全量进内存,且每轮 poll 都来一次; 任务表随时间变大后这是纯浪费。pick_next 只需要 queued 任务和已完成 id(依赖判断),running/failed/interrupted 的任务对它既不是就绪、也不是已完成,排除它们不影响结果。 @@ -1467,7 +1468,7 @@ class Worker: " turn_id = COALESCE(?, turn_id)," " root_turn_id = COALESCE(?, root_turn_id)," " updated_at = ? WHERE id = ?" + status_clause, - ((result_text or "")[:4000] if result_text else None, + ((result_text or "")[:21000] if result_text else None, (error_text or "")[:4000] if error_text else None, actual_model, started_at, finished_at, turn_id, root_turn_id, @@ -1481,7 +1482,7 @@ class Worker: " turn_id = COALESCE(?, turn_id)," " root_turn_id = COALESCE(?, root_turn_id)," " updated_at = ? WHERE id = ?" + status_clause, - ((result_text or "")[:4000] if result_text else None, + ((result_text or "")[:21000] if result_text else None, (error_text or "")[:4000] if error_text else None, actual_model, started_at, finished_at, child_pid, turn_id, root_turn_id, @@ -3893,7 +3894,8 @@ class Worker: 超时用统一的超时文本;runner 异常用异常文本;同步路径的 stdout 是 host.py 的 JSON 时取它的 text 字段(比整段 JSON - 可读);最后退到 host_status token 或通用文本。绝不返回 + 可读),执行器已提取出 text 时直接用该文本;最后退到 + host_status token 或通用文本。绝不返回 任何密钥(stdout 里本就不应含密钥,text 字段同样不含)。 """ if result is not None and result.get("status") == "timeout": @@ -3922,6 +3924,12 @@ class Worker: stderr = (result or {}).get("stderr", "") or "" if not stdout.strip() and stderr.strip(): return stderr.strip()[-1000:] + # 执行器现在返回的是解析出的 text(不再是整段 JSON): + # JSON 解析失败且 stdout 非空时,stdout 本身就是报错 + # 文本,绝不能丢掉它退到 host_status=404 这种代号 + # (2026-10-30 全量因此丢了 "primary 404 boom")。 + if stdout.strip(): + return stdout.strip()[:1000] if token is not None: return "host_status=%s" % token return stdout.strip() or "host execution failed"