Files
FreeCADWasmDev/tools/run_freecad_web_scheduler.py
wangdequan 9a7c4f0335 Save FreeCAD Web workspace sources and user validation evidence
IFC native binding remains incomplete; preserve accurate test results.
2026-09-15 22:01:58 +08:00

3503 lines
217 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Run Codex in durable, non-interactive continuation cycles.
The scheduler is the process that can keep starting new Codex turns after one
turn exits. It never marks a task complete itself: the child Codex run must
implement and persist the result. The ledger/checkpoint remain the source of
truth, and a single process lock prevents concurrent schedulers from touching
the same workspace.
"""
from __future__ import annotations
import argparse
from collections import deque
from contextlib import ExitStack
import fcntl
import hashlib
import json
import os
import re
import shlex
import signal
import subprocess
import sys
import threading
import time
import shutil
from datetime import datetime, timezone
from pathlib import Path
from typing import Callable
from implementation_first import implementation_first_enabled, user_validation_deferred, implementation_scope_complete
import scheduler_maintenance as maintenance
from codex_http_transport import configured_transport
from scheduler_context import compact_packet, source_snapshot, write_checkpoint, tracked_edit_paths, SourceChangeTracker, implementation_blockers, REPAIR_LAYER_POLICY, SOURCE_WINDOW_POLICY
from failure_reconciliation import synchronize as reconcile_failures, record_success, command_tokens
from scheduler_progress import PROGRESS_PATH, read as read_progress, snapshot as progress_snapshot, record_outcome, batch_retry_details, recovery_requested, restore_module_recovery
from scheduler_progress import record_provider_attempt
ROOT = Path(__file__).resolve().parents[1]
STATE = ROOT / "docs/current/task-state.json"
CHECKPOINT = ROOT / "docs/current/continuation-state.json"
LOCK_PATH = ROOT / "docs/current/freecad-web-scheduler.lock"
PID_PATH = ROOT / "docs/current/freecad-web-scheduler.pid"
LOG_DIR = ROOT / "docs/current/continuation-runs"
HEARTBEAT_PATH = ROOT / "docs/current/freecad-web-scheduler-status.json"
TIMING_SUMMARY_PATH = ROOT / "docs/current/freecad-web-scheduler-timing.json"
SCHEDULER_IMPLEMENTATION = "implementation-only-document-delivery-first-v29"
MAX_COMMANDS_PER_TURN = 20
MAX_SOURCE_READS_PER_TURN = 8
FEATURE_SOURCE_READS_PER_TURN = max(2, min(MAX_SOURCE_READS_PER_TURN, int(os.environ.get("FREECAD_FEATURE_SOURCE_READS", "4"))))
FEATURE_SOURCE_READS_HARD_LIMIT = max(FEATURE_SOURCE_READS_PER_TURN, min(MAX_SOURCE_READS_PER_TURN, int(os.environ.get("FREECAD_FEATURE_SOURCE_READS_HARD", "5"))))
MODULE_BUSINESS_SOURCE_READS = max(2, min(MAX_SOURCE_READS_PER_TURN, int(os.environ.get("FREECAD_MODULE_BUSINESS_SOURCE_READS", "3"))))
MODULE_BUSINESS_SOURCE_READS_HARD = max(MODULE_BUSINESS_SOURCE_READS, min(MAX_SOURCE_READS_PER_TURN, int(os.environ.get("FREECAD_MODULE_BUSINESS_SOURCE_READS_HARD", "8"))))
STREAM_ERROR_LIMIT = max(1, int(os.environ.get("FREECAD_STREAM_ERROR_LIMIT", "3")))
STREAM_ERROR_WINDOW_SECONDS = max(30.0, float(os.environ.get("FREECAD_STREAM_ERROR_WINDOW", "180")))
DEFAULT_STREAM_RETRY_DELAY = max(1.0, float(os.environ.get("FREECAD_STREAM_RETRY_DELAY", "20")))
PROVIDER_NO_PROGRESS_SECONDS = max(10.0, float(os.environ.get("FREECAD_PROVIDER_NO_PROGRESS_SECONDS", "25")))
FIRST_ACTIVITY_TIMEOUT_SECONDS = max(30.0, float(os.environ.get("FREECAD_FIRST_ACTIVITY_TIMEOUT_SECONDS", "30")))
FIRST_ACTIVITY_MAX_SECONDS = max(FIRST_ACTIVITY_TIMEOUT_SECONDS, float(os.environ.get("FREECAD_FIRST_ACTIVITY_MAX_SECONDS", "180")))
FIRST_ACTIVITY_TRANSPORT_CAP_SECONDS = max(FIRST_ACTIVITY_TIMEOUT_SECONDS, float(os.environ.get("FREECAD_FIRST_ACTIVITY_TRANSPORT_CAP_SECONDS", "60")))
EXPLORATION_LIMIT_SECONDS = max(60.0, float(os.environ.get("FREECAD_EXPLORATION_LIMIT_SECONDS", "180")))
# General turns are drained after a bounded idle interval. Module-business
# turns already use the tighter 45s limit below; 90s prevents a no-output
# recovery/inspection turn from consuming three minutes before watchdog replan.
AGENT_IDLE_LIMIT_SECONDS = max(60.0, float(os.environ.get("FREECAD_AGENT_IDLE_LIMIT", "90")))
# Recovery turns contain an exact compiler diagnostic and should be short;
# a fresh turn remains available when the diagnostic needs another edit.
COMPILE_RECOVERY_IDLE_LIMIT_SECONDS = max(20.0, float(os.environ.get("FREECAD_COMPILE_RECOVERY_IDLE_LIMIT", "30")))
# A durable file_change is already visible to the outer build pipeline. Keep a
# small grace period for a follow-up message without paying a minute of idle.
POST_EDIT_FINALIZE_SECONDS = max(15.0, float(os.environ.get("FREECAD_POST_EDIT_FINALIZE_SECONDS", "20")))
MODULE_BUSINESS_IDLE_LIMIT_SECONDS = max(45.0, float(os.environ.get("FREECAD_MODULE_BUSINESS_IDLE_LIMIT", "45")))
REPEATED_FAILURE_LIMIT = max(2, int(os.environ.get("FREECAD_REPEATED_FAILURE_LIMIT", "3")))
PATCH_CONFLICT_LIMIT = max(2, int(os.environ.get("FREECAD_PATCH_CONFLICT_LIMIT", "2")))
FEATURE_PATCH_CONFLICT_LIMIT = max(1, min(PATCH_CONFLICT_LIMIT, int(os.environ.get("FREECAD_FEATURE_PATCH_CONFLICT_LIMIT", "1"))))
REDUNDANT_COMMAND_LIMIT = max(2, int(os.environ.get("FREECAD_REDUNDANT_COMMAND_LIMIT", "3")))
STREAM_DRAIN_SECONDS = max(0.1, float(os.environ.get("FREECAD_STREAM_DRAIN_SECONDS", "0.5")))
TURN_EXIT_GRACE_SECONDS = max(0.1, float(os.environ.get("FREECAD_TURN_EXIT_GRACE_SECONDS", "0.5")))
SCHEDULER_REASONING_EFFORT = os.environ.get("FREECAD_SCHEDULER_REASONING_EFFORT", "low").strip().lower()
if SCHEDULER_REASONING_EFFORT not in {"low", "medium", "high", "xhigh"}:
SCHEDULER_REASONING_EFFORT = "medium"
FAILURE_STATE_PATH = ROOT / "docs/current/freecad-web-scheduler-failures.json"
FAILURE_PATTERN_PATH = ROOT / "docs/current/failure-patterns.json"
MODULE_BATCH_PATH = ROOT / "docs/current/module-batch-manifest.json"
IMPLEMENTATION_PACKET_PATH = ROOT / "docs/current/module-implementation-packet.json"
MODULE_PIPELINE_PATH = ROOT / "docs/current/module-batch-pipeline.json"
SESSION_CACHE_PATH = ROOT / "docs/current/freecad-web-scheduler-session-cache.json"
UNBUILT_EDIT_PATH = ROOT / "docs/current/unbuilt-module-batch-edit.json"
SESSION_REUSE_LIMIT = max(1, int(os.environ.get("FREECAD_SESSION_REUSE_LIMIT", "8")))
FEATURE_SESSION_REUSE_LIMIT = max(1, int(os.environ.get("FREECAD_FEATURE_SESSION_REUSE_LIMIT", "3")))
# A resumed Codex thread includes the previous turns in the provider context.
# Keep the default deliberately below the service context ceiling so an
# unattended run rotates before the provider starts rejecting overloaded
# requests. The lower bound still permits large source packets when a user
# explicitly opts in to a larger limit.
SESSION_INPUT_TOKEN_LIMIT = max(16_000, int(os.environ.get("FREECAD_SESSION_INPUT_TOKEN_LIMIT", "96000")))
SESSION_REUSE_ENABLED = os.environ.get("FREECAD_SESSION_REUSE", "1").lower() in {"1", "true", "yes", "on"}
# Prompt bytes are measured before sending stdin to Codex. This is a separate
# guard from the session token limit: a single oversized packet must not make a
# fresh thread fail before the normal reuse policy can take effect.
PROMPT_BYTE_LIMIT = max(12_000, int(os.environ.get("FREECAD_PROMPT_BYTE_LIMIT", "24000")))
def default_build_jobs() -> int:
"""Choose a parallel build width that uses CPU without courting OOM."""
cores = os.cpu_count() or 1
memory_kib = 0
try:
for line in Path("/proc/meminfo").read_text(encoding="ascii").splitlines():
if line.startswith("MemTotal:"):
memory_kib = int(line.split()[1])
break
except (FileNotFoundError, OSError, ValueError):
pass
# Emscripten links are memory-heavy; reserve roughly 3 GiB per active job.
memory_cap = max(1, memory_kib // (3 * 1024 * 1024)) if memory_kib else cores
return max(1, min(cores, memory_cap))
BUILD_JOBS = max(1, int(os.environ.get("FREECAD_BUILD_JOBS", default_build_jobs())))
EMCC_CORES = max(1, int(os.environ.get("EMCC_CORES", str(BUILD_JOBS))))
MAX_FAILURE_REPLAY = max(1, int(os.environ.get("FREECAD_FAILURE_REPLAY_LIMIT", "4")))
REPLAY_FAILURES_DURING_IMPLEMENTATION = os.environ.get("FREECAD_REPLAY_FAILURES_DURING_IMPLEMENTATION", "0").lower() in {"1", "true", "yes", "on"}
def build_environment(source_budget_marker: Path | None = None) -> dict[str, str]:
"""Set compiler parallelism and optionally use a RAM-backed ccache."""
environment = {key: str(value) for key, value in os.environ.items()}
environment.setdefault("EM_CONFIG", str(ROOT / "tools" / "emscripten-writable-config.py"))
environment.setdefault("EM_CACHE", "/tmp/freecad-p6-emcache")
environment.pop("EM_FROZEN_CACHE", None)
environment.pop("FROZEN_CACHE", None)
environment["EMCC_CORES"] = str(EMCC_CORES)
environment["CMAKE_BUILD_PARALLEL_LEVEL"] = str(BUILD_JOBS)
scheduler_bin = ROOT / "tools/scheduler-bin"
real_rg = shutil.which("rg", path=environment.get("PATH"))
real_git = shutil.which("git", path=environment.get("PATH"))
if real_rg:
environment["FREECAD_REAL_RG"] = real_rg
if real_git:
environment["FREECAD_REAL_GIT"] = real_git
environment["PATH"] = f"{scheduler_bin}{os.pathsep}{environment.get('PATH', '')}"
environment["FREECAD_SCHEDULER_BIN"] = str(scheduler_bin)
environment["FREECAD_SCHEDULER_ROOT"] = str(ROOT)
environment["BASH_ENV"] = str(ROOT / "tools/scheduler-shell-env.sh")
if source_budget_marker is not None:
environment["FREECAD_SOURCE_BUDGET_MARKER"] = str(source_budget_marker)
requested_cache = os.environ.get("FREECAD_CCACHE_DIR", "").strip()
use_ram_cache = os.environ.get("FREECAD_CCACHE_RAM", "1").lower() in {"1", "true", "yes", "on", "auto"}
if requested_cache:
cache_path = Path(requested_cache).expanduser()
elif use_ram_cache and Path("/dev/shm").is_dir():
cache_path = Path("/dev/shm/freecad-ccache")
try:
available = shutil.disk_usage("/dev/shm").free
except OSError:
available = 0
# Keep at least 8 GiB free for Emscripten/OS activity.
memory_available = 0
try:
for line in Path("/proc/meminfo").read_text(encoding="ascii").splitlines():
if line.startswith("MemAvailable:"):
memory_available = int(line.split()[1]) * 1024
break
except (FileNotFoundError, OSError, ValueError):
pass
if available < 8 * 1024**3 or (memory_available and memory_available < 8 * 1024**3):
cache_path = None
else:
cache_path = None
if cache_path is not None:
cache_path.mkdir(parents=True, exist_ok=True)
environment["CCACHE_DIR"] = str(cache_path)
environment.setdefault("CCACHE_MAXSIZE", "10G")
return environment
_LEGACY_PROMPT = f"""按 nextTask.md 接续执行。你是全项目持续调度中的一个实现回合,不是报告回合。
必须从 docs/current/task-state.json 的 active_task 恢复,只读取 `docs/current/context/{{active_task}}.json` 中的 mode=read 文件;不要猜测或读取不存在的 `docs/current/context-manifest.json`。直接实施当前任务的源码、依赖、WASM、Worker、Facade、React、证据和文档工作;在用户授权仓库范围内不要请求确认。严格使用统一 fc_web_dispatch_json(cmd,args) native 协议,不以静态 JS 结果替代 FreeCAD 源码。
WASM 模块化铁律:当前批次按功能模块生成独立 runtime/WASM/data artifact;浏览器通过独立 Worker 按需动态加载,已完成模块可以预热并保持运行,revision 变化只替换当前模块。禁止拼接 WASM 二进制,禁止要求最终合成为单体 WASM。若本回合产生新的模块 probe,必须在证据中记录 module、worker、revision/hash、runtimeUrl、wasmUrl、dataUrl 或 assetBaseUrl;React 接入优先使用 `WebModuleRouter.setArtifact/loadManifest/preload` 和 `web/src/module-loader.ts`。
Chrome 测试加载规则:调用 `tools/run_p6_chrome_harness.mjs` 时,已完成模块通过 `P6_CHROME_PRELOAD_MODULES` 预取 `/modules/manifest.json` 中的 WASM/data(可用 `P6_CHROME_PRELOAD_ASSETS` 扩展 runtime),当前增量模块不得加入预加载列表,必须跟随实际命令由 Worker 动态加载;预加载失败写入 `modulePreload` 并保持失败,不得伪造通过。
本回合唯一工作区是 `{ROOT}`。每个 exec/apply_patch 工具调用必须显式使用该绝对路径作为 workdir/文件前缀;禁止使用 `/home/mes123456/FreeCAD` 或自行猜测其他仓库根目录。每条 shell 命令也必须以 `cd {ROOT} &&` 开头,或对每个仓库文件使用 `{ROOT}/...` 绝对路径;禁止依赖平台默认 cwd,禁止仅执行 `pwd` 后再使用相对路径。
接续规则读取边界:`nextTask.md` 只读取当前执行规则区和“当前检查点”摘要(优先第 20-70、72-85 行);后面的旧恢复示例、历史报告和完整命令块不是本回合输入,不得重复读取或照抄执行。结构化状态以 task-state、continuation-state 和当前 context manifest 为准。
源码检索铁律:禁止从仓库根目录对 `freecad-source`、`web/src` 或 `tools` 做全量 `rg`/`find`/批量 `sed`。优先使用 `python3 tools/fast_source_search.py 'Pattern'`,它只搜索当前批次 manifest 的源码文件;需要 runtime 适配时显式加 `--runtime`,需要其它文件时使用受控 `--path`。
源码读取预算:不得对超过 240 行的源码、CMake 或 gate 文件执行无范围的 `sed -n '1,...'`;先用 `fast_source_search.py` 或 `rg -n` 定位命中行,再读取命中行前后最多 120 行。每回合最多 12 条 `source/read` 命令,达到后立即停止读取并开始编辑/构建;重复读取同一文件必须说明输入 hash 已变化,未变化时复用上一回合摘要。这样 `source/read` 计时只反映必要上下文,不把整文件转储算进实现回合。
探索命令容错:`rg`/`fast_source_search.py` 无匹配是正常结果;多命令 shell 链不得把空匹配当失败,优先使用 `fast_source_search.py`,或在独立 `rg` 后显式处理其结果再进入 `&&`。
构建并行度铁律:所有 `cmake --build`、`ninja`、`make` 以及同类编译命令统一使用 `-j{BUILD_JOBS}`;当前主机默认自动选择为 `{BUILD_JOBS}`(可用 `FREECAD_BUILD_JOBS` 覆盖)。禁止新命令使用 `-j2` 或低于自动值。已有正在运行的命令允许自然结束,下一条构建命令必须改为 `-j{BUILD_JOBS}`。Emscripten 优化阶段使用 `EMCC_CORES={EMCC_CORES}`。
Emscripten cache 规则:构建命令继承项目内 `{ROOT}/tools/emscripten-writable-config.py`,该配置将 `FROZEN_CACHE=False`、`CACHE=/tmp/freecad-p6-emcache`、`PORTS=/tmp/freecad-p6-emcache/ports`;禁止回退到 `/usr/share/emscripten/.emscripten` 的冻结 cache。所有命令必须使用当前绝对工作区 `{ROOT}`,禁止生成或重放 `/home/mes123456/FreeCAD` 旧路径。
增量链接规则:Web probe build tree 使用 `FREECAD_WEB_FAST_LINK=ON`(`fast-dev`,link `-O0`)以缩短实现/focused-gate 链接;不得把 fast-dev artifact 当作发布性能结果。最终发布或用户明确要求的全量性能验证前,将各 build tree 重新配置为 `FREECAD_WEB_FAST_LINK=OFF`(`release`,link `-O2`),并在 evidence/manifest 记录 profile。
构建前闭包预检:执行任何新的 `cmake --build` 前,先运行 `python3 tools/check_web_link_closure.py <build-dir> <target> --json`;若报告 pthread/QtConcurrent/模块化 WASM link flag 缺失,先修复 CMake target,再构建。不得用一次完整失败链接替代预检。
批次启动预检:scheduler 已在代理回合前运行 `python3 tools/preflight_current_module_batch.py --json`;代理不得在预检失败时直接 gate 或构建,应先修复/重规划 manifest、context、记录边界。
遇到 `FROZEN_CACHE is set` 时不要反复尝试 `EM_FROZEN_CACHE=`、`FROZEN_CACHE=0` 或读取系统配置;直接使用 `EM_CONFIG={ROOT}/tools/emscripten-writable-config.py` 并复用 `/tmp/freecad-p6-emcache`。单回合 command 数不得超过 48,达到预算后只落盘结果和 checkpoint,由下一回合继续。
构建去重规则:先集中完成当前批次源码/fixture 修改,再构建目标;同一批次最多一次正常构建和一次针对首错的重试,链接成功后直接同步 artifact 并运行 gate,禁止因重复读取或重复 gate 再次重链未变化的 target。若源码 hash 未变且目标产物存在,复用现有 WASM。构建和首错重试完成后不得继续反复分析;无论 gate 结果如何都必须立即写入证据/checkpoint 并返回下一回合。
模块构建入口:`tools/build_web_module_artifacts.py` 无 `--target` 时只构建当前
`module-batch-manifest.selected.module` 对应的目标;只有发布/CI 明确使用 `--all`
或 `FREECAD_WEB_BUILD_ALL=1` 时才允许遍历全部模块 target。
实现节奏:读取 manifest、现有 dispatcher 和同族 gate 后,最多执行 8 条检查命令;随后必须编辑、构建或收口。若当前批次已有 pass/pass_with_boundaries evidence,只做必要的 gate/checkpoint 投影,不重新设计 fixture、不重复读取完整源码、不重复构建未变化 target。
状态所有权:`docs/current/module-batch-manifest.json` 只能由外层 planner 写入;Codex 回合禁止修改其 `status`、`selected`、`records`、`batchId` 或任何字段。gate/evidence 结果写入各自 evidence 和 canonical manifest,批次 manifest 与 context fingerprint 由下一次外层 `continue_web_task_cycle.py` 自动刷新。若发现 manifest/context hash 不一致,立即停止当前回合并落盘首错,不要手工改回 `ready` 或伪造 hash。
探针超时铁律:所有临时 Node/WASM here-doc 探针必须使用 30-120 秒 `timeout`、在证据写入后显式 `process.exit(0)`,禁止让 pthread worker 或事件循环无限期常驻;超时只保留为该记录边界,不得阻塞整批其它记录。
高吞吐功能模块批次铁律:读取 `docs/current/module-batch-manifest.json`,一次只处理其中同一 `module+tier+family` 的全部 selected records(纯函数/只读默认 24 条,对象契约默认 16 条,可缩小到 4/8 或扩大到 32);用一个可复用 native adapter、一个 `fc_web_dispatch_json(cmd,args)` route、一个 Facade/Worker 分支和一个 gate 贯通整批。不得为每个 API 单独创建 Worker、页面或重复编译。每条记录只有在真实 FreeCAD/WASM 证据存在时才晋级;未覆盖记录保留原 disposition。批次完成后立即生成下一批,不在单条 API 之间停顿。
实现优先、验证分层:批次内先完成源码接线、dispatcher schema、资源释放、重复/取消和最小 native fixture;完整 API gate、全量 parity gate、Chrome 矩阵、性能和安全验证在功能族/模块收口后集中执行,不在每条 API 或每个小批次之间重复运行。
增量验证铁律:移植未达到终止条件前,每个回合只运行 `tools/validate_incremental_context.py` 及当前批次的 focused native/Worker gate;禁止运行 `tools/validate_web_context.py`、完整 Python API gate、Full Parity gate 或全量 Chrome/性能/安全回归。全部移植任务完成后保持等待,只有用户明确指令才执行这些全量验证。
时间片边界:一个 Codex 回合只处理当前 manifest 的一个批次;完成该批次的实现、增量 gate、证据和 checkpoint 后立即返回,让 scheduler 在下一回合领取下一批。不得在同一回合连续领取多个批次、重复运行已通过 gate 或进行全量源码/清单扫描。
回合命令预算:默认每回合最多 48 个工具命令,其中读取/探索命令最多 16 个;达到读取预算后必须进入实现和当前批次 gate。禁止重复读取同一文件、重复运行已通过 gate 或加载完整日志;接近预算时先落盘 checkpoint 并返回,让下一回合继续。
失败恢复配额:实现阶段默认不重放历史失败(`FREECAD_REPLAY_FAILURES_DURING_IMPLEMENTATION=0`);只有失败直接阻断当前批次时才定点处理。全部移植完成且用户明确要求全量验证后,才启用历史失败重放。若显式启用,实现回合最多处理 `{MAX_FAILURE_REPLAY}` 条,只有命令实际成功重放后才能标记 resolved。
批次准备防空转:在读取检查器失败且错误是批次不存在、任务不匹配或 selected 记录少于 `batchPolicy.minRecords` 时,先重新运行批次规划器;只有所有剩余分组都不足最小值时才接受 `singletonFallback=true` 尾批。不得连续等待同一份无效 manifest。
在本回合结束前必须落盘当前批次结果、首错或恢复点,并保持 continuation-state.json 的 mustContinue=true、noFinalBeforeTerminate=true、phase=CONTINUE。当前卡未完成时不得领取别卡;当前卡完成后只更新结果/checkpoint 并返回,由外层 scheduler 在下一回合统一执行批次投影、上下文轮换和下一批领取。Codex 回合内禁止再次调用 tools/continue_web_task_cycle.py、rotate_web_task_context.py、prepare_full_parity_module_batch.py、prepare_web_task_context.py 或任何全量 validate_*;只有当前批次 focused gate 与 validate_incremental_context.py 允许执行。仓库内同步脚本、资源和构建入口必须先用 `rg --files` 确认存在,再执行;不存在的命令记录为失败并改用仓库内实际入口。不要把阶段性结果写成项目完成,不要只输出计划。"""
# The original prompt remains above as an audit trail. Automated turns use
# this compact packet because the outer scheduler already validated task,
# checkpoint, context and batch ownership before starting Codex.
LEAN_PROMPT = f"""你是持续调度器已经预检并领取的单批次实现回合。当前策略是“用户功能链优先、验证后置、使用中发现问题再定点修复”。下方 capability/runtime-issue 工作包是权威输入;13,386 个 Python 源符号只作检索索引,不再作为实现队列或主进度。不要重读 `nextTask.md`、完整 `task-state.json`、完整历史日志或无关前置卡,也不要调用外层 planner/context rotation 工具。
唯一工作区:`{ROOT}`。所有工具显式使用该 workdir/绝对路径。只处理当前功能批次或运行时问题,完成源码/WASM/Worker/React 接线和实现回执后立即返回,不自行领取下一批。
当前运行在 Default mode,`request_user_input` 不可用且本任务不需要确认;禁止调用该工具,所有仓库范围内实现决策直接依据源码完成。
1. 直接使用工作包内嵌的 editable excerpts;只有补丁锚点缺失时才定点读取,目标预算 {FEATURE_SOURCE_READS_PER_TURN} 条、普通搜索硬上限 {FEATURE_SOURCE_READS_HARD_LIMIT} 条。禁止重复读取未变化内容;缺失锚点或修改后的锚点可使用 read_implementation_source.py 的独立受限补读配额。先 `rg -n` 定位,每个文件单次最多读取命中前后 80 行,每条命令输出最多约 240 行。禁止把多个大文件拼接输出、全量 `rg/find`、重复读取未变化文件,以及不带当前文件路径的仓库级 `git status`/`git diff`/`git diff --stat`。若工作包的 `editableExcerpts` 为空或 `supportFiles` 中没有目标文件,禁止猜测 `src/...`、`web/...` 路径并反复 `find/ls`;只依据 packet 的 `nativeMatches`/指纹在真实源码位置编辑,若确实没有可用适配点就记录真实边界后返回。
2. 用一个可复用 native adapter、一个 `fc_web_dispatch_json({{cmd,args}})`、一个 Facade/Worker 分支处理整批。功能值必须来自 FreeCAD C++/Python 编译的 WASM;JS/TS 只运输和校验。
3. 优先补用户可操作的完整链:React 参数/状态 → Worker `{{cmd,args}}` → `fc_web_dispatch_json` → 模块 WASM → 结构化结果/错误。补丁一次只改一个已读取目标,同一文件在一个回合内只提交一次补丁并集中包含本批全部修改;首次 patch 冲突立即返回,由下一回合使用刷新后的多锚点工作包恢复,禁止在旧上下文中生成第二个大补丁。没有 native handler 时记录受影响 capability 的真实依赖并继续其它功能,不为源码符号制造假实现。
当前记录若是 `*:desktop:*`,必须按官方 Gui/Qt command 源码语义实现同名 Web command:计算/Document 行为进入模块 WASM dispatcher,选择、任务面板、视图和对话框由 React 等价交互承载;native 注册必须使用原 desktop command 名,由生成器刷新 module/route registry,禁止手改生成文件。只返回 `restricted`/分类摘要不算移植,整批 selected records 都处理后再返回。
命令结果必须由请求 `args`、Document/object handle、BREP/mesh/属性输入和真实 FreeCAD/OCCT handler 计算;禁止固定 ShapeA/ShapeB、固定尺寸、固定计数或预制结果。优先扩展工作包指定的 `<Module>DesktopCommands.cpp` 参数化注册表和 `FreeCADWeb<Module>DesktopCommandProbe` 小模块;React 使用通用 `DesktopCommandDispatcher`,不要复制逐命令 payload。若 selected IDs 尚未全部被 capability registry 识别,外层 pipeline 会以 `implementation_incomplete/buildRequired=false` 返回,此时继续补齐整批,不要尝试手工构建。
桌面命令清单、所属模块和 Worker route 必须由 native 注册与生成器产出;禁止在 React/Facade 复制逐命令默认参数表或固定结果。缺少参数时由 React 通用 JSON/参数 UI 收集并把原始 `args` 传入 WASM。
同一模块有多个 desktop ID 且共享执行模型时,优先在现有 family record handler 中实现 `if (apiId.startsWith("<Module>:desktop:"))` 的参数化入口,并在入口内按官方 command 名执行对应行为。不要新增 `<module>.desktopCommand` transport alias,不要逐命令修改 module registry 或 Worker。
工作包若 `supportChecks.desktopAdapterRecommended=true`,说明现有 native probe 已超过拆分阈值。不要继续扩大该单体文件;参照现有 Part 模式,把新的桌面命令实现放入 `<Module>DesktopCommands.cpp/.h`,现有 runtime 仅调用共享 dispatcher,并把新源加入同一增量 target。生成器和 Worker 仍复用统一 route。
仅有 prefix 分支或统一 fixture 不代表整族功能完成。禁止给固定/摘要 fixture 添加 `FC_WEB_AUDITED_DESKTOP_FAMILY`;只有 handler 已按每个官方 command 名分派并执行对应 FreeCAD Document/geometry 语义时,才允许该审计标记。无法在本批实现全部命令时保留 `implementation_required`,不要伪造进度。
批次允许部分真实实现:只给已经执行对应源码语义的命令加 `FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("name")`,外层会只构建并回执这些记录,再把其余记录自动重排。禁止为仅返回交互摘要、参数回显、固定 fixture 或 `not_implemented` 的命令添加该标记。
4. 若工作包标记 `outer pipeline enabled`,不要运行 preflight/build/sync/gate/validate;外层调度器只执行当前目标的增量 build 和 artifact sync,并写 implementation receipt。只有工作包明确没有外层 pipeline 时,才在集中修改后执行当前模块一次 `-j{BUILD_JOBS}` 增量构建和资源同步。
5. 不要运行 `--help`,不要主动运行 focused gate、增量验证、完整 API/Full Parity/Chrome/性能/安全验证,也不要在成功后重读 evidence、重算 hash 或运行 git 检查。构建错误只处理首错一次;实际使用问题由 runtime issue ledger 定点重新打开。
6. WASM 按功能模块保留独立 runtime/wasm/data,由独立 Worker 动态加载;禁止合并。证据记录 module、worker、revision/hash、runtimeUrl、wasmUrl、dataUrl/assetBaseUrl。
`docs/current/module-batch-manifest.json` 仅外层 planner 可写。失败只处理当前批次首错;相同失败最多一次定点重试,随后保存真实边界/checkpoint 并返回。不要重放历史验证失败,不要把 implementation-ready 写成 validation pass。"""
MODULE_BUSINESS_PROMPT = f"""你是持续调度器已预检的模块业务实现回合。当前 selected records 是本模块用户业务工作流,不是 Python API 任务;禁止扫描或移植 13,386 API,禁止一 API 一实现、一 Worker、一页面或逐条重复编译。
唯一工作区:`{ROOT}`。只处理 manifest 指定的一个 module。先从该模块的 FreeCAD C++/Python 源程序和现有 probe/dispatcher 识别共享业务模型,再一次完善整组用户功能:Document/object handle、参数和返回 schema、真实几何/属性/重算/持久化行为、异常与资源释放。通过同一个 `fc_web_dispatch_json({{cmd,args}})`、模块 Facade 和 Worker 调用;不得用固定返回、源码摘要或分类状态冒充实现。
优先顺序是模块用户业务链:对象创建与编辑 → 几何/约束/属性 → Document 事务与链接 → 导入导出/持久化 → React 工作台。直接复用已有模块 WASM 和已完成功能;Python API inventory 只作离线源码索引,不读取、不排队、不影响完成条件。
直接使用 implementation packet 的 editable excerpts。只有锚点缺失时做 manifest 范围内定点搜索,禁止全库扫描。若 outer pipeline enabled,不运行 preflight/build/sync/gate/validate/git 检查;集中完成源码、native dispatcher、Facade/Worker/React 必需修改后立即返回,由外层只构建当前模块目标并写 implementation receipt。全量 API、Full Parity、Chrome、性能和发布验证继续后置。
一个批次只允许共享模块实现,不得生成逐 API switch 的静态成功结果。内部需要方法调用时由模块 C++/Python 源码直接完成,不公开逐 API WASM 函数;需要 GUI 时把计算和 Document 状态留在 WASM,把任务面板、选择和视图交互实现为 React 工作流。无法由现有源码闭包实现的依赖要记录模块级真实边界,并继续该模块其它可执行业务。"""
BUSINESS_IRON_RULE = """最高优先级,2026-09-07 用户铁律:坚持以功能模块业务、功能路由带动全部移植,充分复用已完成源码、WASM、缓存和用例。846/791 不是上限;遗漏业务必须补齐。Python API/对象/GUI/格式依赖由当前业务驱动,不逐 API 排队。
2026-09-08 用户新增铁律:先搜索用户提供的 FreeCAD 源程序和相关资料。实现、补验收、修复和判定缺口前,先定点检索本地源码、依赖源码、资料、已有用例和构建产物;工作包内已有且指纹未变的结果直接复用。先复用和接通已有实现,确认本地具体缺口后才定向查官方外部资料。按 docs/current/provided-source-index.md 定位已确认的本地资料,不猜路径、不重复全库扫描,不以未搜索到或路径错误宣称源码不存在。
失败同步闭环:failureReconciliation 列出当前模块历史失败,先处理直接阻断当前业务的故障;同类错误合并分析。禁止盲目重放旧 API gate。业务用例实际覆盖某条失败时,在结果中列 resolvedFailureKeys,由外层核对当前失败指纹、真实执行证据和业务结果后关闭;过期诊断用 failure_reconciliation.py --review 提交原因及替代源码/证据,不能伪标 passed,也不能删除原始失败。
自动缺口修复:工作包 implementationGaps/observedIssues/previousAttempt 是当前缺口;integrationRepair 给出已授权的应用调用点。需要页面接入时必须修改该调用点并使用现有模块组件、Worker 和文档状态,不能只创建未挂载的 helper,也不能因旧提示未列页面就重复写边界。moduleRecovery 表示此前零进展,应先修共享缺口,再继续命令批次。
源码读取预算仅阻止额外检索,不终止当前实现回合;使用已提供的完整源码片段进入编辑,正常结束回合后复用上下文。不要把片段省略标记当作源码补丁锚点。
路由、构建回执、业务结果确认是不同状态。禁止固定 fixture、参数回显、整族标记冒充业务实现;禁止仅移除 native-source 保护而不实现 Worker/native contract。当前业务必须作用于用户输入和当前文档。详见 docs/WEB_BUSINESS_MIGRATION_RULES.md。
若 tier=business-acceptance 或 family=runtime-issue,复用已有实现和用例,编写 docs/business-acceptance/<Module>.json 的 command 及项目内业务用例脚本,按 FREECAD_BUSINESS_IDS 执行真实业务,stdout 输出 freecad-web-business-cases/v1 的 results/capabilityId/status/cases/input/assertions。结果断言必须来自实际 WASM 或浏览器运行,不得常量通过;不同参数及必要异常一并覆盖。runtime-issue 修复必须用真实用例复现并声明实际覆盖的 resolvedIssueIds、resolvedFailureKeys;仅编译通过不会关闭问题。Node WASM 复用 tools/wasm-probe-runtime.mjs 的加载器;一次性用例应在结果完整写出及清理后退出,避免 pthread 池拖到超时。外层负责运行用例和保存带指纹证据,不要自己写 acceptance 通过记录。此阶段允许业务用例,后文禁止验证仅指无关历史/API/full-parity gate。发现实现缺口就修对应业务,保留其它模块产物。
业务验收贯穿移植:已覆盖合同的用例由外层优先运行;未覆盖项按 businessWorkflow 复用同一业务链补用例。合同 coveredIds 只列实际覆盖 ID,build 指向用例真正加载的现有 tree/target;不能把固定样例的保存、导入或几何返回当成当前文档完整业务。必要 native 修改由外层增量构建、同步后验收;同次模块执行共享 WASM 初始化和结果证据。
"""
LEAN_PROMPT = BUSINESS_IRON_RULE + "\n" + LEAN_PROMPT
MODULE_BUSINESS_PROMPT = BUSINESS_IRON_RULE + "\n" + MODULE_BUSINESS_PROMPT
def failure_key(command: str) -> str:
normalized = " ".join(command.split())
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:20]
def canonical_failure_command(command: str) -> str:
"""Collapse shell quoting artifacts before matching a replayed command."""
normalized = " ".join(command.split())
normalized = normalized.replace("/home/mes123456/FreeCADWasmDev/working/FreeCAD", "<WORKSPACE>")
normalized = normalized.replace("/home/mes123456/FreeCAD", "<WORKSPACE>")
return normalized.translate(str.maketrans("", "", "'\"\\"))
def plain_source_search(command: str) -> bool:
"""Recognize rg pipelines without treating quoted search patterns as shell code."""
try:
outer = shlex.split(command)
if len(outer) == 3 and Path(outer[0]).name in {"bash", "sh"} and outer[1] in {"-lc", "-c"}:
command = outer[2]
# Expansion/redirection or another executable needs the existing
# conservative classifier, not the plain-search exemption.
if any(character in command for character in ("$", "`", "\n", "\r", "<", ">")):
return False
lexer = shlex.shlex(command, posix=False, punctuation_chars=";&|()")
lexer.whitespace_split = True
lexer.commenters = ""
tokens = list(lexer)
except ValueError:
return False
needs_command = True
found_search = False
for token in tokens:
if token in {";", "&&", "||", "|"}:
if needs_command:
return False
needs_command = True
continue
if token and all(character in ";&|()" for character in token):
return False
if needs_command:
executable = Path(token).name
if executable not in {"rg", "head", "tail", "wc"}:
return False
found_search = found_search or executable == "rg"
needs_command = False
else:
# Quoted option values still become options when the shell runs rg.
option = token[1:-1] if len(token) >= 2 and token[0] in {"'", '"'} and token[-1] == token[0] else token
if option in {"--pre", "--hostname-bin"} or option.startswith(("--pre=", "--hostname-bin=")):
return False
return found_search and not needs_command
def forbidden_implementation_command(command: str, phase: str) -> bool:
"""Keep builds and validation in the deterministic outer pipeline."""
if plain_source_search(command):
return False
value = " ".join(command.lower().split())
if user_validation_deferred() and any(token in value for token in (
"run_business_acceptance", "business-cases", "unittest", "pytest", "playwright", "benchmark_business",
"--noemit", "run_p6_", "full_parity", "full-parity:gate",
)):
return True
if phase in {"build", "gate", "validation"} or "build" in phase or "gate" in phase:
return True
if any(token in value for token in (
"npm test", "pytest", "ctest", "playwright", "validate_", "run_p6_",
"chrome", "cmake --build", "ninja ",
)):
return True
# Accept common wrapper flags between the executable and its subcommand
# (for example `npx --prefix web tsc`) so composite validation commands do
# not escape the implementation-only turn policy.
if re.search(r"\bnpx(?:\s+--[^\s]+(?:\s+[^\s]+)?)?\s+tsc\b", value):
return True
if re.search(r"\bnpm(?:\s+--[^\s]+(?:\s+[^\s]+)?)?\s+run\s+build\b", value):
return True
if re.search(r"\bgit\s+(?:diff|status|log|show|branch)\b", value):
return True
return False
def load_failure_state() -> dict[str, object]:
try:
value = json.loads(FAILURE_STATE_PATH.read_text(encoding="utf-8"))
return value if isinstance(value, dict) else {"schema": "freecad-web-scheduler-failures/v1", "failures": {}}
except (FileNotFoundError, json.JSONDecodeError):
return {"schema": "freecad-web-scheduler-failures/v1", "failures": {}}
def save_failure_state(state: dict[str, object]) -> None:
state["schema"] = "freecad-web-scheduler-failures/v1"
state["updatedAt"] = datetime.now(timezone.utc).isoformat()
temporary = FAILURE_STATE_PATH.with_suffix(".tmp")
temporary.write_text(json.dumps(state, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
temporary.replace(FAILURE_STATE_PATH)
save_failure_patterns(state)
FAILURE_PATTERN_RULES = (
("context-batch-stale", ("stale context", "batchid does not match", "continuation_state_invalid", "context validation failed"), "先运行 continuation checker,再按当前 manifest 重建 context;不得直接运行 gate。"),
("node-module-missing", ("err_module_not_found", "cannot find module", "module-not-found"), "先确认目标 runtime/wasm/worker artifact 存在,再运行 gate。"),
("esm-cjs-worker", ("require is not defined", "type=module", "worker.js"), "将 pthread worker staging 为 .worker.cjs,并通过 locateFile 指向 staging 文件。"),
("wasm-memory", ("memory access out of bounds", "growable_heap", "out of bounds"), "缩小当前功能族批次,检查 initial memory 和单条 fixture 的资源释放。"),
("link-missing-library", ("tkmeshvs", "cannot find -l", "undefined symbol", "failed: bin/"), "先运行 check_web_link_closure,补齐真实归档或移除无用可选依赖,再重链。"),
("gate-hardcoded-batch", ("unexpected .*batch", "active .* batch does not match", "batch manifest"), "gate 必须读取当前 module-batch-manifest,不得硬编码 batchId/recordCount。"),
("target-alias", ("link rule not found for", "unknown target alias"), "使用当前模块主 probe;闭包预检可自动将 target 别名解析到实际目标。"),
("path-or-artifact-missing", ("no such file", "not found", "path mismatch", "worker actual"), "使用绝对 workspace 路径,并将 build tree 归一到实际 bin/artifact 目录。"),
("shell-syntax", ("syntax error", "unexpected token", "here-document"), "避免复杂内嵌 shell/Python 拼接,改用已有工具或独立脚本。"),
("inspection-scope", ("scheduler_search_scope_rejected", "scheduler_git_scope_rejected"), "使用 manifest-scoped fast_source_search 或显式当前文件;git diff/status 必须带 -- pathspec。"),
("patch-apply-conflict", ("apply_patch verification failed", "invalid patch", "multiple operations target", "patch does not apply"), "先读取目标文件的当前版本并将修改拆成单文件、单目标的小 patch;patch 校验失败后不得重复提交同一补丁。"),
("runtime-timeout", ("timeout", "timed out", "execution window"), "保留真实边界,只允许一次受控重试;后续按 evidence 去重。"),
("native-dispatch", ("native_dispatch_failed", "native protocol error", "unknown .* dispatch failure"), "先单条 native dispatcher 探针定位,再修共享 fixture,不重复整批 gate。"),
)
def classify_failure(command: object, output: object) -> tuple[str, str]:
text = f"{command} {output}".lower()
for category, needles, prevention in FAILURE_PATTERN_RULES:
if any(re.search(needle, text) for needle in needles):
return category, prevention
command_text = str(command).lower()
if "run_p6_" in command_text or "module_batch_gate" in command_text:
return "gate-command-failure", "先运行当前批次预检并确认 gate 使用当前 manifest、正确 build/bin 路径和现有 artifact。"
if "cmake --build" in command_text or "build_web_module_artifacts" in command_text:
return "build-command-failure", "先运行 link-closure preflight,使用当前模块主 probe 和 -j8;失败后只允许一次定点重试。"
if any(token in command_text for token in ("prepare_", "validate_", "sync_web_task_state", "rotate_web_task_context")):
return "orchestration-command-failure", "只执行当前状态机允许的增量入口,发现 checkpoint/context 失配先修复再领取任务。"
return "other", "保留首错,先做最小复现和增量修复,不重复整批重试。"
def save_failure_patterns(state: dict[str, object]) -> None:
patterns: dict[str, dict[str, object]] = {}
for entry in failure_entries(state.get("failures")):
category, prevention = classify_failure(entry.get("command", ""), entry.get("firstError", ""))
row = patterns.setdefault(category, {"category": category, "occurrences": 0, "unresolved": 0, "prevention": prevention})
row["occurrences"] = int(row.get("occurrences", 0)) + int(entry.get("count", 0) or 0)
if entry.get("resolved") is not True:
row["unresolved"] = int(row.get("unresolved", 0)) + 1
if "representativeCommand" not in row:
row["representativeCommand"] = compact_text(entry.get("command", ""), 500)
row["representativeError"] = compact_text(entry.get("firstError", ""), 700)
row["lastSeenAt"] = max(str(row.get("lastSeenAt", "")), str(entry.get("lastSeenAt", "")))
payload = {"schema": "freecad-web-failure-patterns/v1", "updatedAt": datetime.now(timezone.utc).isoformat(), "patterns": sorted(patterns.values(), key=lambda item: int(item.get("occurrences", 0)), reverse=True)}
FAILURE_PATTERN_PATH.parent.mkdir(parents=True, exist_ok=True)
temporary = FAILURE_PATTERN_PATH.with_suffix(".tmp")
temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
temporary.replace(FAILURE_PATTERN_PATH)
def failure_entries(failures: object) -> list[dict[str, object]]:
"""Return ledger records for both the legacy keyed map and list format."""
if isinstance(failures, dict):
return [entry for entry in failures.values() if isinstance(entry, dict)]
if isinstance(failures, list):
return [entry for entry in failures if isinstance(entry, dict)]
return []
def find_failure(failures: object, key: str) -> dict[str, object] | None:
if isinstance(failures, dict):
entry = failures.get(key)
return entry if isinstance(entry, dict) else None
for entry in failure_entries(failures):
if entry.get("key") == key:
return entry
return None
def append_failure(failures: object, entry: dict[str, object]) -> None:
if isinstance(failures, dict):
failures[str(entry["key"])] = entry
elif isinstance(failures, list):
failures.append(entry)
def record_command_failure(
active: str,
cycle: int,
command: str,
exit_code: object,
output: object,
*,
state: dict[str, object] | None = None,
persist: bool = True,
module: str | None = None,
capability_ids: list[str] | None = None,
) -> dict[str, object]:
state = state if state is not None else load_failure_state()
failures = state.setdefault("failures", {})
if not isinstance(failures, (dict, list)):
failures = {}
state["failures"] = failures
key = failure_key(command)
now = datetime.now(timezone.utc).isoformat()
entry = find_failure(failures, key)
if entry is None:
entry = {
"key": key,
"command": command,
"originalCommand": command,
"firstSeenAt": now,
"count": 0,
"resolved": False,
}
append_failure(failures, entry)
entry.update(
{
"lastSeenAt": now,
"lastCycle": cycle,
"lastTask": active,
"lastExitCode": exit_code,
"count": int(entry.get("count", 0)) + 1,
"resolved": False,
"lastError": compact_text(output, 2000),
}
)
if not entry.get("firstError"):
entry["firstError"] = compact_text(output, 2000)
if module:
entry["module"] = module
if capability_ids:
entry["capabilityIds"] = sorted(set(capability_ids))
category, prevention = classify_failure(command, output)
entry["failureClass"] = category
entry["prevention"] = prevention
# Original failures remain available after reconciliation for audit.
if persist:
save_failure_state(state)
return entry
def resolve_command_failure(
command: str,
*,
state: dict[str, object] | None = None,
persist: bool = True,
output: object = "",
) -> bool:
state = state if state is not None else load_failure_state()
failures = state.get("failures")
if not isinstance(failures, (dict, list)):
return False
entry = find_failure(failures, failure_key(command))
if not isinstance(entry, dict):
canonical = command_tokens(command)
for candidate in failure_entries(failures):
if candidate.get("resolved") is True and candidate.get("resolutionProof"):
continue
candidate_command = candidate.get("command")
if isinstance(candidate_command, str) and command_tokens(candidate_command) == canonical:
entry = candidate
break
if not isinstance(entry, dict) or (entry.get("resolved") is True and entry.get("resolutionProof")):
return False
entry["resolutionProof"] = record_success(entry, command, output)
entry["resolved"] = True
entry["resolvedAt"] = datetime.now(timezone.utc).isoformat()
if persist:
save_failure_state(state)
return True
def record_pipeline_commands(active: str, cycle: int, batch: dict, report: dict) -> None:
module = batch.get("selected", {}).get("module")
ids = [str(row[0]) for row in batch.get("records", []) if row]
for step in report.get("steps", []):
if not isinstance(step, dict) or not isinstance(step.get("command"), list):
continue
if step.get("name") == "failure-reconciliation" and step.get("exitCode") == 1:
continue
command = shlex.join(step["command"])
output = str(step.get("stderr") or step.get("stdout") or "")
if step.get("exitCode") not in (None, 0):
record_command_failure(active, cycle, command, step.get("exitCode"), output, module=module, capability_ids=ids)
elif step.get("exitCode") == 0:
resolve_command_failure(command, output=output)
def unresolved_failures(
active: str | None = None,
*,
state: dict[str, object] | None = None,
) -> list[dict[str, object]]:
state = state if state is not None else load_failure_state()
failures = state.get("failures")
if not isinstance(failures, (dict, list)):
return []
values = []
for entry in failure_entries(failures):
if entry.get("resolved") is True:
continue
if active and entry.get("lastTask") not in (None, active):
continue
values.append(entry)
return sorted(values, key=lambda item: (-int(item.get("count", 0)), str(item.get("lastSeenAt", ""))))
def normalize_exploratory_no_match_failures(state: dict[str, object]) -> int:
normalized = 0
for entry in failure_entries(state.get("failures")):
if entry.get("resolved") is True:
continue
command = str(entry.get("command") or "")
first_error = str(entry.get("firstError") or "")
no_match = exploratory_no_match(command, entry.get("lastExitCode"), first_error)
prevented_scope = "SCHEDULER_SEARCH_SCOPE_REJECTED" in first_error and bool(re.search(r"\brg\s", command))
tool_compat = "fast_source_search.py" in command and any(token in first_error for token in (
"unrecognized arguments:", "Permission denied", "python: command not found"
))
if (no_match or prevented_scope or tool_compat) and not entry.get("diagnosticReview"):
entry["diagnosticReview"] = "rg_no_match" if no_match else "rg_scope" if prevented_scope else "tool_compat"
normalized += 1
return normalized
def failure_recovery_mode(active: str | None) -> str:
"""Describe whether failures block the current implementation turn."""
pending = unresolved_failures(active)
if not pending:
return "none"
return "required" if REPLAY_FAILURES_DURING_IMPLEMENTATION else "module-business-reconciliation"
def hydrate_recent_failures(active: str | None) -> None:
"""Import each completed cycle log at most once after a scheduler restart."""
if not active:
return
paths = sorted(LOG_DIR.glob("cycle-*.log"), key=lambda path: path.stat().st_mtime)[-20:]
state = load_failure_state()
normalized_no_match = normalize_exploratory_no_match_failures(state)
failures = state.setdefault("failures", {})
if not isinstance(failures, (dict, list)):
failures = {}
state["failures"] = failures
hydrated_logs = state.get("hydratedLogs", [])
if not isinstance(hydrated_logs, list):
hydrated_logs = []
hydrated_set = {str(item) for item in hydrated_logs}
imported = 0
hydration_changed = False
for path in paths:
log_id = path.stem
if log_id in hydrated_set:
continue
try:
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
except OSError:
continue
task = None
complete = False
for line in lines:
try:
event = json.loads(line)
except json.JSONDecodeError:
continue
if event.get("type") == "scheduler.cycle.started":
task = event.get("activeTask")
continue
if event.get("type") == "scheduler.cycle.completed":
complete = True
continue
if task != active or event.get("type") != "item.completed":
continue
item = event.get("item") if isinstance(event.get("item"), dict) else {}
if item.get("type") != "command_execution":
continue
command = str(item.get("command") or "")
if not command:
continue
key = failure_key(command)
entry = find_failure(failures, key)
command_output = item.get("aggregated_output")
if not successful_command(command, item.get("exit_code"), command_output):
now = datetime.now(timezone.utc).isoformat()
if entry is None:
entry = {"key": key, "command": compact_text(command, 1600), "firstSeenAt": now, "count": 0, "resolved": False}
append_failure(failures, entry)
entry.update(
{
"lastSeenAt": now,
"lastCycle": path.stem.removeprefix("cycle-"),
"lastTask": active,
"lastExitCode": item.get("exit_code"),
"count": int(entry.get("count", 0)) + 1,
"resolved": False,
"firstError": compact_text(item.get("aggregated_output"), 1000),
}
)
imported += 1
elif isinstance(entry, dict):
entry["resolved"] = True
entry["resolvedAt"] = datetime.now(timezone.utc).isoformat()
else:
canonical = canonical_failure_command(command)
for candidate in failure_entries(failures):
if candidate.get("resolved") is True:
continue
candidate_command = candidate.get("command")
if isinstance(candidate_command, str) and canonical_failure_command(candidate_command) == canonical:
candidate["resolved"] = True
candidate["resolvedAt"] = datetime.now(timezone.utc).isoformat()
break
# Do not mark an in-flight log as hydrated. If a scheduler is killed
# mid-turn, the watchdog can import its final evidence on the next
# restart once the cycle-completed marker exists.
if complete:
hydrated_logs.append(log_id)
hydrated_set.add(log_id)
hydration_changed = True
if len(hydrated_logs) > 64:
del hydrated_logs[:-64]
hydration_changed = True
if imported or hydration_changed or normalized_no_match:
state["hydratedLogs"] = hydrated_logs
state["lastHydratedTask"] = active
state["lastHydratedCount"] = imported
state["lastNormalizedNoMatchCount"] = normalized_no_match
save_failure_state(state)
def module_probe_hint(module: str, probe_sources: list[Path]) -> tuple[str, str] | None:
"""Resolve the current module target/build tree without parsing build.ninja."""
cmake_path = ROOT / "freecad-source/src/WebAssembly/FreeCADCoreProbe/CMakeLists.txt"
try:
cmake_text = cmake_path.read_text(encoding="utf-8")
except OSError:
return None
targets: list[str] = []
for source in probe_sources:
pattern = rf"add_executable\(\s*(FreeCADWeb[A-Za-z0-9]+Probe)\s+{re.escape(source.name)}\b"
targets.extend(re.findall(pattern, cmake_text))
build_root = ROOT / "freecad-source/build-web"
for target in dict.fromkeys(targets):
artifacts = list(build_root.glob(f"*/bin/{target}.js"))
artifacts = [path for path in artifacts if path.with_suffix(".wasm").exists()]
if not artifacts:
continue
def rank(path: Path) -> tuple[int, str]:
name = path.parents[1].name.lower()
if module == "TechDraw" and "techdraw-native" in name:
return (0, name)
if module == "Fem" and "fem-wasm" in name:
return (0, name)
return (1 if name == "configure" else 2, name)
artifact = sorted(artifacts, key=rank)[0]
return target, str(artifact.parents[1].relative_to(ROOT))
return None
def diagnostic_source_excerpt(diagnostics: str, radius: int = 28) -> str:
"""Materialize exact compiler locations so recovery needs no search turn."""
matches = re.findall(r"(/[^:\n]+\.(?:c|cc|cpp|cxx|h|hpp)):(\d+):\d*:?\s*(?:fatal )?error:", diagnostics)
excerpts: list[str] = []
for raw_path, raw_line in matches[:2]:
path = Path(raw_path).resolve()
if not path.is_file() or not path.is_relative_to(ROOT):
continue
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
line = max(1, int(raw_line))
start = max(1, line - radius)
end = min(len(lines), line + radius)
body = "\n".join(f"{index}: {lines[index - 1]}" for index in range(start, end + 1))
excerpts.append(f"[{path.relative_to(ROOT)}:{line}]\n{body}")
return compact_text("\n\n".join(excerpts), 5200)
def diagnostic_cmake_excerpt(target: object) -> str:
"""Return the configured target block for linker-flag recovery."""
if not isinstance(target, str) or not target:
return ""
path = ROOT / "freecad-source/src/WebAssembly/FreeCADCoreProbe/CMakeLists.txt"
try:
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
except OSError:
return ""
hits = [index for index, line in enumerate(lines, 1) if target in line]
if not hits:
return ""
start = max(1, min(hits) - 2)
end = min(len(lines), max(value for value in hits if value <= min(hits) + 30) + 2)
body = "\n".join(f"{index}: {lines[index - 1]}" for index in range(start, end + 1))
return f"[{path.relative_to(ROOT)}:{min(hits)}]\n{body}"
def packet_has_unbuilt_changes(packet: dict[str, object]) -> bool:
"""Detect durable source edits that have not reached the target artifact."""
build = packet.get("build") if isinstance(packet.get("build"), dict) else {}
artifacts = [
ROOT / str(build.get(key))
for key in ("runtime", "wasm")
if isinstance(build.get(key), str) and build.get(key)
]
if not artifacts or not all(path.is_file() for path in artifacts):
return True
artifact_mtime = min(path.stat().st_mtime_ns for path in dict.fromkeys(artifacts))
frontend = build.get("kind") == "frontend"
return any(
isinstance(row, dict)
and isinstance(row.get("mtimeNs"), int)
and int(row["mtimeNs"]) > artifact_mtime
and (
frontend
or str(row.get("path") or "").startswith("freecad-source/src/WebAssembly/FreeCADCoreProbe/")
)
for row in packet.get("supportFiles", [])
)
def unbuilt_edit_matches(batch_id: object) -> bool:
try:
marker = load_json(UNBUILT_EDIT_PATH)
except (OSError, json.JSONDecodeError):
return False
return bool(batch_id and marker.get("batchId") == batch_id)
def record_unbuilt_edit(batch_id: object, module: object) -> None:
if not isinstance(batch_id, str) or not batch_id:
return
payload = {
"schema": "freecad-web-unbuilt-module-batch-edit/v1",
"batchId": batch_id,
"module": module,
"updatedAt": datetime.now(timezone.utc).isoformat(),
}
temporary = UNBUILT_EDIT_PATH.with_suffix(".tmp")
temporary.write_text(json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n", encoding="utf-8")
temporary.replace(UNBUILT_EDIT_PATH)
def compile_failure_steps(pipeline: dict, batch_id: object) -> list[dict]:
if pipeline.get("batchId") != batch_id or pipeline.get("status") != "failed":
return []
stages = {"build", "cmake-regenerate", "preflight"}
if pipeline.get("failedStage") not in stages:
return []
return [row for row in pipeline.get("steps", []) if isinstance(row, dict)
and row.get("name") in stages and row.get("exitCode") not in (None, 0)]
def runtime_pipeline_ready(batch: dict) -> bool:
if user_validation_deferred():
return False
selected = batch.get("selected", {})
if selected.get("family") != "runtime-issue" and selected.get("tier") != "business-acceptance":
return False
module = str(batch.get("selected", {}).get("module", ""))
contract = read_progress(ROOT / "docs/business-acceptance" / f"{module}.json")
command = contract.get("command", [])
if not isinstance(command, list) or len(command) < 2 or command[0] not in {"node", "python3"}:
return False
script = (ROOT / str(command[1])).resolve()
ids = {str(row[0]) for row in batch.get("records", []) if row}
return bool(ids and ids <= set(contract.get("coveredIds", [])) and script.is_relative_to(ROOT) and script.is_file())
def local_pipeline_available_during_backoff() -> bool:
batch = read_progress(MODULE_BATCH_PATH)
packet = read_progress(IMPLEMENTATION_PACKET_PATH)
if (batch.get("status") != "ready" or not runtime_pipeline_ready(batch) or batch_retry_details(batch)
or not batch.get("batchId") or packet.get("batchId") != batch["batchId"]
or packet.get("outerPipelineAvailable") is not True):
return False
previous = read_progress(MODULE_PIPELINE_PATH)
if (previous.get("batchId") == batch["batchId"]
and previous.get("status") not in {"pass", "implementation_ready", "implementation_partial", "implementation_pending_build"}):
hashes = previous.get("supportHashes")
if not hashes:
return False
for relative, expected in hashes.items():
source = (ROOT / relative).resolve()
if not source.is_relative_to(ROOT):
return False
if source.is_file() and hashlib.sha256(source.read_bytes()).hexdigest() != expected:
return True
return False
return True
def failed_pipeline_is_unchanged(batch_id: object, packet: dict[str, object]) -> bool:
"""Avoid rerunning an identical failed build before another source edit.
The compiler diagnostic remains in ``module-batch-pipeline.json`` and is
already injected into the next recovery prompt. Re-running the same
compile only recreates the identical failure and delays the corrective
turn. Compare the persisted pipeline mtime with the packet's support
files so edits made outside the scheduler still invalidate the shortcut.
"""
if not batch_id or not isinstance(packet, dict):
return False
pipeline = load_json(MODULE_PIPELINE_PATH) if MODULE_PIPELINE_PATH.exists() else {}
if (
pipeline.get("batchId") != batch_id
or pipeline.get("status") != "failed"
or pipeline.get("failedStage") not in {"build", "business-acceptance"}
):
return False
if pipeline.get("supportHashes") is not None:
current_hashes = {row["path"]: row.get("sha256") for row in packet.get("supportFiles", []) if isinstance(row, dict) and row.get("path")}
if current_hashes != pipeline["supportHashes"]:
return False
for key in ("runtime", "wasm"):
artifact = packet.get("build", {}).get(key)
if artifact:
path = ROOT / str(artifact)
try:
if path.stat().st_mtime_ns > MODULE_PIPELINE_PATH.stat().st_mtime_ns:
return False
except OSError:
return False
try:
pipeline_mtime = MODULE_PIPELINE_PATH.stat().st_mtime_ns
except OSError:
return False
for row in packet.get("supportFiles", []) if isinstance(packet.get("supportFiles"), list) else []:
if not isinstance(row, dict) or not isinstance(row.get("path"), str):
continue
path = ROOT / row["path"]
try:
if path.stat().st_mtime_ns > pipeline_mtime:
return False
except OSError:
# A missing support file must be re-evaluated by the pipeline.
return False
return True
def _build_prompt_unbounded(active: str, *, failures: list[dict[str, object]] | None = None, resumed: bool = False) -> str:
failures = unresolved_failures(active) if failures is None else failures
batch = load_json(MODULE_BATCH_PATH) if MODULE_BATCH_PATH.exists() else {}
selected = batch.get("selected") if isinstance(batch.get("selected"), dict) else {}
pipeline_state = load_json(MODULE_PIPELINE_PATH) if MODULE_PIPELINE_PATH.exists() else {}
if user_validation_deferred():
packet = read_progress(IMPLEMENTATION_PACKET_PATH)
diagnostics = [{"stage": step.get("name"), "output": compact_text(step.get("stderr") or step.get("stdout"), 2400)}
for step in pipeline_state.get("steps", []) if step.get("exitCode") not in (None, 0)
and step.get("name") in {"build", "cmake-regenerate", "preflight", "artifact-sync"}]
return (f"用户最新指令:全部功能先打通,不需要自动验证,用户使用时自己验证。工作区 {ROOT}。\n"
"本轮只完成当前模块的功能接通:React 参数/选择/任务 → 既有 Facade/Worker → FreeCAD WASM 当前文档 → 已提供 OCCT → 结构化结果。Three.js 只负责显示,OPFS 保存实际文件。\n"
"源码、资料、WASM 已提供;优先直接复用下面已有产物、代码和上轮发现,不重做 DXF、不重编译未变化 OCCT、不转为逐 API 清查。\n"
"禁止新增或运行业务验收、回归测试、浏览器测试、性能探测及历史对账;禁止为验收指纹变化重做已接通业务,禁止修改测试合同或追加验证说明。\n"
"使用工作包片段直接补缺失原生行为/接口/界面,不重复读取完整状态、日志、规则或工作包。必要补丁锚点仅定点查找。已有同模块 helper 优先,避免改共用 dispatcher。\n"
"同一业务链批量完成必要修改后交给外层增量编译、链接、资源同步和路由生成。接通记 implementation_ready/待用户验证,不写 acceptance passed,不以固定返回或 marker 冒充真实功能。\n"
"静态缺口 staticReadiness 是当前构建阻塞,不是用户验证任务。必须根据 implementationBlockers.stage 定位失败层:transport/worker 要核对真实信封、Facade、Worker 和加载的 WASM,禁止继续添加原生 marker;native 才检查真实命令语义与登记。样例、回显或 GUI-only fallback 不等于业务实现,不能直接放行。口头声明 implementation_ready 不会产生回执,禁止改错误分支或放宽条件凑数。\n"
"遇到编译首错定点修复;部分功能无法接通时保留准确缺口和下一步,不反复改无关参数校验来充数。旧源码读取预算不能阻止完成有依据的实现。\n"
+ json.dumps({"batchId": batch.get("batchId"), "selected": selected, "records": batch.get("records"),
"build": packet.get("build"), "repositoryContext": packet.get("repositoryContext"),
"diagnostics": diagnostics if pipeline_state.get("batchId") == batch.get("batchId") else [],
"staticReadiness": pipeline_state.get("staticReadiness") if pipeline_state.get("batchId") == batch.get("batchId") else None,
"repairLayerPolicy": REPAIR_LAYER_POLICY,
"implementationBlockers": implementation_blockers(packet, pipeline_state.get("staticReadiness") if pipeline_state.get("batchId") == batch.get("batchId") else None),
"retainedSourceDecisions": [{"id": row.get("apiId"), **row["semanticDecision"]}
for row in packet.get("records", []) if row.get("semanticDecision")],
"editableExcerpts": [] if resumed else packet.get("editableExcerpts", []),
"integrationRepair": packet.get("integrationRepair"),
"previousAttempt": packet.get("previousAttempt")}, ensure_ascii=False))
if selected.get("tier") == "failure-reconciliation":
packet = read_progress(IMPLEMENTATION_PACKET_PATH)
return (BUSINESS_IRON_RULE + f"\n当前是 {selected.get('module')} 模块失败记录对账,工作区 {ROOT}。\n"
"逐条审查下方最新失败。旧诊断被当前流程替代时,可运行 python3 tools/failure_reconciliation.py --review KEY --disposition superseded --reason 原因 --source 相关源码路径;不适用的运行事件使用 not_applicable。必须有真实依据,不得批量猜测关闭。\n"
"需要业务代码修复时,通过 tools/record_runtime_issue.py CAPABILITY_ID --error 描述 --failure-key KEY 将故障关联已有业务,再返回调度器立即继续该业务;不要重放旧 API gate。业务用例覆盖失败时列 resolvedFailureKeys,由 run_business_acceptance.py 验证。\n"
"允许读取当前失败关联的源码和证据;不扫描全量 API。对账工具可以直接调用,外层检查是否闭环;未知原因保留 pending。\n"
+ json.dumps(packet.get("failureReconciliation", {}), ensure_ascii=False))
failed_steps = [
row for row in pipeline_state.get("steps", [])
if isinstance(row, dict) and row.get("exitCode") not in (None, 0)
] if pipeline_state.get("batchId") == batch.get("batchId") and pipeline_state.get("status") == "failed" else []
packet = read_progress(IMPLEMENTATION_PACKET_PATH)
missing_cases = ((selected.get("family") == "runtime-issue" or selected.get("tier") == "business-acceptance")
and packet.get("batchId") == batch.get("batchId")
and isinstance(packet.get("acceptanceSetup"), dict) and not packet["acceptanceSetup"].get("ready"))
if (failed_steps or missing_cases) and not compile_failure_steps(pipeline_state, batch.get("batchId")):
return (BUSINESS_IRON_RULE + f"\n当前是 {selected.get('module')} 模块业务恢复,阶段 {pipeline_state.get('failedStage')}。\n"
"按下方实际失败修复对应业务,不要把验收配置、用例失败或证据关联缺失当作编译错误。\n"
"若 status=acceptance_required,只需为已有实现补齐声明的业务用例;若 status=issue_coverage_required,已有业务已通过,核对用例实际覆盖范围后补齐 resolvedIssueIds/resolvedFailureKeys,未覆盖的功能才需要实现。不要为证据关联而修改已通过的 native 逻辑。\n"
"合同缺少用例时,先复用 caseScripts 与已有输入接口补齐 missingIds 的实际断言;若发现真实实现缺口,再定点修复对应源码。不得仅添加 coveredIds 或原生 marker 充当业务通过。\n"
"结果必须来自真实 WASM/浏览器。复用已有代码、产物和用例,外层运行必要构建与验收,不手工写通过状态。\n"
"工作区、已有文件及 AGENTS.md 检查结果已给出,不再执行 pwd、git status 或 find AGENTS.md。若是续接回合,复用上一轮源码,直接完成修改。\n"
+ json.dumps({"diagnostics": [{"stage": step.get("name"), "output": compact_text(step.get("stderr") or step.get("stdout"), 3000)} for step in failed_steps],
"acceptanceContract": packet.get("acceptanceContract"), "acceptanceSetup": packet.get("acceptanceSetup"), "build": packet.get("build"),
"repositoryContext": packet.get("repositoryContext"),
"records": [{"id": row.get("apiId"), "observedIssues": row.get("observedIssues")} for row in packet.get("records", [])],
"editableExcerpts": [] if resumed else packet.get("editableExcerpts", [])}, ensure_ascii=False))
if compile_failure_steps(pipeline_state, batch.get("batchId")):
step = failed_steps[0]
diagnostics = str(step.get("stderr") or step.get("stdout") or "")
marker = "--- extracted diagnostics ---"
if marker in diagnostics:
diagnostics = diagnostics.split(marker, 1)[1].strip()
source_excerpt = diagnostic_source_excerpt(diagnostics)
cmake_excerpt = diagnostic_cmake_excerpt(pipeline_state.get("target"))
packet = load_json(IMPLEMENTATION_PACKET_PATH) if IMPLEMENTATION_PACKET_PATH.exists() else {}
excerpts = packet.get("editableExcerpts", []) if packet.get("batchId") == batch.get("batchId") else []
return (
"当前是确定性的模块编译首错恢复,不是功能分析或验证。禁止重读任务文档、历史日志、API 清单和完整源码。\n"
f"工作区固定为 {ROOT};apply_patch 必须使用下方方括号标题中的完整相对路径或其绝对路径,禁止省略 freecad-source/ 前缀。\n"
f"batchId={batch.get('batchId')} module={selected.get('module')} target={pipeline_state.get('target')} stage={step.get('name')}。\n"
"只修下面第一组编译/链接诊断;优先直接 apply_patch。已提供故障源码和 target 锚点时禁止 rg/find/sed,编辑后立即返回;不要自行构建或验证。\n"
f"diagnostics:\n{compact_text(diagnostics, 4200)}\n"
f"failureSource:\n{source_excerpt or 'not available'}\n"
f"targetCMake:\n{compact_text(cmake_excerpt, 2600) or 'not needed'}\n"
f"fallbackEditableExcerpts={json.dumps(excerpts, ensure_ascii=False, separators=(',', ':'))}\n"
"保持现有 fc_web_dispatch_json、Worker、React 和模块 WASM 架构,不扩大功能范围,不回退到 API 移植。"
)
if (resumed and batch.get("status") == "ready" and selected
and (selected.get("tier") in {"format-workflow", "product-integration"}
or str(selected.get("family", "")).startswith("module-business-"))):
record_ids = [str(row[0]) for row in batch.get("records", []) if isinstance(row, list) and row]
return (
"继续上一轮同一模块业务包;上一轮的源码读取和 editable excerpts 已保留,禁止重新读取、搜索或解释。"
f" batchId={batch.get('batchId')} module={selected.get('module')} family={selected.get('family')}"
f" records={json.dumps(record_ids, ensure_ascii=False, separators=(',', ':'))}。\n"
"现在直接集中 apply_patch 完成模块 fc_web_dispatch_json、Facade/Worker 与格式或 React 工作流;"
"不得运行 build、tsc、test、gate、validate、Chrome 或 git。修改完成立即返回,外层负责编译和回执。"
)
if resumed and batch.get("status") == "ready" and selected:
record_ids = [
str(row[0]) for row in batch.get("records", [])
if isinstance(row, list) and row
]
pipeline_state = load_json(MODULE_PIPELINE_PATH) if MODULE_PIPELINE_PATH.exists() else {}
readiness = pipeline_state.get("staticReadiness") if pipeline_state.get("batchId") == batch.get("batchId") and isinstance(pipeline_state.get("staticReadiness"), dict) else {}
rejected_markers = readiness.get("rejectedMarkers", [])
return (
"继续上一轮尚未完成的同一功能批次,不要重新分析或重新检索。"
f" batchId={batch.get('batchId')} module={selected.get('module')}"
f" family={selected.get('family')} records={json.dumps(record_ids, ensure_ascii=False, separators=(',', ':'))}.\n"
"上一轮的完整工作包、editable excerpts 和工具输出都保留在本线程。"
"直接依据它们实施 FreeCAD/WASM native dispatcher、统一 cmd+args Worker/Facade 和 React 功能链。"
"已有锚点足够时立即 apply_patch;确实缺少补丁锚点时最多再做一次定点读取,然后编辑。"
f"静态验收拒绝原因={json.dumps(rejected_markers, ensure_ascii=False, separators=(',', ':'))}。"
"若 marker 被拒绝,必须在同名命令专属分支内部明确写入 status=exposed;共享尾部状态不算专属实现。"
"禁止重复 rg/find/ls/sed、禁止重读状态和历史日志、禁止只报告计划。"
"功能结果必须来自 FreeCAD 源码编译的模块 WASM,不能以 JS 固定结果替代。"
"外层负责增量 build/sync/receipt,本轮不要运行构建、gate、验证、hash 或 git 检查;完成编辑后立即返回。"
)
batch_lines = []
if batch.get("status") == "ready" and selected:
module = str(selected.get("module") or "")
tier = str(selected.get("tier") or "")
slug = re.sub(r"[^a-z0-9]+", "", module.lower())
probe_root = ROOT / "freecad-source/src/WebAssembly/FreeCADCoreProbe"
candidates: list[Path] = [ROOT / "tools/wasm-probe-runtime.mjs"]
probe_sources = [
path for path in sorted(probe_root.glob(f"*{module}*Probe.cpp"))
if path.stem.lower().startswith(module.lower())
and not (module == "Part" and path.stem.startswith("PartDesign"))
and not (module == "Mesh" and path.stem.startswith("MeshPart"))
][:3]
candidates.extend(probe_sources)
if tier not in {"format-workflow", "product-integration"}:
for candidate in (
ROOT / f"web/src/{slug}-python-family-facade.ts",
ROOT / f"web/src/{slug}-python.worker.ts",
):
if candidate.exists():
candidates.append(candidate)
gate_candidates = [] if implementation_first_enabled() else list((ROOT / "tools").glob(f"run_p6_{slug}*module_batch_gate.mjs"))
generic_gate_name = f"run_p6_{slug}_module_batch_gate.mjs"
family_token = str(selected.get("family") or "").replace("-", "_")
gate_candidates.sort(key=lambda path: (0 if path.name == generic_gate_name else 1 if family_token and family_token in path.name else 2, path.name))
candidates.extend(gate_candidates[:5])
candidate_text = ", ".join(str(path.relative_to(ROOT)) for path in dict.fromkeys(candidates))
probe_hint = module_probe_hint(module, probe_sources)
record_lines = []
for row in batch.get("records", []):
if not isinstance(row, list) or not row:
continue
api_id = str(row[0])
name = str(row[1]) if len(row) > 1 else ""
source = str(row[3]) if len(row) > 3 else ""
record_lines.append(f" - {api_id} | {name} | {source}")
batch_lines = [
"\n已预检批次工作包(无需重读状态/接续文档):",
f"- batchId={batch.get('batchId')} module={selected.get('module')} tier={selected.get('tier')} family={selected.get('family')} records={selected.get('recordCount')}",
f"- reuse candidates: {candidate_text or 'use manifest-scoped fast_source_search only'}",
f"- build target/tree: {probe_hint[0]} / {probe_hint[1]}" if probe_hint else "- build target/tree: resolve once with build_web_module_artifacts.py --dry-run --json",
f"- exact preflight: python3 tools/check_web_link_closure.py {probe_hint[1]} {probe_hint[0]} --json" if probe_hint else "- exact preflight: use the resolved build target/tree; never call the checker without arguments",
"- selected records:",
*record_lines,
"批次内共享同一 module native/dispatcher/Facade/Worker;记录只是功能覆盖锚点,不建立逐 API 实现或 gate。",
]
packet = load_json(IMPLEMENTATION_PACKET_PATH) if IMPLEMENTATION_PACKET_PATH.exists() else {}
if packet.get("batchId") == batch.get("batchId"):
support = [
{"path": row.get("path"), "sha256": str(row.get("sha256") or "")[:16], "bytes": row.get("bytes")}
for row in packet.get("supportFiles", []) if isinstance(row, dict)
]
source_records = [
{
"apiId": row.get("apiId"),
"name": row.get("name"),
"source": row.get("source"),
"line": row.get("excerptLine"),
"excerpt": row.get("excerpt"),
"nativeMatches": row.get("nativeMatches"),
"implementationGaps": row.get("implementationGaps"),
"runtimeRouteBlock": row.get("runtimeRouteBlock"),
"observedIssues": row.get("observedIssues"),
"previousAttempt": row.get("previousAttempt"),
}
for row in packet.get("records", []) if isinstance(row, dict)
]
batch_lines = [line for line in batch_lines if line != "- selected records:" and not line.startswith(" - ")]
batch_lines.extend([
"- deterministic implementation packet: support files are fingerprinted and facade/gate structure is prechecked; do not reread them unless editing a named line.",
"- support checks: " + json.dumps(packet.get("supportChecks", {}), ensure_ascii=False, separators=(",", ":")),
"- support fingerprints: " + json.dumps(support, ensure_ascii=False, separators=(",", ":")),
"- inspected repository instructions (do not search again): " + json.dumps(packet.get("repositoryContext", {}), ensure_ascii=False, separators=(",", ":")),
"- native entry points: " + json.dumps(packet.get("nativeEntryPoints", []), ensure_ascii=False, separators=(",", ":")),
"- editable excerpts: " + json.dumps(packet.get("editableExcerpts", []), ensure_ascii=False, separators=(",", ":")),
"- stable support omitted from prompt: " + json.dumps(packet.get("omittedStableSupport", []), ensure_ascii=False, separators=(",", ":")),
"- official source excerpts: " + json.dumps(source_records, ensure_ascii=False, separators=(",", ":")),
"- shared business recovery: " + json.dumps(packet.get("moduleRecovery", {}), ensure_ascii=False, separators=(",", ":")),
"- application integration repair: " + json.dumps(packet.get("integrationRepair"), ensure_ascii=False, separators=(",", ":")),
"- module failure reconciliation: " + json.dumps(packet.get("failureReconciliation", {}), ensure_ascii=False, separators=(",", ":")),
])
if packet.get("outerPipelineAvailable") is True:
if implementation_first_enabled():
batch_lines.append(
"- implementation-first outer pipeline enabled: only implement the named user capability/runtime issue. "
"Do not run preflight, build, artifact sync, focused gate, validate, evidence/hash/git checks; "
"the outer scheduler will build/sync and persist an implementation receipt without changing API disposition."
)
else:
batch_lines.append(
"- outer pipeline enabled: your only job is native/source implementation judgment and edits. "
"Do not run preflight, build, artifact sync, focused gate, validate, evidence/hash/git checks; "
"return immediately after the edit, or immediately without edits when the supplied source proves a real boundary."
)
pipeline_state = load_json(MODULE_PIPELINE_PATH) if MODULE_PIPELINE_PATH.exists() else {}
if pipeline_state.get("batchId") == batch.get("batchId") and pipeline_state.get("status") == "failed":
failed_steps = [row for row in pipeline_state.get("steps", []) if isinstance(row, dict) and row.get("exitCode") not in (None, 0)]
batch_lines.append(
"- previous outer pipeline first error (fix this only): "
+ json.dumps(failed_steps[:1], ensure_ascii=False, separators=(",", ":"))
)
elif pipeline_state.get("batchId") == batch.get("batchId") and pipeline_state.get("status") in {"implementation_partial", "implementation_incomplete"}:
receipt = pipeline_state.get("implementationReceipt") if isinstance(pipeline_state.get("implementationReceipt"), dict) else {}
readiness = pipeline_state.get("staticReadiness") if isinstance(pipeline_state.get("staticReadiness"), dict) else {}
batch_lines.append(
"- previous implementation was partial: "
f"ready={receipt.get('recordCount', readiness.get('ready', 0))} "
f"remaining={receipt.get('remainingRecordCount', readiness.get('missing', selected.get('recordCount', 0)))} "
f"missingIds={json.dumps(readiness.get('missingIds', []), ensure_ascii=False, separators=(',', ':'))}. "
f"rejectedMarkers={json.dumps(readiness.get('rejectedMarkers', []), ensure_ascii=False, separators=(',', ':'))}. "
"Native metadata/classification alone is not a user function. Add the exact desktop command Web route, "
"a Worker branch, React-visible command entry, and real Document/geometry/view behavior for every remaining record."
)
business_turn = bool(
implementation_first_enabled()
and selected
and (selected.get("tier") != "feature-workflow" or str(selected.get("family", "")).startswith("module-business-"))
)
active_prompt = MODULE_BUSINESS_PROMPT if business_turn else LEAN_PROMPT
active_prompt = SOURCE_WINDOW_POLICY + "\n" + active_prompt
if resumed:
active_prompt += (
"\n同一模块连续会话已恢复。复用上一批已经读取的 native probe、Facade、Worker、gate 和构建知识;"
"本轮只看新 record/source excerpts。除非 support fingerprint 与记忆不符,禁止再次读取共享文件。"
)
if not failures or not REPLAY_FAILURES_DURING_IMPLEMENTATION:
suffix = (
"\n历史失败账本仅保留原始证据;当前为实现阶段,除非某条失败直接阻断当前批次,禁止重放历史失败、全量 gate 或旧诊断命令。"
if failures else ""
)
return active_prompt + suffix + "\n" + "\n".join(batch_lines) if batch_lines else active_prompt + suffix
lines = [
active_prompt,
"\n本回合必须优先处理以下调度器失败账本条目;先定点复现并修复,成功重放后再继续当前卡:",
]
lines.extend(batch_lines)
for entry in failures[:MAX_FAILURE_REPLAY]:
lines.append(
f"- failure={entry.get('key')} count={entry.get('count')} exit={entry.get('lastExitCode')} "
f"command={entry.get('command')} firstError={entry.get('firstError')}"
)
lines.append("修复后让相同命令成功退出;调度器会自动把该命令标记 resolved。不得删除失败证据或把未解决条目标成通过。")
return "\n".join(lines)
def _prompt_bytes(value: str) -> int:
return len(value.encode("utf-8"))
def bound_prompt_context(prompt: str, limit: int = PROMPT_BYTE_LIMIT) -> str:
"""Keep a useful, deterministic prompt when a packet grows unexpectedly.
Implementation packets contain large source excerpts and historical
diagnostics. Truncating by Python characters can split a UTF-8 sequence
and, more importantly, can remove the current batch identity. Preserve a
bounded instruction prefix and the current-batch tail, then add an explicit
marker so the agent knows that omitted history must not be reconstructed by
broad repository searches.
"""
if not isinstance(prompt, str):
prompt = str(prompt)
limit = max(4_000, int(limit))
if _prompt_bytes(prompt) <= limit:
return prompt
marker = (
"\n\n[CONTEXT_COMPACTED by unattended scheduler: omitted stable excerpts and "
"historical diagnostics; use only the current batch identifiers and "
"provided file anchors, do not rescan the repository.]\n\n"
)
marker_bytes = _prompt_bytes(marker)
available = max(1_000, limit - marker_bytes)
# Keep more of the prefix because it contains the operating rules, while
# retaining enough tail for batchId/module/records and the latest error.
prefix_budget = int(available * 0.58)
suffix_budget = available - prefix_budget
def take_utf8(text: str, budget: int, from_end: bool = False) -> str:
lines = text.splitlines(keepends=True)
selected, used = [], 0
for line in reversed(lines) if from_end else lines:
size = _prompt_bytes(line)
if used + size > budget:
break
selected.append(line)
used += size
return "".join(reversed(selected) if from_end else selected)
prefix = take_utf8(prompt, prefix_budget)
suffix = take_utf8(prompt, suffix_budget, from_end=True)
compacted = prefix + marker + suffix
return compacted
def build_prompt(active: str, *, failures: list[dict[str, object]] | None = None, resumed: bool = False) -> str:
"""Build a bounded prompt for an unattended model turn."""
prompt = _build_prompt_unbounded(active, failures=failures, resumed=resumed)
if _prompt_bytes(prompt) <= PROMPT_BYTE_LIMIT:
return prompt
batch = read_progress(MODULE_BATCH_PATH)
packet = read_progress(IMPLEMENTATION_PACKET_PATH)
if batch.get("batchId") and packet.get("batchId") == batch.get("batchId"):
return compact_packet(batch, packet, read_progress(MODULE_PIPELINE_PATH), PROMPT_BYTE_LIMIT, IMPLEMENTATION_PACKET_PATH)
return bound_prompt_context(prompt)
def load_json(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def load_session_cache() -> dict[str, object]:
try:
value = json.loads(SESSION_CACHE_PATH.read_text(encoding="utf-8"))
return value if isinstance(value, dict) else {}
except (OSError, json.JSONDecodeError):
return {}
def implementation_scope(packet: dict) -> str:
value = {"paths": sorted(row.get("path", "") for row in packet.get("supportFiles", [])),
"integration": packet.get("integrationRepair"),
"recoveryLevel": packet.get("moduleRecovery", {}).get("recoveryLevel", 0)}
return hashlib.sha256(json.dumps(value, sort_keys=True).encode()).hexdigest()
def resumable_session(module: str, batch_id: object = None, *, same_batch_only: bool = False) -> dict[str, object] | None:
if not SESSION_REUSE_ENABLED or not module:
return None
session = load_session_cache()
if session.get("module") != module or not isinstance(session.get("threadId"), str):
return None
if same_batch_only and session.get("lastBatchId") != batch_id:
return None
packet = read_progress(IMPLEMENTATION_PACKET_PATH)
if packet.get("batchId") == batch_id and packet.get("module") == module and session.get("scopeFingerprint") != implementation_scope(packet):
return None
if "sourceSnapshot" in session and session["sourceSnapshot"] != source_snapshot(ROOT, packet):
return None
reuse_limit = min(SESSION_REUSE_LIMIT, FEATURE_SESSION_REUSE_LIMIT) if same_batch_only else SESSION_REUSE_LIMIT
if int(session.get("turns", 0) or 0) >= reuse_limit:
return None
if int(session.get("predictedNextInputTokens", session.get("lastInputTokens", 0)) or 0) >= SESSION_INPUT_TOKEN_LIMIT:
return None
return session
def save_session_cache(module: str, batch_id: object, metadata: dict[str, object], previous: dict[str, object] | None, successful: bool) -> None:
thread_id = metadata.get("threadId")
# Never carry a provider/network failure into the next attempt. A resumed
# thread replays its prior context and is precisely what can turn a
# transient overload into a persistent overload loop.
provider_failed = bool(metadata.get("streamFailed") or metadata.get("providerFailureReason"))
if provider_failed:
SESSION_CACHE_PATH.unlink(missing_ok=True)
metadata["contextRotatedAfterProviderFailure"] = True
return
if not successful or not isinstance(thread_id, str) or not thread_id:
if (previous and metadata.get("sessionResumed") is True
and previous.get("module") == module and previous.get("lastBatchId") == batch_id
and thread_id in {None, previous.get("threadId")}
and metadata.get("commandsStarted") == 0 and metadata.get("fileChanges") == 0
and not metadata.get("sessionCorrupt") and not metadata.get("patchConflicts")):
metadata["previousSessionPreserved"] = True
return
if SESSION_CACHE_PATH.exists():
SESSION_CACHE_PATH.unlink()
return
usage = metadata.get("usage") if isinstance(metadata.get("usage"), dict) else {}
same_session = bool(previous and previous.get("threadId") == thread_id)
turns = int(previous.get("turns", 0) or 0) + 1 if same_session else 1
request_usage = metadata.get("requestUsage") or {}
measured = request_usage.get("complete") is True and type(request_usage.get("lastInputTokens")) is int
input_tokens = int(request_usage["lastInputTokens"] if measured else usage.get("input_tokens", 0) or 0)
previous_input_tokens = int(previous.get("lastInputTokens", 0) or 0) if same_session and previous else 0
input_delta = max(0, input_tokens - previous_input_tokens) if same_session else input_tokens
if input_delta == 0:
input_delta = max(1, input_tokens // max(1, turns))
payload = {
"schema": "freecad-web-scheduler-session-cache/v1",
"module": module,
"threadId": thread_id,
"turns": turns,
"limit": SESSION_REUSE_LIMIT,
"lastBatchId": batch_id,
"scopeFingerprint": metadata.get("scopeFingerprint"),
"lastInputTokens": input_tokens,
"contextMeasurement": "per-request" if measured else "aggregate-fallback",
"aggregateInputTokens": int(usage.get("input_tokens", 0) or 0),
"lastInputDelta": input_delta,
"predictedNextInputTokens": input_tokens + input_delta,
"lastCachedInputTokens": int(usage.get("cached_input_tokens", 0) or 0),
"updatedAt": datetime.now(timezone.utc).isoformat(),
}
packet = read_progress(IMPLEMENTATION_PACKET_PATH)
if packet.get("batchId") == batch_id and packet.get("module") == module:
payload["sourceSnapshot"] = source_snapshot(ROOT, packet)
payload["scopeFingerprint"] = implementation_scope(packet)
SESSION_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
temporary = SESSION_CACHE_PATH.with_suffix(".tmp")
temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
temporary.replace(SESSION_CACHE_PATH)
def terminal() -> bool:
if user_validation_deferred():
return implementation_scope_complete()
if implementation_first_enabled():
from business_acceptance import business_complete
return business_complete()
state = load_json(STATE)
counts = state.get("counts", {})
return not state.get("active_task") and all(counts.get(key, 0) == 0 for key in ("planned", "in_progress", "blocked"))
def set_phase(phase: str) -> None:
checkpoint = load_json(CHECKPOINT)
checkpoint["phase"] = phase
checkpoint["lastCheckpoint"] = phase
checkpoint["lastTransition"] = datetime.now(timezone.utc).isoformat()
CHECKPOINT.write_text(json.dumps(checkpoint, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def run_local(*args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run([sys.executable, *args], cwd=ROOT, text=True, capture_output=True, check=False)
def replan_cooled_batch(batch: dict, publish: Callable[..., None]) -> bool:
cooling = batch_retry_details(batch)
if not cooling:
return False
publish(status="replanning_cooled_batch", childPid=None, rejectedBatchId=batch.get("batchId"), **{key: cooling[key] for key in ("deferredRecords", "retryAt")})
result = run_local(planner_script())
still_cooled = batch_retry_details(read_progress(MODULE_BATCH_PATH))
repaired = result.returncode == 0 and not still_cooled
publish(cooldownReplanStatus="pass" if repaired else "failed",
cooldownReplanOutput=compact_text(result.stdout or result.stderr, 500))
# A failed replan must never fall through to running the old batch.
if not repaired:
publish(status="cooldown_replan_backoff", childPid=None, retryAt=time.time() + 30)
time.sleep(30)
return True
def planner_script() -> str:
return "tools/prepare_implementation_business_batch.py" if implementation_first_enabled() else "tools/prepare_full_parity_module_batch.py"
def write_log(cycle: int, content: str) -> Path:
LOG_DIR.mkdir(parents=True, exist_ok=True)
path = LOG_DIR / f"cycle-{cycle:06d}.log"
path.write_text(content, encoding="utf-8")
return path
def append_log(path: Path, content: str) -> None:
with path.open("a", encoding="utf-8") as stream:
stream.write(content)
def next_cycle_number() -> int:
LOG_DIR.mkdir(parents=True, exist_ok=True)
numbers = []
for path in LOG_DIR.glob("cycle-*.log"):
try:
numbers.append(int(path.stem.removeprefix("cycle-")))
except ValueError:
continue
# Deterministic pipeline cycles need not create a model log. Their IDs
# still own timing/progress records and must survive a process restart.
status = read_progress(HEARTBEAT_PATH)
timing = read_progress(TIMING_SUMMARY_PATH)
progress = read_progress(PROGRESS_PATH)
for row in [status, *timing.get("cycles", []), *progress.get("history", [])]:
if isinstance(row, dict) and isinstance(row.get("cycle"), int):
numbers.append(row["cycle"])
return max(numbers, default=0) + 1
def write_status(**values: object) -> None:
"""Publish a small, replace-atomically observable scheduler heartbeat."""
temp_path = HEARTBEAT_PATH.with_suffix(".tmp")
temp_path.write_text(
json.dumps(values, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
temp_path.replace(HEARTBEAT_PATH)
def compact_text(value: object, limit: int = 800) -> str:
text = " ".join(str(value or "").split())
return text if len(text) <= limit else text[: limit - 3] + "..."
def is_stream_disconnect_error(value: object) -> bool:
"""Identify model-service failures emitted outside tool command results."""
message = compact_text(value, 1200).lower()
return any(token in message for token in (
"concurrency limit exceeded", "servers are currently overloaded", "server is overloaded",
"stream disconnected before completion", "stream error", "rate limit exceeded",
"too many requests", "upstream request failed",
))
def provider_wait_expired(has_error: bool, command_active: bool, idle_seconds: float) -> bool:
return has_error and not command_active and idle_seconds >= PROVIDER_NO_PROGRESS_SECONDS
def record_scheduler_wait(cycle: int, seconds: float) -> None:
"""Account for retry sleep separately from model and tool execution."""
summary = read_progress(TIMING_SUMMARY_PATH)
record = next((row for row in summary.get("cycles", []) if row.get("cycle") == cycle), None)
if record is None:
return
waits = record.setdefault("waitingTiming", {})
waits["scheduler/provider-backoff"] = round(waits.get("scheduler/provider-backoff", 0.0) + seconds, 3)
temporary = TIMING_SUMMARY_PATH.with_suffix(".tmp")
temporary.write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
temporary.replace(TIMING_SUMMARY_PATH)
def first_activity_budget(provider: dict) -> float:
# Repeatedly cancelling the same cold request sooner cannot establish
# whether a slow provider would have answered. Allow a bounded longer try.
failures = int(provider.get("consecutiveFailures", 0) or 0)
if "no first command" not in str(provider.get("reason", "")):
failures = 0
return min(FIRST_ACTIVITY_MAX_SECONDS, FIRST_ACTIVITY_TIMEOUT_SECONDS * 2 ** min(failures, 3))
def first_activity_limit(provider: dict, transport_budget: float = 0.0) -> float:
"""Bound silent gateway waits without changing active tool timeouts."""
budget = first_activity_budget(provider)
if transport_budget > 0:
# The HTTP transport can retain a request for 130s (two 60s header
# attempts), but a turn with no command or edit is not productive.
# After a recorded silent failure, the normal exponential budget is
# allowed to grow up to the transport budget for recovery attempts.
budget = max(budget, min(transport_budget, FIRST_ACTIVITY_TRANSPORT_CAP_SECONDS))
return min(budget, transport_budget) if transport_budget > 0 else budget
def transport_can_continue(state: dict, silence_seconds: float, local_limit: float) -> bool:
# CLI-level retries share this silence budget; a new HTTP request cannot
# restart it indefinitely before the CLI emits useful activity.
# responseBudgetSeconds describes the transport's upper bound for an
# individual request. It must not override the scheduler's local
# first-activity budget, otherwise a retrying gateway can keep a turn
# silent for the full 130s transport budget before we rotate context.
if state.get("canWait") is not True:
return False
# Once upstream headers or a stream are active, the transport itself is
# receiving useful progress and its idle/read budget is authoritative.
# A request that has not produced headers still shares the scheduler's
# absolute silence budget across all gateway retries.
if state.get("phase") in {"headers", "streaming"}:
return True
return silence_seconds <= local_limit
def recovery_turn_limits(module: str, business_turn: bool, batch: dict[str, object]) -> tuple[int, int, float]:
"""Bound repeated no-progress business turns while preserving normal turns."""
if not business_turn or not module:
return MODULE_BUSINESS_SOURCE_READS, MODULE_BUSINESS_SOURCE_READS_HARD, MODULE_BUSINESS_IDLE_LIMIT_SECONDS
state = read_progress(PROGRESS_PATH).get("modules", {}).get(module, {})
streak = int(state.get("consecutiveNoProgress", 0) or 0)
recovery = int(state.get("recoveryLevel", 0) or 0)
pipeline = load_json(MODULE_PIPELINE_PATH) if MODULE_PIPELINE_PATH.exists() else {}
readiness = pipeline.get("staticReadiness", {}) if pipeline.get("module") == module and pipeline.get("batchId") == batch.get("batchId") else {}
all_missing = bool(readiness.get("selected")) and int(readiness.get("ready", 0) or 0) == 0 and int(readiness.get("missing", 0) or 0) == int(readiness.get("selected", 0) or 0)
if streak >= 2 or recovery > 0 or all_missing:
return 2, 4, min(MODULE_BUSINESS_IDLE_LIMIT_SECONDS, 45.0)
return MODULE_BUSINESS_SOURCE_READS, MODULE_BUSINESS_SOURCE_READS_HARD, MODULE_BUSINESS_IDLE_LIMIT_SECONDS
def exploration_expired(elapsed: float, command_active: bool, edits: int, commands: list[dict], limit: float) -> bool:
if elapsed < limit or command_active or edits:
return False
return not any(row.get("phase") != "source/read" and re.search(r"cmake\s+--build|run_business_acceptance|run_module_batch_pipeline|unittest|pytest|\btsc\b|vite\s+build|business-cases", str(row.get("command", ""))) for row in commands)
def retain_turn_context(*, business_turn: bool, feature_turn: bool, pipeline_eligible: bool,
agent_successful: bool, session_clean: bool, retained_business_context: bool) -> bool:
if business_turn:
return (agent_successful and session_clean) or retained_business_context
return pipeline_eligible and session_clean if feature_turn else agent_successful and session_clean
def update_timing_summary(
cycle: int,
active: str,
turn_seconds: float,
phase_totals: dict[str, float],
command_rows: list[dict[str, object]],
agent_gap_rows: list[dict[str, object]] | None = None,
redundancy: dict[str, float | int] | None = None,
activity: dict[str, int] | None = None,
usage: dict[str, int] | None = None,
command_count: int | None = None,
preparation: dict[str, float] | None = None,
timed_out: bool = False,
stream_failed: bool = False,
budget_exceeded: bool = False,
agent_idle_stopped: bool = False,
) -> None:
"""Persist a bounded timing index so status queries avoid rescanning logs."""
try:
previous = json.loads(TIMING_SUMMARY_PATH.read_text(encoding="utf-8"))
except (FileNotFoundError, json.JSONDecodeError, OSError):
previous = {}
records = previous.get("cycles", []) if isinstance(previous, dict) else []
if not isinstance(records, list):
records = []
try:
batch_manifest = json.loads(MODULE_BATCH_PATH.read_text(encoding="utf-8"))
except (FileNotFoundError, json.JSONDecodeError, OSError):
batch_manifest = {}
selected = batch_manifest.get("selected", {}) if isinstance(batch_manifest.get("selected"), dict) else {}
step_stats: dict[str, dict[str, float | int]] = {}
for row in [*command_rows, *(agent_gap_rows or [])]:
phase = str(row.get("phase") or "other")
seconds = float(row.get("seconds", 0.0) or 0.0)
stats = step_stats.setdefault(phase, {"count": 0, "seconds": 0.0, "maxSeconds": 0.0, "failed": 0, "redundant": 0})
stats["count"] = int(stats["count"]) + 1
stats["seconds"] = float(stats["seconds"]) + seconds
stats["maxSeconds"] = max(float(stats["maxSeconds"]), seconds)
stats["failed"] = int(stats["failed"]) + int(row.get("exitCode") not in (None, 0))
stats["redundant"] = int(stats["redundant"]) + int(bool(row.get("redundant")))
record = {
"cycle": cycle,
"activeTask": active,
"turnSeconds": round(turn_seconds, 3),
"commandSeconds": round(sum(float(row.get("seconds", 0.0)) for row in command_rows), 3),
"timedCommands": len(command_rows),
"commands": int(command_count if command_count is not None else len(command_rows)),
"timedOut": timed_out,
"streamFailed": stream_failed,
"commandBudgetExceeded": budget_exceeded,
"agentIdleStopped": agent_idle_stopped,
# turnSeconds starts when the Codex process is spawned. Preparation
# happens before that boundary and must not hide unaccounted turn time.
"accountedSeconds": round(sum(float(value) for value in phase_totals.values()), 3),
"preparationSeconds": round(sum(float(value) for value in (preparation or {}).values()), 3),
"cycleSeconds": round(turn_seconds + sum(float(value) for value in (preparation or {}).values()), 3),
"batch": {
"id": batch_manifest.get("batchId"),
"module": selected.get("module"),
"tier": selected.get("tier"),
"family": selected.get("family"),
"records": selected.get("recordCount", 0),
},
"phaseTiming": {key: round(float(value), 3) for key, value in phase_totals.items()},
"stepStats": {
key: {name: round(float(value), 3) if isinstance(value, float) else value for name, value in stats.items()}
for key, stats in step_stats.items()
},
"slowestCommands": sorted(command_rows, key=lambda row: float(row.get("seconds", 0.0)), reverse=True)[:10],
"slowestAgentGaps": sorted(agent_gap_rows or [], key=lambda row: float(row.get("seconds", 0.0)), reverse=True)[:10],
"redundancy": dict(redundancy or {}),
"activity": dict(activity or {}),
"usage": dict(usage or {}),
}
if preparation:
record["preparationTiming"] = {key: round(float(value), 3) for key, value in preparation.items()}
records = [item for item in records if not (isinstance(item, dict) and item.get("cycle") == cycle)]
records.append(record)
records = [item for item in records if isinstance(item, dict)][-50:]
phases: dict[str, float] = {}
slowest: list[dict[str, object]] = []
slowest_agent_gaps: list[dict[str, object]] = []
redundancy_totals: dict[str, float] = {}
activity_totals: dict[str, int] = {}
usage_totals: dict[str, int] = {}
for item in records:
for phase, seconds in (item.get("phaseTiming", {}) if isinstance(item.get("phaseTiming"), dict) else {}).items():
phases[phase] = phases.get(phase, 0.0) + float(seconds)
for phase, seconds in (item.get("preparationTiming", {}) if isinstance(item.get("preparationTiming"), dict) else {}).items():
phases[f"preparation/{phase}"] = phases.get(f"preparation/{phase}", 0.0) + float(seconds)
slowest.extend(item.get("slowestCommands", []) if isinstance(item.get("slowestCommands"), list) else [])
slowest_agent_gaps.extend(item.get("slowestAgentGaps", []) if isinstance(item.get("slowestAgentGaps"), list) else [])
for name, value in (item.get("redundancy", {}) if isinstance(item.get("redundancy"), dict) else {}).items():
if isinstance(value, (int, float)):
redundancy_totals[str(name)] = redundancy_totals.get(str(name), 0.0) + float(value)
for name, value in (item.get("activity", {}) if isinstance(item.get("activity"), dict) else {}).items():
if isinstance(value, int):
activity_totals[str(name)] = activity_totals.get(str(name), 0) + value
for name, value in (item.get("usage", {}) if isinstance(item.get("usage"), dict) else {}).items():
if isinstance(value, int):
usage_totals[str(name)] = usage_totals.get(str(name), 0) + value
summary = {
"schema": "freecad-web-scheduler-timing/v2",
"updatedAt": datetime.now(timezone.utc).isoformat(),
"window": len(records),
"cycles": records,
"totals": {
"turnSeconds": round(sum(float(item.get("turnSeconds", 0.0)) for item in records), 3),
"preparationSeconds": round(sum(float(item.get("preparationSeconds", 0.0)) for item in records), 3),
"cycleSeconds": round(sum(float(item.get("cycleSeconds", item.get("turnSeconds", 0.0))) for item in records), 3),
"commandSeconds": round(sum(float(item.get("commandSeconds", 0.0)) for item in records), 3),
"timedCommands": sum(int(item.get("timedCommands", 0)) for item in records),
"unmeasuredCommands": sum(max(0, int(item.get("commands", 0)) - int(item.get("timedCommands", 0))) for item in records),
"unaccountedSeconds": round(sum(max(0.0, float(item.get("turnSeconds", 0.0)) - float(item.get("accountedSeconds", item.get("commandSeconds", 0.0)))) for item in records if int(item.get("timedCommands", 0)) > 0), 3),
},
"phaseTiming": {key: round(value, 3) for key, value in sorted(phases.items(), key=lambda pair: pair[1], reverse=True)},
"slowestCommands": sorted(slowest, key=lambda row: float(row.get("seconds", 0.0)), reverse=True)[:10],
"slowestAgentGaps": sorted(slowest_agent_gaps, key=lambda row: float(row.get("seconds", 0.0)), reverse=True)[:10],
"redundancy": {key: round(value, 3) for key, value in redundancy_totals.items()},
"activity": activity_totals,
"usage": usage_totals,
}
temporary = TIMING_SUMMARY_PATH.with_suffix(".tmp")
temporary.write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
temporary.replace(TIMING_SUMMARY_PATH)
def attach_post_pipeline_timing(cycle: int, pipeline: dict[str, object], wall_seconds: float) -> None:
"""Attach deterministic post-edit work to the already flushed turn record."""
try:
summary = json.loads(TIMING_SUMMARY_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return
records = summary.get("cycles") if isinstance(summary.get("cycles"), list) else []
record = next((row for row in records if isinstance(row, dict) and row.get("cycle") == cycle), None)
if record is None:
return
phases = {
f"post-pipeline/{step.get('name', 'other')}": float(step.get("seconds", 0.0) or 0.0)
for step in pipeline.get("steps", []) if isinstance(step, dict)
}
record["postPipelineSeconds"] = round(wall_seconds, 3)
record["postPipelineStatus"] = pipeline.get("status")
record["postPipelineTiming"] = {key: round(value, 3) for key, value in phases.items()}
record["effectiveCycleSeconds"] = round(
float(record.get("turnSeconds", 0.0) or 0.0)
+ float(record.get("preparationSeconds", 0.0) or 0.0)
+ wall_seconds,
3,
)
summary["updatedAt"] = datetime.now(timezone.utc).isoformat()
temporary = TIMING_SUMMARY_PATH.with_suffix(".tmp")
temporary.write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
temporary.replace(TIMING_SUMMARY_PATH)
def classify_command_phase(command: str) -> str:
"""Classify command time so gate cost is separated from orchestration cost."""
if plain_source_search(command):
return "source/read"
value = command.lower()
try:
parts = shlex.split(command)
script = parts[-1].lower() if len(parts) >= 3 and Path(parts[0]).name in {"bash", "sh"} and "-lc" in parts else value
except ValueError:
script = value
has_gate = bool(re.search(r"\b(?:node|python3?)\s+[^;&|\s]*run_p6_[^;&|\s]*(?:gate|smoke)", script))
has_build = bool(re.search(r"\bcmake\s+--build\b|\bbuild_web_module_artifacts(?:\.py)?\b|(?:^|[;&|]\s*)ninja\b|(?:^|[;&|]\s*)make\b", script))
has_context = bool(re.search(r"\bpython3?\s+[^;&|\s]*(?:prepare_web_task_context|validate_incremental_context)\.py\b", script))
has_preflight = bool(re.search(r"\bpython3?\s+[^;&|\s]*check_web_link_closure\.py\b", script))
has_read = bool(re.search(r"(?:^|[;&|]\s*)(?:rg|sed|cat|nl|ls|stat|sha256sum|git\s+(?:status|diff))\b", script))
# Compound shell commands are measured as one process. Keep their
# dominant work visible instead of letting an early validate token hide a
# 30-100s build or focused gate in the context bucket.
if has_build and has_gate:
return "build+focused-gate"
if has_build and has_preflight:
return "build+preflight"
if has_gate and has_context:
return "focused-gate+context"
if "read_implementation_source.py" in script and not any((has_gate, has_build, has_context, has_preflight, has_read)):
return "source/anchor"
if has_read and not any((has_gate, has_build, has_context, has_preflight)):
return "source/read"
if "continue_web_task_cycle" in value or "check_continuation_state" in value:
return "prepare/checkpoint"
if "prepare_web_task_context" in value or "validate_incremental_context" in value:
return "context"
if "prepare_full_parity_module_batch" in value:
return "batch-planning"
if has_preflight:
return "build/preflight"
if has_build:
return "build"
if "sync-assets" in value or "generate_web_release_manifest" in value or "module-preload" in value:
return "artifact-manifest"
if has_read or "fast_source_search" in script:
return "source/read"
if "prepare_" in value and ("resource" in value or "staging" in value):
return "resource-staging"
if has_gate:
return "focused-gate"
if any(token in value for token in ("sync_web_task_state", "rotate_web_task_context", "repair_continuation_checkpoint", "compact_continuation_docs")):
return "checkpoint/rotation"
return "other"
def batch_has_completed_evidence(batch: dict[str, object]) -> bool:
"""Use the compact checkpoint to select a cheaper recovery turn."""
selected = batch.get("selected") if isinstance(batch.get("selected"), dict) else {}
if selected.get("tier") == "failure-reconciliation":
from failure_reconciliation import pending_keys
return bool(batch.get("failureKeys")) and not pending_keys(batch["failureKeys"])
if selected.get("family") in {"runtime-issue", "business-acceptance"}:
return False
if not batch.get("batchId") or not selected:
return False
state = load_json(STATE)
checkpoint = state.get("batch_checkpoint") if isinstance(state.get("batch_checkpoint"), dict) else {}
checkpoint_counts = checkpoint.get("dispositionCounts") if isinstance(checkpoint.get("dispositionCounts"), dict) else {}
checkpoint_has_unresolved = any(int(checkpoint_counts.get(key, 0) or 0) > 0 for key in ("planned", "not_implemented"))
if (
checkpoint.get("batchId") == batch.get("batchId")
and str(checkpoint.get("status", "")) in {"pass", "pass_with_boundaries", "completed"}
and not checkpoint_has_unresolved
):
return True
records = batch.get("records") if isinstance(batch.get("records"), list) else []
ids = {row[0] for row in records if isinstance(row, list) and row}
if not ids:
return False
for evidence_path in (ROOT / "docs/p6").glob(f"*{batch.get('batchId')}*runtime.json"):
evidence = load_json(evidence_path)
evidence_state = str(evidence.get("status") or (evidence.get("result") if isinstance(evidence.get("result"), str) else "") or "")
if not (evidence_state in {"pass", "passed", "pass_with_boundaries", "completed", "blocked_native_probe", "pass_with_restricted_and_pending"} or evidence_state.startswith("pass_with_")):
continue
result_payload = evidence.get("result") if isinstance(evidence.get("result"), dict) else {}
raw_rows = evidence.get("records", []) if isinstance(evidence.get("records"), list) else result_payload.get("records", [])
evidence_rows = [row for row in raw_rows if isinstance(row, dict)]
matching = [row for row in evidence_rows if (row.get("apiId") or row.get("id")) in ids]
evidence_ids = {(row.get("apiId") or row.get("id")) for row in matching}
terminal_states = {"exposed", "restricted", "nonportable", "blocked_native_probe", "passed", "passed_boundary", "pass", "completed", "native_handler"}
if ids.issubset({value for value in evidence_ids if isinstance(value, str)}) and all(
str(row.get("status") or row.get("result") or "") in terminal_states for row in matching
):
return True
return False
def record_fast_path_timing(
cycle: int,
active: str,
duration: float,
preparation: dict[str, float] | None,
) -> None:
"""Record deterministic completed-batch skips in the same timing index."""
update_timing_summary(
cycle,
active,
duration,
{"scheduler/fast-path": duration},
[{
"cycle": cycle,
"phase": "scheduler/fast-path",
"seconds": duration,
"command": "batch evidence/checkpoint already complete; planner continuation",
"exitCode": 0,
}],
command_count=1,
preparation=preparation,
)
def record_scheduler_idle_timing(
cycle: int,
active: str,
duration: float,
preparation: dict[str, float] | None,
) -> None:
"""Record a deterministic no-batch wait without starting a Codex turn."""
update_timing_summary(
cycle,
active,
max(0.0, duration - sum((preparation or {}).values())),
{"scheduler/no-ready": max(0.0, duration - sum((preparation or {}).values()))},
[],
command_count=0,
preparation=preparation,
)
def record_static_pipeline_timing(
cycle: int,
active: str,
duration: float,
pipeline: dict[str, object],
preparation: dict[str, float] | None,
) -> None:
"""Record a source-complete feature batch that skipped the agent."""
rows = [
{
"cycle": cycle,
"phase": f"pre-agent-pipeline/{step.get('name', 'other')}",
"seconds": float(step.get("seconds", 0.0) or 0.0),
"command": "deterministic feature pipeline",
"exitCode": step.get("exitCode"),
}
for step in pipeline.get("steps", [])
if isinstance(step, dict)
]
phases = {str(row["phase"]): float(row["seconds"]) for row in rows}
update_timing_summary(
cycle,
active,
duration,
phases,
rows,
command_count=len(rows),
preparation=preparation,
)
def successful_exit(value: object) -> bool:
return value in (None, 0, "0")
def exploratory_no_match(command: str, exit_code: object, output: object) -> bool:
if exit_code not in (1, "1"):
return False
try:
parts = shlex.split(command)
if len(parts) >= 3 and Path(parts[0]).name in {"bash", "sh"} and "-lc" in parts:
script = parts[-1]
if any(separator in script for separator in ("&&", "||")):
return False
segments = [shlex.split(segment) for segment in script.split(";") if segment.strip()]
if len(segments) > 1:
read_only = {"rg", "sed", "nl", "stat", "ls"}
return all(segment and Path(segment[0]).name in read_only for segment in segments) and Path(segments[-1][0]).name == "rg"
parts = segments[0] if segments else []
except ValueError:
return False
return not str(output or "").strip() and bool(parts) and Path(parts[0]).name == "rg" and not any(part in {"&&", "||", ";", "|"} for part in parts)
def successful_command(command: str, exit_code: object, output: object) -> bool:
return successful_exit(exit_code) or exploratory_no_match(command, exit_code, output)
def source_read_should_stop(reads: int, file_changes: int, soft_limit: int, hard_limit: int) -> bool:
return reads >= hard_limit or (reads > soft_limit and file_changes > 0)
def summarize_codex_event(event: dict, counters: dict[str, int]) -> tuple[dict[str, object], str | None]:
"""Convert one `codex exec --json` event into observable scheduler state."""
event_type = str(event.get("type", "unknown"))
counters["eventCount"] += 1
updates: dict[str, object] = {
"lastEvent": event_type,
"lastActivityAt": datetime.now(timezone.utc).isoformat(),
"eventCount": counters["eventCount"],
}
display: str | None = None
if event_type == "thread.started":
updates["threadId"] = event.get("thread_id")
display = f"thread={event.get('thread_id')}"
elif event_type in {"item.started", "item.completed"}:
item = event.get("item") if isinstance(event.get("item"), dict) else {}
item_type = str(item.get("type", "unknown"))
updates["currentItemType"] = item_type
if item_type == "command_execution":
command = compact_text(item.get("command"), 1000)
if event_type == "item.started":
counters["commandsStarted"] += 1
updates.update(
currentCommand=command,
commandsStarted=counters["commandsStarted"],
)
display = f"command started: {compact_text(command, 240)}"
else:
exit_code = item.get("exit_code")
effective_exit_code = item.get("schedulerEffectiveExitCode", exit_code)
counters["commandsCompleted"] += 1
if not successful_exit(effective_exit_code):
counters["commandsFailed"] += 1
updates.update(
currentCommand=None,
lastCommand=command,
lastCommandExitCode=exit_code,
lastCommandEffectiveExitCode=effective_exit_code,
lastCommandNoMatch=item.get("schedulerNoMatch", False),
lastCommandStatus=item.get("status"),
commandsCompleted=counters["commandsCompleted"],
commandsFailed=counters["commandsFailed"],
lastCommandOutput=compact_text(item.get("aggregated_output"), 800),
)
display = f"command completed exit={exit_code}: {compact_text(command, 220)}"
elif item_type == "agent_message":
message = compact_text(item.get("text"), 1000)
updates["lastAgentMessage"] = message
display = f"agent: {compact_text(message, 240)}"
else:
display = f"{event_type} item={item_type}"
elif event_type == "turn.completed":
updates["turnStatus"] = "completed"
usage = event.get("usage")
if isinstance(usage, dict):
updates["usage"] = usage
display = "turn completed"
elif event_type in {"turn.failed", "error"}:
updates.update(turnStatus="failed", lastError=compact_text(event.get("error") or event, 1000))
display = f"{event_type}: {compact_text(updates['lastError'], 240)}"
elif event_type == "turn.started":
updates["turnStatus"] = "running"
display = "turn started"
return updates, display
def run_codex_turn(
codex_args: list[str],
prompt: str,
timeout: float,
cycle: int,
active: str,
live_log: Path,
publish: Callable[..., None],
preparation: dict[str, float] | None = None,
reasoning_effort: str | None = None,
turn_metadata: dict[str, object] | None = None,
source_read_limit: int = MAX_SOURCE_READS_PER_TURN,
source_read_hard_limit: int | None = None,
patch_conflict_limit: int = PATCH_CONFLICT_LIMIT,
agent_idle_limit_seconds: float = AGENT_IDLE_LIMIT_SECONDS,
optimization: bool = False,
exploration_limit_seconds: float = EXPLORATION_LIMIT_SECONDS,
source_batch: Path | None = None,
first_activity_timeout_seconds: float | None = None,
source_paths: list[Path] | None = None,
transport_activity: Callable[[], dict] | None = None,
cancel_transport: Callable[[], int] | None = None,
) -> tuple[int, bool, bool, bool, bool]:
"""Run a Codex turn while streaming every JSONL event to disk and status."""
source_read_hard_limit = max(source_read_limit, source_read_hard_limit or source_read_limit)
started = datetime.now(timezone.utc).isoformat()
turn_started_monotonic = time.monotonic()
source_tracker = SourceChangeTracker(source_paths or [])
first_activity_limit = min(timeout, first_activity_timeout_seconds if first_activity_timeout_seconds is not None
else first_activity_budget(read_progress(PROGRESS_PATH).get("provider", {})))
live_log.parent.mkdir(parents=True, exist_ok=True)
live_log.write_text(
json.dumps(
{
"type": "scheduler.cycle.started",
"cycle": cycle,
"activeTask": active,
"startedAt": started,
},
ensure_ascii=False,
)
+ "\n",
encoding="utf-8",
)
source_budget_marker = Path("/tmp") / f"freecad-web-source-budget-{os.getpid()}-{cycle}"
source_budget_marker.unlink(missing_ok=True)
environment = build_environment(source_budget_marker)
if source_batch is not None:
environment["FREECAD_SOURCE_BATCH"] = str(source_batch)
process = subprocess.Popen(
codex_args,
cwd=ROOT,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
start_new_session=True,
env=environment,
)
assert process.stdin is not None
assert process.stdout is not None
assert process.stderr is not None
# Keep one failure-ledger snapshot for the whole turn. Persisting the
# JSON file for every successful command caused repeated parse/write/fsync
# work and made the gate appear slower than it is. The streamed cycle log
# remains durable evidence; the compact ledger is flushed once at turn end.
failure_state = load_failure_state()
failure_state_dirty = False
failure_state_lock = threading.Lock()
try:
process.stdin.write(prompt)
process.stdin.close()
except BrokenPipeError:
process.stdin.close()
publish(
status="codex_turn_running",
reasoningEffort=reasoning_effort or SCHEDULER_REASONING_EFFORT,
childPid=process.pid,
activeTask=active,
cycle=cycle,
turnStartedAt=started,
firstActivityTimeoutSeconds=first_activity_limit,
firstActivityTimedOut=False,
providerBackoffRemainingSeconds=0.0,
retryAt=None,
failureKind=None,
streamFailureReason=None,
agentIdleSeconds=0.0,
forbiddenImplementationCommand=None,
currentTurnElapsedSeconds=0.0,
phaseTiming={},
liveLog=str(live_log.relative_to(ROOT)),
eventCount=0,
commandsStarted=0,
commandsCompleted=0,
commandsFailed=0,
sourceReads=0,
sourceReadBudget=source_read_limit,
sourceReadHardLimit=source_read_hard_limit,
sourceReadBudgetExceeded=False,
fileChanges=0,
agentMessages=0,
unresolvedFailures=len(unresolved_failures(active, state=failure_state)),
commandBudget=MAX_COMMANDS_PER_TURN,
commandBudgetExceeded=False,
commandBudgetStopRequested=False,
streamErrorCount=0,
streamErrorLimit=STREAM_ERROR_LIMIT,
streamErrorWindowSeconds=STREAM_ERROR_WINDOW_SECONDS,
currentAgentGapSeconds=0.0,
patchConflictCount=0,
patchConflictLimit=patch_conflict_limit,
redundancy={"duplicateCommands": 0, "duplicateCommandSeconds": 0.0, "duplicateReads": 0, "duplicateGates": 0, "duplicateBuilds": 0, "patchConflicts": 0},
)
log_lock = threading.Lock()
counter_lock = threading.Lock()
counters = {"eventCount": 0, "commandsStarted": 0, "commandsCompleted": 0, "commandsFailed": 0, "sourceReads": 0, "fileChanges": 0, "observedSourceChanges": 0, "agentMessages": 0}
command_started_at: dict[str, float] = {}
command_started_wall: dict[str, str] = {}
phase_totals: dict[str, float] = {}
command_rows: list[dict[str, object]] = []
stream_error_times: deque[float] = deque()
stream_failure_event = threading.Event()
stream_failure_reason = ""
repeated_failure_event = threading.Event()
repeated_failure_reason = ""
turn_failure_counts: dict[str, int] = {}
budget_stop_event = threading.Event()
source_budget_reached_event = threading.Event()
agent_idle_stop_event = threading.Event()
forbidden_command_event = threading.Event()
forbidden_command_reason = [""]
turn_completed_event = threading.Event()
turn_completed_at = [0.0]
event_last_at = time.monotonic()
active_command_ids: set[str] = set()
event_gap_totals: dict[str, float] = {}
agent_gap_rows: list[dict[str, object]] = []
event_gap_lock = threading.Lock()
last_idle_context = "process-start"
mutation_epoch = 0
command_replay_counts: dict[tuple[int, str], int] = {}
command_replay_by_id: dict[str, tuple[int, str, int]] = {}
patch_conflict_count = 0
redundancy: dict[str, float | int] = {
"duplicateCommands": 0,
"duplicateCommandSeconds": 0.0,
"duplicateReads": 0,
"duplicateGates": 0,
"duplicateBuilds": 0,
"patchConflicts": 0,
}
turn_usage: dict[str, int] = {}
def classify_agent_gap(next_event: str, next_item_type: str = "") -> str:
if last_idle_context == "provider-wait":
return "provider/stream-wait"
if last_idle_context == "process-start":
return "scheduler/process-start"
if next_item_type == "file_change":
return "agent/editing"
if last_idle_context in {"command-failed", "patch-conflict"}:
return "agent/failure-recovery"
if last_idle_context == "turn-start":
return "agent/initial-decision"
if last_idle_context == "file-change":
return "agent/post-edit"
if last_idle_context.startswith("command:"):
previous_phase = last_idle_context.removeprefix("command:")
if previous_phase == "source/read":
return "agent/post-read"
if previous_phase == "build/preflight":
return "agent/post-preflight"
if "build" in previous_phase:
return "agent/post-build"
if "gate" in previous_phase:
return "agent/post-gate"
if previous_phase in {"context", "prepare/checkpoint", "checkpoint/rotation"}:
return "agent/post-checkpoint"
if next_event == "turn.completed" or next_item_type == "agent_message":
return "agent/synthesis"
return "agent/decision"
def record_idle_gap(event_now: float, next_event: str, next_item_type: str = "") -> None:
nonlocal event_last_at
with event_gap_lock:
event_gap = event_now - event_last_at
event_last_at = event_now
if event_gap < 0.05 or active_command_ids:
return
gap_phase = classify_agent_gap(next_event, next_item_type)
event_gap_totals[gap_phase] = event_gap_totals.get(gap_phase, 0.0) + event_gap
agent_gap_rows.append(
{
"cycle": cycle,
"phase": gap_phase,
"seconds": round(event_gap, 3),
"after": last_idle_context,
"before": next_item_type or next_event,
}
)
live_phase_timing = dict(phase_totals)
for phase, seconds in event_gap_totals.items():
live_phase_timing[phase] = live_phase_timing.get(phase, 0.0) + seconds
publish(
currentAgentGapSeconds=round(event_gap, 3),
phaseTiming={key: round(value, 3) for key, value in live_phase_timing.items()},
redundancy=redundancy,
)
def live_phase_snapshot() -> dict[str, float]:
with event_gap_lock:
snapshot = dict(phase_totals)
for phase, seconds in event_gap_totals.items():
snapshot[phase] = snapshot.get(phase, 0.0) + seconds
return {key: round(value, 3) for key, value in snapshot.items()}
def write_line(line: str) -> None:
with log_lock:
append_log(live_log, line)
def read_stdout() -> None:
nonlocal failure_state_dirty, stream_failure_reason, repeated_failure_reason, last_idle_context, mutation_epoch
for line in process.stdout:
try:
event = json.loads(line)
except json.JSONDecodeError:
write_line(line)
publish(lastEvent="non_json_stdout", lastActivityAt=datetime.now(timezone.utc).isoformat(), lastOutput=compact_text(line))
continue
observed_at = datetime.now(timezone.utc).isoformat()
event["schedulerObservedAt"] = observed_at
event_now = time.monotonic()
item = event.get("item") if isinstance(event.get("item"), dict) else {}
item_type = str(item.get("type", ""))
idle_error: object = event.get("error") or event.get("message") or event
if isinstance(idle_error, dict):
idle_error = idle_error.get("message") or idle_error
stream_disconnect_event = event.get("type") in {"error", "turn.failed"} and is_stream_disconnect_error(idle_error)
# Provider reconnect notices are diagnostics, not useful agent
# activity. They must not extend a no-output implementation turn.
if not stream_disconnect_event:
record_idle_gap(event_now, str(event.get("type") or "event"), item_type)
if item_type or event.get("type") == "turn.completed":
stream_error_times.clear()
stream_failure_event.clear()
else:
last_idle_context = "provider-wait"
if event.get("type") == "thread.started" and turn_metadata is not None and isinstance(event.get("thread_id"), str):
turn_metadata["threadId"] = event["thread_id"]
if event.get("type") == "turn.completed" and turn_metadata is not None:
turn_metadata["turnCompleted"] = True
if event.get("type") in {"error", "turn.failed"}:
error_value: object = event.get("error") or event.get("message") or event
if isinstance(error_value, dict):
error_value = error_value.get("message") or error_value
if is_stream_disconnect_error(error_value):
now = time.monotonic()
stream_error_times.append(now)
while stream_error_times and now - stream_error_times[0] > STREAM_ERROR_WINDOW_SECONDS:
stream_error_times.popleft()
stream_failure_reason = compact_text(error_value, 1000)
if turn_metadata is not None:
turn_metadata["providerFailureReason"] = stream_failure_reason
publish(
streamErrorCount=len(stream_error_times),
streamErrorLimit=STREAM_ERROR_LIMIT,
streamErrorWindowSeconds=STREAM_ERROR_WINDOW_SECONDS,
lastError=stream_failure_reason,
)
if len(stream_error_times) >= STREAM_ERROR_LIMIT:
stream_failure_event.set()
print(
f"SCHEDULER_STREAM_FAILURE: cycle={cycle} count={len(stream_error_times)} "
f"window={STREAM_ERROR_WINDOW_SECONDS:.0f}s reason={stream_failure_reason}",
file=sys.stderr,
flush=True,
)
if item_type == "command_execution":
item_id = str(item.get("id") or item.get("command") or "")
command = str(item.get("command") or "")
item["schedulerPhase"] = classify_command_phase(command)
if event.get("type") == "item.started":
command_started_at[item_id] = time.monotonic()
command_started_wall[item_id] = observed_at
item["schedulerStartedAt"] = observed_at
active_command_ids.add(item_id)
if not optimization and implementation_first_enabled() and forbidden_implementation_command(command, str(item.get("schedulerPhase") or "")):
if item.get("schedulerPhase") == "source/read":
item["schedulerOutOfScopeExploration"] = True
publish(lastRejectedSourceCommand=compact_text(command, 500))
else:
item["schedulerBlockedImplementationCommand"] = True
forbidden_command_reason[0] = compact_text(command, 500)
forbidden_command_event.set()
publish(status="codex_turn_forbidden_command_stopping", forbiddenImplementationCommand=forbidden_command_reason[0])
if item.get("schedulerPhase") == "source/read":
counters["sourceReads"] += 1
canonical = hashlib.sha256(canonical_failure_command(command).encode("utf-8")).hexdigest()[:20]
replay_key = (mutation_epoch, canonical)
replay_count = command_replay_counts.get(replay_key, 0) + 1
command_replay_counts[replay_key] = replay_count
command_replay_by_id[item_id] = (mutation_epoch, canonical, replay_count)
if replay_count > 1:
redundancy["duplicateCommands"] = int(redundancy["duplicateCommands"]) + 1
phase = str(item.get("schedulerPhase") or "other")
if phase == "source/read":
redundancy["duplicateReads"] = int(redundancy["duplicateReads"]) + 1
if "gate" in phase:
redundancy["duplicateGates"] = int(redundancy["duplicateGates"]) + 1
if "build" in phase:
redundancy["duplicateBuilds"] = int(redundancy["duplicateBuilds"]) + 1
item["schedulerRedundantReplay"] = True
item["schedulerReplayCount"] = replay_count
elif event.get("type") == "item.completed":
raw_exit_code = item.get("exit_code")
if exploratory_no_match(str(item.get("command") or ""), raw_exit_code, item.get("aggregated_output")):
item["schedulerNoMatch"] = True
item["schedulerEffectiveExitCode"] = 0
started_at = command_started_at.pop(item_id, None)
started_wall = command_started_wall.pop(item_id, None)
if started_at is not None:
item["schedulerDurationSeconds"] = round(time.monotonic() - started_at, 3)
phase = str(item.get("schedulerPhase") or "other")
phase_totals[phase] = phase_totals.get(phase, 0.0) + float(item["schedulerDurationSeconds"])
replay = command_replay_by_id.pop(item_id, (mutation_epoch, "", 1))
redundant = replay[2] > 1
if redundant:
redundancy["duplicateCommandSeconds"] = round(float(redundancy["duplicateCommandSeconds"]) + float(item["schedulerDurationSeconds"]), 3)
command_rows.append({"cycle": cycle, "phase": phase, "seconds": float(item["schedulerDurationSeconds"]), "command": command[:500], "exitCode": item.get("schedulerEffectiveExitCode", item.get("exit_code")), "rawExitCode": item.get("exit_code"), "noMatch": item.get("schedulerNoMatch", False), "redundant": redundant, "replayCount": replay[2]})
if replay[2] >= REDUNDANT_COMMAND_LIMIT:
repeated_failure_reason = (
f"same command ran {replay[2]} times without an intervening file edit "
f"(limit={REDUNDANT_COMMAND_LIMIT}, key={replay[1]})"
)
repeated_failure_event.set()
if started_wall:
item["schedulerStartedAt"] = started_wall
active_command_ids.discard(item_id)
if not active_command_ids:
changed_sources = source_tracker.poll()
if changed_sources:
mutation_epoch += 1
counters["fileChanges"] += 1
counters["observedSourceChanges"] += 1
item["schedulerObservedSourceChanges"] = changed_sources
if turn_metadata is not None and not optimization:
record_unbuilt_edit(turn_metadata.get("batchId"), turn_metadata.get("module"))
publish(fileChanges=counters["fileChanges"], observedSourceChanges=counters["observedSourceChanges"], lastChangedSources=changed_sources)
if item.get("schedulerPhase") == "source/read" and source_read_should_stop(
counters["sourceReads"], counters["fileChanges"], source_read_limit, source_read_hard_limit
):
source_budget_reached_event.set()
source_budget_marker.write_text(
"Source-read budget reached. Use the retained results and edit now.\n",
encoding="utf-8",
)
# Block further searches through the wrappers, but let
# this turn use the source it just read to make edits.
# Killing it here discarded context before any patch.
publish(
sourceReadBudgetReached=True,
sourceReadHardLimit=source_read_hard_limit,
sourceReadBudgetStopRequested=False,
)
event["item"] = item
if event.get("type") == "item.completed":
if item_type == "file_change":
source_tracker.poll()
mutation_epoch += 1
counters["fileChanges"] += 1
if turn_metadata is not None and not optimization:
record_unbuilt_edit(turn_metadata.get("batchId"), turn_metadata.get("module"))
last_idle_context = "file-change"
elif item_type == "command_execution":
effective_exit_code = item.get("schedulerEffectiveExitCode", item.get("exit_code"))
last_idle_context = "file-change" if item.get("schedulerObservedSourceChanges") else "command-failed" if not successful_exit(effective_exit_code) else f"command:{item.get('schedulerPhase', 'other')}"
elif item_type == "agent_message":
counters["agentMessages"] += 1
last_idle_context = "agent-message"
if turn_metadata is not None:
turn_metadata["lastAgentMessage"] = str(item.get("text", ""))
elif event.get("type") == "turn.started":
last_idle_context = "turn-start"
elif event.get("type") == "turn.completed":
last_idle_context = "turn-completed"
turn_completed_at[0] = event_now
turn_completed_event.set()
usage = event.get("usage") if isinstance(event.get("usage"), dict) else {}
for name, value in usage.items():
if isinstance(value, int):
turn_usage[str(name)] = value
if turn_metadata is not None:
turn_metadata["usage"] = dict(turn_usage)
write_line(json.dumps(event, ensure_ascii=False) + "\n")
with counter_lock:
updates, display = summarize_codex_event(event, counters)
if event.get("type") == "item.completed" and item_type in {"file_change", "agent_message"}:
updates.update(fileChanges=counters["fileChanges"], agentMessages=counters["agentMessages"])
if item_type == "command_execution" and event.get("type") == "item.started" and counters["commandsStarted"] > MAX_COMMANDS_PER_TURN:
updates["commandBudgetExceeded"] = True
updates["commandBudget"] = MAX_COMMANDS_PER_TURN
if item_type == "command_execution":
updates["sourceReads"] = counters["sourceReads"]
updates["sourceReadBudgetExceeded"] = counters["sourceReads"] > source_read_limit
item = event.get("item") if isinstance(event.get("item"), dict) else {}
if item_type == "command_execution" and event.get("type") == "item.completed":
duration = item.get("schedulerDurationSeconds")
if isinstance(duration, (int, float)):
updates.update(
lastStepPhase=item.get("schedulerPhase", "other"),
lastStepDurationSeconds=duration,
phaseTiming=live_phase_snapshot(),
redundancy=redundancy,
)
elif item_type == "command_execution" and event.get("type") == "item.started":
updates["currentStepPhase"] = item.get("schedulerPhase", "other")
if event.get("type") == "item.completed" and item.get("type") == "command_execution":
command = str(item.get("command") or "")
exit_code = item.get("exit_code")
effective_success = successful_command(command, exit_code, item.get("aggregated_output"))
if not effective_success:
with failure_state_lock:
entry = record_command_failure(
active,
cycle,
command,
exit_code,
item.get("aggregated_output"),
state=failure_state,
persist=False,
module=(turn_metadata or {}).get("module"),
capability_ids=(turn_metadata or {}).get("capabilityIds"),
)
failure_state_dirty = True
unresolved_count = len(unresolved_failures(active, state=failure_state))
# Count equivalent commands together even when a gate
# alternates between relative and absolute workspace
# paths or shell quoting forms.
command_key = hashlib.sha256(canonical_failure_command(command).encode("utf-8")).hexdigest()[:20]
turn_failure_counts[command_key] = turn_failure_counts.get(command_key, 0) + 1
if turn_failure_counts[command_key] >= REPEATED_FAILURE_LIMIT:
repeated_failure_reason = (
f"same command failed {turn_failure_counts[command_key]} times "
f"(limit={REPEATED_FAILURE_LIMIT}, key={command_key})"
)
repeated_failure_event.set()
updates.update(
failureDetected=True,
lastFailureKey=entry.get("key"),
lastFailureCount=entry.get("count"),
unresolvedFailures=unresolved_count,
failureRecovery=failure_recovery_mode(active),
)
print(
f"SCHEDULER_FAILURE_DETECTED: cycle={cycle} active={active} "
f"key={entry.get('key')} count={entry.get('count')} exit={exit_code} "
f"command={compact_text(command, 240)}",
file=sys.stderr,
flush=True,
)
elif command and successful_exit(exit_code):
with failure_state_lock:
before = len(unresolved_failures(active, state=failure_state))
resolved = resolve_command_failure(command, state=failure_state, persist=False, output=item.get("aggregated_output"))
unresolved_count = len(unresolved_failures(active, state=failure_state))
if resolved:
failure_state_dirty = True
updates["unresolvedFailures"] = unresolved_count
if before != unresolved_count:
print(
f"SCHEDULER_FAILURE_RESOLVED: cycle={cycle} active={active} "
f"command={compact_text(command, 240)}",
flush=True,
)
if counters["commandsStarted"] > MAX_COMMANDS_PER_TURN:
budget_stop_event.set()
updates["commandBudgetExceeded"] = True
updates["commandBudgetStopRequested"] = True
publish(**updates)
if display:
print(f"SCHEDULER_EVENT: cycle={cycle} active={active} {display}", flush=True)
def read_stderr() -> None:
nonlocal last_idle_context, patch_conflict_count, repeated_failure_reason
try:
for line in process.stderr:
now = time.monotonic()
lowered = line.lower()
is_patch_conflict = any(token in lowered for token in ("apply_patch verification failed", "invalid patch", "multiple operations target", "patch does not apply"))
is_session_corruption = "custom tool call output is missing" in lowered
record_idle_gap(now, "scheduler.stderr", "patch-conflict" if is_patch_conflict else "stderr")
if is_patch_conflict:
patch_conflict_count += 1
redundancy["patchConflicts"] = patch_conflict_count
last_idle_context = "patch-conflict"
if patch_conflict_count >= patch_conflict_limit:
repeated_failure_reason = (
f"apply_patch conflicted {patch_conflict_count} times "
f"(limit={patch_conflict_limit}); refresh edit anchors in next turn"
)
repeated_failure_event.set()
if is_session_corruption:
repeated_failure_reason = "resumed session has a missing tool result; rotate the session"
if turn_metadata is not None:
turn_metadata["sessionCorrupt"] = True
repeated_failure_event.set()
payload = json.dumps(
{"type": "scheduler.stderr", "text": line.rstrip("\n"), "schedulerObservedAt": datetime.now(timezone.utc).isoformat(), "failureClass": "patch-apply-conflict" if is_patch_conflict else None}, ensure_ascii=False
)
write_line(payload + "\n")
publish(lastEvent="stderr", lastActivityAt=datetime.now(timezone.utc).isoformat(), lastError=compact_text(line), patchConflictCount=patch_conflict_count, redundancy=redundancy)
print(f"SCHEDULER_STDERR: cycle={cycle} {compact_text(line, 240)}", file=sys.stderr, flush=True)
except ValueError:
# The process exited and the bounded stream drain closed the pipe.
return
stdout_thread = threading.Thread(target=read_stdout, name=f"cycle-{cycle}-stdout", daemon=True)
stderr_thread = threading.Thread(target=read_stderr, name=f"cycle-{cycle}-stderr", daemon=True)
stdout_thread.start()
stderr_thread.start()
deadline = time.monotonic() + timeout
timed_out = False
stream_failed = False
budget_exceeded = False
agent_idle_stopped = False
turn_exit_optimized = False
while process.poll() is None:
with event_gap_lock:
provider_idle_seconds = time.monotonic() - event_last_at
provider_command_active = bool(active_command_ids)
awaiting_first_activity = (counters["commandsStarted"] == 0 and counters["fileChanges"] == 0
and counters["agentMessages"] == 0)
transport_state = transport_activity() if transport_activity else {}
transport_waiting = transport_can_continue(transport_state,
time.monotonic() - turn_started_monotonic if awaiting_first_activity else provider_idle_seconds,
first_activity_limit if awaiting_first_activity else agent_idle_limit_seconds)
# An explicit provider stream error should use the scheduler recovery
# budget rather than the transport's full 130s retry budget. Active
# local commands remain protected by the command_active guard below.
if stream_error_times and not provider_command_active:
transport_waiting = False
if transport_state:
publish(transportActivity=transport_state)
if transport_state.get("phase") == "context_rejected" and not provider_command_active:
if turn_metadata is not None:
turn_metadata.update(contextBudgetExceeded=True, sessionCorrupt=True)
publish(contextBudgetExceeded=True, status="context_budget_rotation")
budget_stop_event.set()
if not optimization and not provider_command_active and recovery_requested(os.getpid(), (turn_metadata or {}).get("batchId")):
publish(status="automatic_stall_recovery", automaticRepair="restart and replan cooled batch")
if turn_metadata is not None:
turn_metadata["automaticStallRecovery"] = True
try:
os.killpg(process.pid, signal.SIGTERM)
process.wait(timeout=2)
except (ProcessLookupError, subprocess.TimeoutExpired):
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
break
if not transport_waiting and provider_wait_expired(bool(stream_error_times), provider_command_active, provider_idle_seconds):
stream_failure_event.set()
if (not provider_command_active and not transport_waiting and awaiting_first_activity
and transport_state.get("phase") != "context_rejected"
and time.monotonic() - turn_started_monotonic >= first_activity_limit):
stream_failure_reason = "no first command, edit or message within the response budget"
last_idle_context = "first-activity-wait"
if turn_metadata is not None:
turn_metadata["firstActivityTimedOut"] = True
turn_metadata["providerFailureReason"] = stream_failure_reason
publish(firstActivityTimedOut=True, failureKind="first_activity_timeout")
stream_failure_event.set()
if turn_completed_event.is_set() and time.monotonic() - turn_completed_at[0] >= TURN_EXIT_GRACE_SECONDS:
# `codex exec` can keep its tool host alive for roughly ten seconds
# after emitting the terminal event. The result and usage are
# already durable in JSONL, so close only that completed process.
turn_exit_optimized = True
publish(status="codex_turn_exit_draining", turnExitOptimized=True)
try:
os.killpg(process.pid, signal.SIGTERM)
process.wait(timeout=2)
except (ProcessLookupError, subprocess.TimeoutExpired):
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
break
if stream_failure_event.is_set() and not provider_command_active:
stream_failed = True
publish(status="codex_turn_stream_error_stopping", streamFailureReason=stream_failure_reason)
try:
os.killpg(process.pid, signal.SIGTERM)
process.wait(timeout=10)
except (ProcessLookupError, subprocess.TimeoutExpired):
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
break
if forbidden_command_event.is_set():
turn_exit_optimized = True
if turn_metadata is not None:
turn_metadata["forbiddenImplementationCommand"] = forbidden_command_reason[0]
turn_metadata["postEditAutoFinalize"] = counters["fileChanges"] > 0
try:
os.killpg(process.pid, signal.SIGTERM)
process.wait(timeout=10)
except (ProcessLookupError, subprocess.TimeoutExpired):
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
break
if repeated_failure_event.is_set():
publish(status="codex_turn_repeated_failure_stopping", repeatedFailureReason=repeated_failure_reason)
print(
f"SCHEDULER_REPEATED_FAILURE: cycle={cycle} {repeated_failure_reason}",
file=sys.stderr,
flush=True,
)
try:
os.killpg(process.pid, signal.SIGTERM)
process.wait(timeout=10)
except (ProcessLookupError, subprocess.TimeoutExpired):
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
break
if budget_stop_event.is_set():
budget_exceeded = True
publish(
status="codex_turn_command_budget_stopping",
commandBudgetExceeded=counters["commandsStarted"] > MAX_COMMANDS_PER_TURN,
sourceReadBudgetExceeded=counters["sourceReads"] > source_read_limit,
sourceReadHardLimit=source_read_hard_limit,
)
try:
os.killpg(process.pid, signal.SIGTERM)
process.wait(timeout=10)
except (ProcessLookupError, subprocess.TimeoutExpired):
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
break
with event_gap_lock:
idle_seconds = time.monotonic() - event_last_at
command_active = bool(active_command_ids)
if not optimization and not command_active and not transport_waiting and counters["fileChanges"] > 0 and idle_seconds >= min(agent_idle_limit_seconds, POST_EDIT_FINALIZE_SECONDS):
# A completed file_change event is already durable. Waiting for
# prose synthesis after a quiet edit delays the authoritative
# compiler without adding implementation value.
turn_exit_optimized = True
if turn_metadata is not None:
turn_metadata["postEditAutoFinalize"] = True
publish(
status="codex_turn_post_edit_finalizing",
postEditAutoFinalize=True,
postEditIdleSeconds=round(idle_seconds, 3),
postEditFinalizeSeconds=min(agent_idle_limit_seconds, POST_EDIT_FINALIZE_SECONDS),
)
try:
os.killpg(process.pid, signal.SIGTERM)
process.wait(timeout=10)
except (ProcessLookupError, subprocess.TimeoutExpired):
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
break
if not command_active and not transport_waiting and not awaiting_first_activity and idle_seconds >= agent_idle_limit_seconds:
agent_idle_stopped = True
agent_idle_stop_event.set()
publish(
status="codex_turn_agent_idle_stopping",
agentIdleSeconds=round(idle_seconds, 3),
agentIdleLimitSeconds=agent_idle_limit_seconds,
)
print(
f"SCHEDULER_AGENT_IDLE: cycle={cycle} idle={idle_seconds:.1f}s "
f"limit={agent_idle_limit_seconds:.1f}s",
file=sys.stderr,
flush=True,
)
try:
os.killpg(process.pid, signal.SIGTERM)
process.wait(timeout=10)
except (ProcessLookupError, subprocess.TimeoutExpired):
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
break
if not transport_waiting and exploration_expired(time.monotonic() - turn_started_monotonic, command_active,
counters["fileChanges"], command_rows, exploration_limit_seconds):
agent_idle_stopped = True
if turn_metadata is not None:
turn_metadata["explorationBudgetExceeded"] = True
publish(status="exploration_budget_reached", explorationBudgetSeconds=exploration_limit_seconds,
explorationBudgetExceeded=True)
try:
os.killpg(process.pid, signal.SIGTERM)
process.wait(timeout=10)
except (ProcessLookupError, subprocess.TimeoutExpired):
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
break
if time.monotonic() >= deadline:
timed_out = True
publish(status="codex_turn_timeout_stopping")
try:
os.killpg(process.pid, signal.SIGTERM)
process.wait(timeout=10)
except (ProcessLookupError, subprocess.TimeoutExpired):
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
break
time.sleep(1.0)
return_code = process.wait()
if cancel_transport:
cancelled_requests = cancel_transport()
publish(cancelledModelRequests=cancelled_requests)
if turn_metadata is not None:
turn_metadata["cancelledModelRequests"] = cancelled_requests
if turn_exit_optimized and not any((timed_out, stream_failed, budget_exceeded, agent_idle_stopped)):
return_code = 0
drain_started = time.monotonic()
stdout_thread.join(timeout=STREAM_DRAIN_SECONDS)
stderr_thread.join(timeout=STREAM_DRAIN_SECONDS)
drain_seconds = time.monotonic() - drain_started
stream_failed = stream_failed or stream_failure_event.is_set()
budget_exceeded = budget_exceeded or counters["commandsStarted"] > MAX_COMMANDS_PER_TURN or counters["sourceReads"] > source_read_hard_limit
agent_idle_stopped = agent_idle_stopped or agent_idle_stop_event.is_set()
with event_gap_lock:
final_gap = max(0.0, time.monotonic() - event_last_at - drain_seconds)
if final_gap >= 0.05 and not active_command_ids:
final_phase = "provider/stream-wait" if last_idle_context == "provider-wait" else "request/first-activity-wait" if last_idle_context == "first-activity-wait" else "agent/idle-stop" if agent_idle_stopped else "agent/finalization"
event_gap_totals[final_phase] = event_gap_totals.get(final_phase, 0.0) + final_gap
agent_gap_rows.append({"cycle": cycle, "phase": final_phase, "seconds": round(final_gap, 3), "after": last_idle_context, "before": "process-exit"})
for phase, seconds in event_gap_totals.items():
phase_totals[phase] = phase_totals.get(phase, 0.0) + seconds
if drain_seconds >= 0.01:
phase_totals["scheduler/stream-drain"] = phase_totals.get("scheduler/stream-drain", 0.0) + drain_seconds
agent_gap_rows.append({"cycle": cycle, "phase": "scheduler/stream-drain", "seconds": round(drain_seconds, 3), "after": "process-exit", "before": "timing-flush"})
if failure_state_dirty:
with failure_state_lock:
save_failure_state(failure_state)
publish(
lastTurnDurationSeconds=round(time.monotonic() - turn_started_monotonic, 3),
currentTurnElapsedSeconds=round(time.monotonic() - turn_started_monotonic, 3),
phaseTiming={key: round(value, 3) for key, value in phase_totals.items()},
sourceChangeScanSeconds=round(source_tracker.seconds, 6),
)
update_timing_summary(
cycle,
active,
time.monotonic() - turn_started_monotonic,
phase_totals,
command_rows,
agent_gap_rows=agent_gap_rows,
redundancy=redundancy,
activity={
"commands": counters["commandsCompleted"],
"commandFailures": counters["commandsFailed"],
"sourceReads": counters["sourceReads"],
"fileChanges": counters["fileChanges"],
"observedSourceChanges": counters["observedSourceChanges"],
"agentMessages": counters["agentMessages"],
"patchConflicts": patch_conflict_count,
"turnExitOptimized": int(turn_exit_optimized),
"sessionResumed": int(bool(turn_metadata and turn_metadata.get("sessionResumed"))),
},
usage=turn_usage,
command_count=counters.get("commandsCompleted", len(command_rows)),
preparation=preparation,
timed_out=timed_out,
stream_failed=stream_failed,
budget_exceeded=budget_exceeded,
agent_idle_stopped=agent_idle_stopped,
)
if turn_metadata is not None:
turn_metadata["commandsStarted"] = counters["commandsStarted"]
turn_metadata["commandsCompleted"] = counters["commandsCompleted"]
turn_metadata["commandsFailed"] = counters["commandsFailed"]
if optimization:
turn_metadata["commandResults"] = command_rows
turn_metadata["fileChanges"] = counters["fileChanges"]
turn_metadata["observedSourceChanges"] = counters["observedSourceChanges"]
turn_metadata["sourceChangeScanSeconds"] = round(source_tracker.seconds, 6)
turn_metadata["sourceReads"] = counters["sourceReads"]
turn_metadata["patchConflicts"] = patch_conflict_count
turn_metadata["sourceBudgetReached"] = source_budget_reached_event.is_set()
append_log(
live_log,
json.dumps(
{
"type": "scheduler.cycle.completed",
"cycle": cycle,
"activeTask": active,
"exitCode": return_code,
"timedOut": timed_out,
"streamFailed": stream_failed,
"commandBudgetExceeded": budget_exceeded,
"agentIdleStopped": agent_idle_stopped,
"turnExitOptimized": turn_exit_optimized,
"observedSourceChanges": counters["observedSourceChanges"],
"sourceChangeScanSeconds": round(source_tracker.seconds, 6),
"firstActivityTimeoutSeconds": first_activity_limit,
"firstActivityTimedOut": bool((turn_metadata or {}).get("firstActivityTimedOut")),
"failureReason": stream_failure_reason or None,
"completedAt": datetime.now(timezone.utc).isoformat(),
},
ensure_ascii=False,
)
+ "\n",
)
publish(childPid=None, exitCode=return_code, timedOut=timed_out)
source_budget_marker.unlink(missing_ok=True)
return return_code, timed_out, stream_failed, budget_exceeded, agent_idle_stopped
def main() -> int:
global LOG_DIR
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--max-cycles", type=int, default=0, help="stop after N Codex turns; 0 means until TERMINATE")
parser.add_argument("--idle-delay", type=float, default=30.0, help="seconds before retrying when no ready leaf exists")
parser.add_argument("--timeout", type=float, default=1800.0, help="maximum seconds per Codex turn (default: 1800; use a larger value only for a known long build)")
parser.add_argument("--stream-retry-delay", type=float, default=DEFAULT_STREAM_RETRY_DELAY, help="backoff after a provider stream disconnect (default: 20s)")
parser.add_argument("--model", default=None, help="optional Codex model override")
parser.add_argument("--log-dir", type=Path, default=LOG_DIR)
args = parser.parse_args()
LOG_DIR = args.log_dir if args.log_dir.is_absolute() else ROOT / args.log_dir
LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
with LOCK_PATH.open("a+", encoding="ascii") as lock, ExitStack() as transport_stack:
try:
fcntl.flock(lock.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
print("SCHEDULER_ALREADY_RUNNING", file=sys.stderr)
return 2
transport = transport_stack.enter_context(configured_transport(ROOT, process_id=os.getpid()))
transport_args = transport.cli_args() if transport else []
transport_idle_floor = transport.response_budget if transport else 0.0
PID_PATH.write_text(str(os.getpid()) + "\n", encoding="ascii")
cycle = next_cycle_number() - 1
cycles_run = 0
stream_retry_count = 0
stopping = False
status_lock = threading.Lock()
heartbeat_stop = threading.Event()
last_status_flush = 0.0
status_flush_interval = 0.25
heartbeat_state: dict[str, object] = {
"pid": os.getpid(),
"implementation": SCHEDULER_IMPLEMENTATION,
"reasoningEffort": SCHEDULER_REASONING_EFFORT,
"status": "starting",
"executionMode": "module-business",
"cycle": cycle,
"activeTask": None,
"startedAt": datetime.now(timezone.utc).isoformat(),
"schedulerSourceSha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
"configuredFirstActivitySeconds": FIRST_ACTIVITY_TIMEOUT_SECONDS,
"configuredFirstActivityMaxSeconds": FIRST_ACTIVITY_MAX_SECONDS,
"modelTransport": {"mode": "http1-loopback", "upstream": transport.upstream, "localUrl": transport.url,
"responseBudgetSeconds": transport_idle_floor} if transport else {"mode": "direct"},
}
def publish_status(**updates: object) -> None:
nonlocal last_status_flush
with status_lock:
heartbeat_state.update(updates)
now_wall = datetime.now(timezone.utc).isoformat()
heartbeat_state["heartbeatAt"] = now_wall
now_mono = time.monotonic()
critical = not updates or any(key in updates for key in (
"status", "childPid", "exitCode", "timedOut", "failureDetected", "failureRecovery",
"preparationTiming", "phaseTiming", "lastTurnDurationSeconds", "lastCommandStatus",
"lastFailureKey", "lastError",
))
if critical or now_mono - last_status_flush >= status_flush_interval:
write_status(**heartbeat_state)
last_status_flush = now_mono
def heartbeat() -> None:
while not heartbeat_stop.is_set():
started_at = heartbeat_state.get("turnStartedAt")
if heartbeat_state.get("status") == "codex_turn_running" and isinstance(started_at, str):
try:
elapsed = (datetime.now(timezone.utc) - datetime.fromisoformat(started_at.replace("Z", "+00:00"))).total_seconds()
publish_status(currentTurnElapsedSeconds=round(max(0.0, elapsed), 3))
except ValueError:
publish_status()
else:
publish_status()
heartbeat_stop.wait(15.0)
heartbeat_thread = threading.Thread(target=heartbeat, name="scheduler-heartbeat", daemon=True)
heartbeat_thread.start()
initial_state = load_json(STATE)
recovered_modules = restore_module_recovery()
if recovered_modules:
publish_status(restoredModuleRecovery=recovered_modules)
initial_active = initial_state.get("active_task")
hydrate_recent_failures(initial_active)
reconcile_failures()
publish_status(
unresolvedFailures=len(unresolved_failures(initial_active)),
failureRecovery=failure_recovery_mode(initial_active),
)
def stop(_signum: int, _frame: object) -> None:
nonlocal stopping
stopping = True
signal.signal(signal.SIGINT, stop)
signal.signal(signal.SIGTERM, stop)
try:
while not stopping:
if terminal():
publish_status(status="terminated", activeTask=None, childPid=None)
print("SCHEDULER_TERMINATE: current completion scope is complete", flush=True)
return 0
if args.max_cycles and cycles_run >= args.max_cycles:
publish_status(status="max_cycles", cycle=cycle, cyclesRun=cycles_run, childPid=None)
print(f"SCHEDULER_MAX_CYCLES: {cycles_run}", flush=True)
return 0
provider = read_progress(PROGRESS_PATH).get("provider", {})
provider_wait = float(provider.get("retryAt", 0)) - time.time()
local_during_backoff = provider_wait > 0 and local_pipeline_available_during_backoff()
publish_status(localPipelineDuringBackoff=local_during_backoff)
if provider_wait > 0 and not local_during_backoff:
publish_status(status="provider_backoff", childPid=None, retryAt=provider.get("retryAt"), streamRetryCount=provider.get("consecutiveFailures"), streamFailureReason=provider.get("reason"), providerBackoffRemainingSeconds=round(provider_wait, 3))
wait_started = time.monotonic()
deadline = time.monotonic() + min(provider_wait, 30.0)
while not stopping and time.monotonic() < deadline:
time.sleep(min(1.0, max(0, deadline - time.monotonic())))
record_scheduler_wait(cycle, time.monotonic() - wait_started)
continue
previous_optimization_child = maintenance.active_child()
if previous_optimization_child:
publish_status(status="waiting_for_previous_optimization", executionMode="periodic-optimization",
childPid=previous_optimization_child)
time.sleep(1.0)
continue
optimization_request = maintenance.claim_due() if provider_wait <= 0 else None
if optimization_request:
cycle += 1
cycles_run += 1
optimization_log = LOG_DIR / f"cycle-{cycle:06d}.log"
optimization_context = maintenance.diagnostic_context()
publish_status(status="periodic_optimization_starting", executionMode="periodic-optimization",
cycle=cycle, cyclesRun=cycles_run, activeTask="UNATTENDED-OPTIMIZATION",
childPid=None, moduleBatchId=None, moduleBatchModule=None, moduleBatchTier=None,
moduleBatchFamily=None, moduleBatchRecords=0, progressOutcome=None,
lastError=None, lastCommand=None, lastAgentMessage=None,
maintenanceRunId=optimization_request["runId"])
optimization_args = [
"codex", "--ask-for-approval", "never", "--sandbox", "danger-full-access",
"exec", "--json", "--skip-git-repo-check", "-C", str(ROOT),
"-c", f'model_reasoning_effort="{SCHEDULER_REASONING_EFFORT}"', *transport_args, "-",
]
if args.model:
optimization_args[1:1] = ["--model", args.model]
def optimization_publish(**updates):
if "childPid" in updates:
maintenance.track_child(optimization_request["runId"], updates["childPid"])
publish_status(**updates)
def optimize(prompt, log, metadata):
request_checkpoint = transport.usage_checkpoint() if transport else 0
return run_codex_turn(optimization_args, prompt, min(args.timeout, 900), cycle,
"UNATTENDED-OPTIMIZATION", log, optimization_publish,
reasoning_effort=SCHEDULER_REASONING_EFFORT, turn_metadata=metadata,
source_read_limit=8, source_read_hard_limit=8,
agent_idle_limit_seconds=MODULE_BUSINESS_IDLE_LIMIT_SECONDS,
first_activity_timeout_seconds=first_activity_limit(read_progress(PROGRESS_PATH).get("provider", {}), transport_idle_floor),
optimization=True,
transport_activity=(lambda: transport.activity_since(request_checkpoint)) if transport else None,
cancel_transport=(lambda: transport.cancel_since(request_checkpoint)) if transport else None,
source_batch=Path(metadata["sourceBatch"]) if metadata.get("sourceBatch") else None)
result = maintenance.execute(optimization_request, optimize, optimization_log, context=optimization_context)
record_provider_attempt(failed=result.get("providerFailed", False), responded=result.get("modelResponded", False),
reason=result.get("providerFailureReason", ""), retry_delay=args.stream_retry_delay)
publish_status(status="periodic_optimization_finished", childPid=None,
maintenanceOutcome=result["status"], lastLog=result["logPath"],
executionMode="module-business")
print(f"SCHEDULER_OPTIMIZATION_DONE: {json.dumps(result, ensure_ascii=False)}", flush=True)
# The watchdog restarts after this clean exit. This also
# loads any scheduler fixes made in the optimization turn.
return 0
waiting_batch = read_progress(MODULE_BATCH_PATH)
if replan_cooled_batch(waiting_batch, publish_status):
continue
if waiting_batch.get("status") == "waiting_for_retry":
publish_status(status="waiting_for_retry", childPid=None, retryAt=waiting_batch.get("retryAt"), deferredRecords=waiting_batch.get("deferredRecords"))
deadline = time.monotonic() + min(30.0, max(0.0, float(waiting_batch.get("retryAt", 0)) - time.time()))
while not stopping and time.monotonic() < deadline:
time.sleep(min(1.0, max(0, deadline - time.monotonic())))
if not stopping:
run_local(planner_script())
continue
cycle += 1
cycles_run += 1
publish_status(
status="preparing",
executionMode="module-business",
cycle=cycle,
cyclesRun=cycles_run,
activeTask=None,
childPid=None,
turnStartedAt=None,
currentTurnElapsedSeconds=0.0,
currentAgentGapSeconds=0.0,
phaseTiming={},
preparationTiming={},
commandsStarted=0,
commandsCompleted=0,
commandsFailed=0,
sourceReads=0,
fileChanges=0,
explorationBudgetExceeded=False,
lastRejectedSourceCommand=None,
patchConflictCount=0,
commandBudgetExceeded=False,
commandBudgetStopRequested=False,
streamErrorCount=0,
repeatedFailureReason=None,
lastError=None,
lastFailureKey=None,
lastFailureCount=None,
lastCommand=None,
lastCommandExitCode=None,
lastCommandStatus=None,
lastCommandOutput=None,
lastAgentMessage=None,
currentItemType=None,
currentCommand=None,
noReadyBatch=False,
retryAt=None,
progressOutcome=None,
progressedRecords=0,
deferredRecords=0,
sessionSaved=False,
postTurnPipelineStatus=None,
postTurnPipelineOutput=None,
postTurnPipelineSeconds=None,
)
preparation_timing: dict[str, float] = {}
preparation_started = time.monotonic()
advance = run_local("tools/continue_web_task_cycle.py")
preparation_timing["continue"] = round(time.monotonic() - preparation_started, 3)
preparation_seconds = preparation_timing["continue"]
publish_status(preparationSeconds=preparation_seconds, preparationTiming=dict(preparation_timing))
if advance.returncode == 2:
log = write_log(cycle, "TASK_CYCLE_NO_READY_LEAF\n" + advance.stdout + advance.stderr)
publish_status(status="waiting_for_ready_leaf", activeTask=None, childPid=None, lastLog=str(log.relative_to(ROOT)))
print(f"SCHEDULER_WAIT: no ready leaf; log={log}", flush=True)
time.sleep(max(1.0, args.idle_delay))
continue
if advance.returncode != 0:
compact_started = time.monotonic()
compact = run_local("tools/compact_continuation_docs.py")
preparation_timing["compact"] = round(time.monotonic() - compact_started, 3)
publish_status(preparationTiming=dict(preparation_timing))
if compact.returncode == 0:
retry_started = time.monotonic()
retry = run_local("tools/continue_web_task_cycle.py")
preparation_timing["continueRetry"] = round(time.monotonic() - retry_started, 3)
publish_status(preparationTiming=dict(preparation_timing))
if retry.returncode == 0:
advance = retry
publish_status(status="context_compacted_and_recovered", activeTask=load_json(STATE).get("active_task"), childPid=None)
print(f"SCHEDULER_CONTEXT_COMPACTED: {compact.stdout.strip()}", flush=True)
if advance.returncode != 0:
repair_started = time.monotonic()
repair = run_local("tools/repair_continuation_checkpoint.py")
preparation_timing["checkpointRepair"] = round(time.monotonic() - repair_started, 3)
publish_status(preparationTiming=dict(preparation_timing))
if repair.returncode == 0:
log = write_log(cycle, "CHECKPOINT_REPAIRED\n" + repair.stdout + repair.stderr + "\nORIGINAL_TASK_CYCLE_ERROR\n" + advance.stdout + advance.stderr)
publish_status(status="checkpoint_repaired", activeTask=load_json(STATE).get("active_task"), childPid=None, lastLog=str(log.relative_to(ROOT)))
print(f"SCHEDULER_CHECKPOINT_REPAIRED: log={log}", flush=True)
continue
log = write_log(cycle, "TASK_CYCLE_ERROR\n" + advance.stdout + advance.stderr)
publish_status(status="prepare_error", childPid=None, lastLog=str(log.relative_to(ROOT)))
print(f"SCHEDULER_CYCLE_PREPARE_ERROR: log={log}", file=sys.stderr, flush=True)
time.sleep(max(1.0, args.idle_delay))
continue
preparation_output = compact_text(advance.stdout, 700)
if preparation_output:
print(
f"SCHEDULER_PREPARED: cycle={cycle} seconds={preparation_seconds} {preparation_output}",
flush=True,
)
# `continue_web_task_cycle.py` can legitimately return zero
# while the planner has no executable record. Do one cheap
# direct planner pass, then wait without spending an entire
# Codex turn repeatedly inspecting the same empty queue.
batch = load_json(MODULE_BATCH_PATH) if MODULE_BATCH_PATH.exists() else {}
if replan_cooled_batch(batch, publish_status):
continue
if batch.get("status") == "waiting_for_retry":
publish_status(status="waiting_for_retry", childPid=None, retryAt=batch.get("retryAt"), deferredRecords=batch.get("deferredRecords"), noReadyBatch=True)
continue
if batch.get("status") == "no_ready_batch":
plan_started = time.monotonic()
planner = run_local(planner_script())
plan_seconds = round(time.monotonic() - plan_started, 3)
preparation_timing["plannerNoReady"] = plan_seconds
publish_status(preparationTiming=dict(preparation_timing))
batch = load_json(MODULE_BATCH_PATH) if MODULE_BATCH_PATH.exists() else {}
if batch.get("status") == "no_ready_batch":
# Close deferred feature-build transactions before
# entering idle. This is native build bookkeeping,
# not runtime validation, and is safe to retry with
# the helper's bounded failure backoff.
flush_started = time.monotonic()
deferred_flush = run_local("tools/flush_deferred_module_builds.py", "--json")
preparation_timing["deferredBuildFlush"] = round(time.monotonic() - flush_started, 3)
publish_status(
preparationTiming=dict(preparation_timing),
deferredBuildFlushStatus="pass" if deferred_flush.returncode == 0 else "failed",
deferredBuildFlushOutput=compact_text(deferred_flush.stdout or deferred_flush.stderr, 700),
)
active_wait = load_json(STATE).get("active_task") or ""
wait_duration = preparation_seconds + plan_seconds
record_scheduler_idle_timing(cycle, active_wait, wait_duration, preparation_timing)
publish_status(
status="waiting_for_batch",
activeTask=active_wait or None,
childPid=None,
moduleBatchId=None,
moduleBatchModule=None,
moduleBatchTier=None,
moduleBatchFamily=None,
moduleBatchRecords=0,
noReadyBatch=True,
)
print(
f"SCHEDULER_WAIT: no executable module batch; "
f"plannerSeconds={plan_seconds:.3f}",
flush=True,
)
time.sleep(max(1.0, args.idle_delay))
continue
active = batch.get("taskId") or read_progress(STATE).get("active_task")
preflight_started = time.monotonic()
preflight = run_local("tools/preflight_current_module_batch.py", "--json")
preflight_timing = round(time.monotonic() - preflight_started, 3)
preparation_timing["batchPreflight"] = preflight_timing
publish_status(preparationTiming=dict(preparation_timing))
if preflight.returncode != 0:
repair_started = time.monotonic()
repair = run_local("tools/continue_web_task_cycle.py")
preparation_timing["batchPreflightRepair"] = round(time.monotonic() - repair_started, 3)
publish_status(preparationTiming=dict(preparation_timing))
if repair.returncode == 0:
batch = read_progress(MODULE_BATCH_PATH)
checked = run_local("tools/preflight_current_module_batch.py", "--json") if repair.returncode == 0 else repair
if repair.returncode != 0 or checked.returncode != 0:
before = progress_snapshot(batch)
record_outcome(batch, before, before, cycle=cycle, reason=compact_text(checked.stdout or checked.stderr, 1000))
log = write_log(cycle, "BATCH_PREFLIGHT_ERROR\n" + preflight.stdout + preflight.stderr + "\nREPAIR_ERROR\n" + repair.stdout + repair.stderr)
publish_status(status="batch_preflight_error", activeTask=active, childPid=None, lastLog=str(log.relative_to(ROOT)))
print(f"SCHEDULER_BATCH_PREFLIGHT_ERROR: log={log}", file=sys.stderr, flush=True)
time.sleep(max(1.0, args.idle_delay))
continue
batch = load_json(MODULE_BATCH_PATH) if MODULE_BATCH_PATH.exists() else {}
checkpoint = load_json(CHECKPOINT)
active = checkpoint.get("activeTask")
if not active:
publish_status(status="waiting_for_active_task", activeTask=None, childPid=None)
time.sleep(max(1.0, args.idle_delay))
continue
set_phase("IMPLEMENT_BATCH")
live_log = LOG_DIR / f"cycle-{cycle:06d}.log"
pending_failures = unresolved_failures(active)
batch = load_json(MODULE_BATCH_PATH) if MODULE_BATCH_PATH.exists() else {}
if pending_failures:
print(
f"SCHEDULER_FAILURE_RECOVERY: cycle={cycle} active={active} "
f"unresolved={len(pending_failures)}; mode={'replay' if REPLAY_FAILURES_DURING_IMPLEMENTATION else 'deferred'}",
flush=True,
)
if batch_has_completed_evidence(batch):
fast_started = time.monotonic()
fast_path = run_local("tools/continue_web_task_cycle.py")
fast_duration = time.monotonic() - fast_started
if fast_path.returncode == 0:
record_fast_path_timing(cycle, active, fast_duration + preparation_timing.get("continue", 0.0), preparation_timing)
publish_status(
status="batch_fast_path_complete",
activeTask=active,
childPid=None,
fastPathSeconds=round(fast_duration, 3),
nextAction="planner_required",
)
print(
f"SCHEDULER_BATCH_FAST_PATH: cycle={cycle} active={active} "
f"seconds={fast_duration:.3f}; skipped_codex_turn=true",
flush=True,
)
continue
print(
f"SCHEDULER_BATCH_FAST_PATH_FALLBACK: cycle={cycle} active={active} "
f"exit={fast_path.returncode}; starting Codex turn",
flush=True,
)
packet_started = time.monotonic()
implementation_packet = run_local("tools/prepare_module_implementation_packet.py")
preparation_timing["implementationPacket"] = round(time.monotonic() - packet_started, 3)
publish_status(
preparationTiming=dict(preparation_timing),
implementationPacketStatus="ready" if implementation_packet.returncode == 0 else "unavailable",
implementationPacketOutput=compact_text(implementation_packet.stdout or implementation_packet.stderr, 500),
)
selected = batch.get("selected") if isinstance(batch.get("selected"), dict) else {}
progress_before = progress_snapshot(batch)
selected_module = str(selected.get("module") or "")
feature_turn = selected.get("tier") == "feature-workflow"
packet_state = load_json(IMPLEMENTATION_PACKET_PATH) if IMPLEMENTATION_PACKET_PATH.exists() else {}
pending_targets = read_progress(ROOT / "docs/current/module-build-pending.json").get("targets", {})
current_build = packet_state.get("build") if isinstance(packet_state.get("build"), dict) else {}
current_target = f"{current_build.get('tree', '')}:{current_build.get('target', '')}"
if packet_state.get("outerPipelineAvailable") and any(
key != current_target and rows and isinstance(rows[0], dict) and float(rows[0].get("nextAttemptAt") or 0) <= time.time()
for key, rows in pending_targets.items() if isinstance(rows, list)
):
flushed = run_local("tools/flush_deferred_module_builds.py", "--exclude-target", current_target,
"--report-path", "docs/current/deferred-build-flush.json", "--json")
publish_status(deferredPreviousTargetFlush="pass" if flushed.returncode == 0 else "failed", deferredPreviousTargetOutput=compact_text(flushed.stdout or flushed.stderr, 500))
recover_unbuilt_business = bool(
not feature_turn
and implementation_packet.returncode == 0
and packet_state.get("outerPipelineAvailable") is True
and unbuilt_edit_matches(batch.get("batchId"))
and packet_has_unbuilt_changes(packet_state)
)
unchanged_failed_pipeline = failed_pipeline_is_unchanged(batch.get("batchId"), packet_state)
executable_acceptance = runtime_pipeline_ready(batch)
ran_static_pipeline = False
if (feature_turn or recover_unbuilt_business or executable_acceptance) and (selected.get("family") != "runtime-issue" or runtime_pipeline_ready(batch)
or user_validation_deferred() and packet_has_unbuilt_changes(packet_state)) and implementation_packet.returncode == 0 and packet_state.get("outerPipelineAvailable") is True:
if unchanged_failed_pipeline:
# The same compiler error is already in the recovery
# prompt. Let the agent fix it before spending another
# subprocess/build round on an unchanged input.
publish_status(
preTurnPipelineStatus="skipped-unchanged-failure",
preTurnPipelineSeconds=0.0,
preTurnPipelineOutput="unchanged failed build or business case retained; waiting for the relevant edit",
)
else:
static_pipeline_started = time.monotonic()
static_pipeline = run_local("tools/run_module_batch_pipeline.py")
ran_static_pipeline = True
static_pipeline_seconds = round(time.monotonic() - static_pipeline_started, 3)
static_pipeline_report = load_json(MODULE_PIPELINE_PATH) if MODULE_PIPELINE_PATH.exists() else {}
record_pipeline_commands(active, cycle, batch, static_pipeline_report)
static_pipeline_output = compact_text(static_pipeline.stdout or static_pipeline.stderr, 1000)
publish_status(
preTurnPipelineStatus=static_pipeline_report.get("status") or ("failed" if static_pipeline.returncode else "unknown"),
preTurnPipelineSeconds=static_pipeline_seconds,
preTurnPipelineOutput=static_pipeline_output,
)
if static_pipeline.returncode == 0 and (static_pipeline_report.get("status") in {"implementation_ready", "implementation_pending_build"}
or executable_acceptance and static_pipeline_report.get("status") == "pass"):
publish_status(failureReconciliation=reconcile_failures()["summary"])
record_outcome(batch, progress_before, progress_snapshot(batch), cycle=cycle, seconds=static_pipeline_seconds)
UNBUILT_EDIT_PATH.unlink(missing_ok=True)
record_static_pipeline_timing(cycle, active, static_pipeline_seconds, static_pipeline_report, preparation_timing)
publish_status(
status="batch_static_pipeline_complete",
activeTask=active,
childPid=None,
nextAction="planner_required",
)
print(
f"SCHEDULER_STATIC_PIPELINE: cycle={cycle} active={active} "
f"seconds={static_pipeline_seconds:.3f}; skipped_codex_turn=true",
flush=True,
)
continue
if static_pipeline_report.get("failedStage") == "business-acceptance":
run_local("tools/prepare_module_implementation_packet.py")
packet_state = read_progress(IMPLEMENTATION_PACKET_PATH)
provider = read_progress(PROGRESS_PATH).get("provider", {})
if float(provider.get("retryAt", 0)) > time.time():
if ran_static_pipeline:
record_static_pipeline_timing(cycle, active, static_pipeline_seconds, static_pipeline_report, preparation_timing)
else:
record_scheduler_idle_timing(cycle, active, sum(preparation_timing.values()), preparation_timing)
publish_status(status="provider_backoff", childPid=None, retryAt=provider["retryAt"],
localPipelineDuringBackoff=ran_static_pipeline)
continue
# Desktop command ports need a clean source-driven decision;
# resuming the old API evidence session repeatedly encouraged
# metadata-only boundary patches instead of real behavior.
current_pipeline = load_json(MODULE_PIPELINE_PATH) if MODULE_PIPELINE_PATH.exists() else {}
compile_recovery = bool(compile_failure_steps(current_pipeline, batch.get("batchId")))
# Compile recovery is tied to the current batch and now uses
# the retained short session, avoiding repeated cold-start
# reasoning while keeping other batches isolated.
previous_session = resumable_session(
selected_module,
batch.get("batchId"),
same_batch_only=compile_recovery or feature_turn,
)
session_resumed = previous_session is not None
turn_prompt = build_prompt(active, failures=pending_failures, resumed=session_resumed)
turn_reasoning_effort = "low" if feature_turn or batch_has_completed_evidence(batch) else SCHEDULER_REASONING_EFFORT
business_turn = (selected.get("family") == "runtime-issue" or str(selected.get("family", "")).startswith("module-business-")
or selected.get("tier") in {"format-workflow", "product-integration", "business-acceptance", "failure-reconciliation"})
turn_idle_limit = (
COMPILE_RECOVERY_IDLE_LIMIT_SECONDS if compile_recovery
else MODULE_BUSINESS_IDLE_LIMIT_SECONDS if business_turn
else AGENT_IDLE_LIMIT_SECONDS
)
source_read_limit, source_read_hard_limit, recovery_idle_limit = recovery_turn_limits(
selected_module, business_turn, batch
)
if business_turn:
turn_idle_limit = min(turn_idle_limit, recovery_idle_limit)
# Active tools and live response streams are guarded inside
# run_codex_turn. A request timeout is not a minimum idle budget.
publish_status(
status="codex_turn_starting",
reasoningEffort=turn_reasoning_effort,
activeTask=active,
childPid=None,
unresolvedFailures=len(pending_failures),
failureRecovery=failure_recovery_mode(active),
moduleBatchId=batch.get("batchId"),
moduleBatchModule=(batch.get("selected") or {}).get("module") if isinstance(batch.get("selected"), dict) else None,
moduleBatchTier=(batch.get("selected") or {}).get("tier") if isinstance(batch.get("selected"), dict) else None,
moduleBatchFamily=(batch.get("selected") or {}).get("family") if isinstance(batch.get("selected"), dict) else None,
moduleBatchRecords=(batch.get("selected") or {}).get("recordCount") if isinstance(batch.get("selected"), dict) else 0,
moduleBatchManifest=str(MODULE_BATCH_PATH.relative_to(ROOT)) if batch else None,
promptBytes=len(turn_prompt.encode("utf-8")),
promptByteLimit=PROMPT_BYTE_LIMIT,
contextCompacted="[CONTEXT_COMPACTED by unattended scheduler:" in turn_prompt,
promptLines=turn_prompt.count("\n") + 1,
sessionMode="resume" if session_resumed else "fresh",
sessionThreadId=previous_session.get("threadId") if previous_session else None,
sessionTurns=int(previous_session.get("turns", 0) or 0) if previous_session else 0,
sessionReuseLimit=(
min(SESSION_REUSE_LIMIT, FEATURE_SESSION_REUSE_LIMIT)
if (feature_turn or compile_recovery)
else SESSION_REUSE_LIMIT
),
sessionPredictedInputTokens=int(previous_session.get("predictedNextInputTokens", 0) or 0) if previous_session else 0,
recoveryMode="compile-first-error" if compile_recovery else None,
agentIdleLimitSeconds=turn_idle_limit,
)
codex_args = [
"codex", "--ask-for-approval", "never", "--sandbox", "danger-full-access",
"exec", "--json", "--skip-git-repo-check", "-C", str(ROOT),
"-c", f'model_reasoning_effort="{turn_reasoning_effort}"',
*transport_args,
]
if session_resumed:
codex_args.extend(["resume", "--json", str(previous_session["threadId"]), "-"])
else:
codex_args.append("-")
if args.model:
codex_args[1:1] = ["--model", args.model]
turn_metadata: dict[str, object] = {
"sessionResumed": session_resumed,
"batchId": batch.get("batchId"),
"module": selected_module,
"scopeFingerprint": implementation_scope(packet_state),
"capabilityIds": [str(row[0]) for row in batch.get("records", []) if row],
}
if replan_cooled_batch(batch, publish_status):
continue
usage_checkpoint = transport.usage_checkpoint() if transport else 0
context_checkpoint = {
"schema": "freecad-web-context-checkpoint/v1", "cycle": cycle,
"batchId": batch.get("batchId"), "module": selected_module,
"recordIds": turn_metadata["capabilityIds"], "businessWorkflow": batch.get("businessWorkflow"),
"packetPath": str(IMPLEMENTATION_PACKET_PATH.relative_to(ROOT)),
"pipelinePath": str(MODULE_PIPELINE_PATH.relative_to(ROOT)),
"promptBytes": _prompt_bytes(turn_prompt), "promptByteLimit": PROMPT_BYTE_LIMIT,
"sessionInputTokenLimit": SESSION_INPUT_TOKEN_LIMIT,
"sessionMode": "resume" if session_resumed else "fresh",
"contextCompacted": "[CONTEXT_COMPACTED" in turn_prompt,
"sourceSnapshot": source_snapshot(ROOT, packet_state),
"status": "running", "updatedAt": datetime.now(timezone.utc).isoformat(),
"nextAction": "Resume only this batch from packet and current pipeline first error; reuse unchanged files.",
}
write_checkpoint(ROOT / "docs/current/scheduler-context.json", context_checkpoint)
return_code, timed_out, stream_failed, budget_exceeded, agent_idle_stopped = run_codex_turn(
codex_args,
turn_prompt,
args.timeout,
cycle,
active,
live_log,
publish_status,
preparation_timing,
turn_reasoning_effort,
turn_metadata,
source_read_limit=(FEATURE_SOURCE_READS_PER_TURN if feature_turn else source_read_limit if business_turn else MAX_SOURCE_READS_PER_TURN),
source_read_hard_limit=(FEATURE_SOURCE_READS_HARD_LIMIT if feature_turn else source_read_hard_limit if business_turn else MAX_SOURCE_READS_PER_TURN),
patch_conflict_limit=FEATURE_PATCH_CONFLICT_LIMIT if feature_turn else PATCH_CONFLICT_LIMIT,
agent_idle_limit_seconds=turn_idle_limit,
first_activity_timeout_seconds=first_activity_limit(read_progress(PROGRESS_PATH).get("provider", {}), transport_idle_floor),
source_paths=tracked_edit_paths(ROOT, packet_state),
transport_activity=(lambda: transport.activity_since(usage_checkpoint)) if transport else None,
cancel_transport=(lambda: transport.cancel_since(usage_checkpoint)) if transport else None,
)
# Make the failure class visible to session persistence. This
# is intentionally set before any post-turn pipeline work so a
# disconnected provider can never preserve its old thread.
turn_metadata["streamFailed"] = stream_failed
turn_metadata["requestUsage"] = transport.usage_since(usage_checkpoint) if transport else {}
agent_successful = return_code == 0 and not any((timed_out, stream_failed, budget_exceeded, agent_idle_stopped))
pipeline_eligible = return_code == 0 and not any((timed_out, stream_failed, agent_idle_stopped))
packet_state = load_json(IMPLEMENTATION_PACKET_PATH) if IMPLEMENTATION_PACKET_PATH.exists() else {}
if pipeline_eligible and (packet_state.get("outerPipelineAvailable") is not True or int(turn_metadata.get("fileChanges", 0) or 0) > 0):
refresh_started = time.monotonic()
refreshed_packet = run_local("tools/prepare_module_implementation_packet.py")
publish_status(
implementationPacketRefreshSeconds=round(time.monotonic() - refresh_started, 3),
implementationPacketRefreshStatus="ready" if refreshed_packet.returncode == 0 else "failed",
)
if refreshed_packet.returncode == 0:
packet_state = load_json(IMPLEMENTATION_PACKET_PATH)
if (
pipeline_eligible
and packet_state.get("batchId") == batch.get("batchId")
and packet_state.get("outerPipelineAvailable") is True
and (not implementation_first_enabled() or int(turn_metadata.get("fileChanges", 0) or 0) > 0 or selected.get("tier") in {"failure-reconciliation", "business-acceptance"}
or (runtime_pipeline_ready(batch) and not failed_pipeline_is_unchanged(batch.get("batchId"), packet_state)))
):
pipeline_started = time.monotonic()
publish_status(status="post_turn_pipeline", postTurnPipelineStatus="running")
pipeline = run_local("tools/run_module_batch_pipeline.py")
pipeline_seconds = round(time.monotonic() - pipeline_started, 3)
pipeline_output = compact_text(pipeline.stdout or pipeline.stderr, 1000)
pipeline_report = load_json(MODULE_PIPELINE_PATH) if MODULE_PIPELINE_PATH.exists() else {}
attach_post_pipeline_timing(cycle, pipeline_report, pipeline_seconds)
append_log(
live_log,
json.dumps({"type": "scheduler.post-turn-pipeline", "exitCode": pipeline.returncode, "seconds": pipeline_seconds, "output": pipeline_output}, ensure_ascii=False) + "\n",
)
publish_status(
postTurnPipelineStatus="pass" if pipeline.returncode == 0 else "failed",
postTurnPipelineSeconds=pipeline_seconds,
postTurnPipelineOutput=pipeline_output,
)
if pipeline.returncode != 0:
return_code = pipeline.returncode
elif pipeline_report.get("status") in {"implementation_ready", "implementation_pending_build"}:
UNBUILT_EDIT_PATH.unlink(missing_ok=True)
record_pipeline_commands(active, cycle, batch, pipeline_report)
failure_report = reconcile_failures()
publish_status(failureReconciliation=failure_report["summary"])
session_clean = bool(turn_metadata.get("turnCompleted")) and not int(turn_metadata.get("patchConflicts", 0) or 0) and not bool(turn_metadata.get("sessionCorrupt"))
latest_pipeline = read_progress(MODULE_PIPELINE_PATH)
failed_steps = [row for row in latest_pipeline.get("steps", []) if row.get("exitCode") not in (None, 0)] if latest_pipeline.get("batchId") == batch.get("batchId") and latest_pipeline.get("status") == "failed" else []
reason = (compact_text(turn_metadata.get("providerFailureReason") or heartbeat_state.get("lastError") or "provider response unavailable", 1000)
if stream_failed else compact_text(failed_steps[0].get("stderr") or failed_steps[0].get("stdout"), 1000)
if failed_steps else compact_text(heartbeat_state.get("lastAgentMessage") or "", 1000))
progress_result = record_outcome(batch, progress_before, progress_snapshot(batch), cycle=cycle,
seconds=float(heartbeat_state.get("lastTurnDurationSeconds", 0) or 0),
file_changes=int(turn_metadata.get("fileChanges", 0) or 0), provider_failure=stream_failed,
model_response=bool(turn_metadata.get("turnCompleted") or turn_metadata.get("commandsStarted")),
retry_delay=args.stream_retry_delay, reason=reason)
publish_status(progressOutcome=progress_result["outcome"], progressedRecords=len(progress_result["progressedIds"]), deferredRecords=len(progress_result["deferredIds"]), moduleRecovery=progress_result["moduleRecovery"])
retained_business_context = bool(
business_turn
and isinstance(turn_metadata.get("threadId"), str)
and not int(turn_metadata.get("patchConflicts", 0) or 0)
and not bool(turn_metadata.get("sessionCorrupt"))
and not bool(turn_metadata.get("forbiddenImplementationCommand"))
and not timed_out
and not stream_failed
and (int(turn_metadata.get("sourceReads", 0) or 0) > 0 or int(turn_metadata.get("fileChanges", 0) or 0) > 0)
)
session_successful = retain_turn_context(business_turn=business_turn, feature_turn=feature_turn,
pipeline_eligible=pipeline_eligible, agent_successful=agent_successful,
session_clean=session_clean, retained_business_context=retained_business_context)
save_session_cache(selected_module, batch.get("batchId"), turn_metadata, previous_session, session_successful)
context_checkpoint.update({"status": "completed" if agent_successful else "interrupted",
"requestUsage": turn_metadata["requestUsage"], "aggregateUsage": turn_metadata.get("usage", {}),
"sessionSaved": session_successful, "sourceSnapshot": source_snapshot(ROOT, packet_state),
"outcome": progress_result["outcome"], "remainingReason": reason,
"firstFailedStage": failed_steps[0].get("name") if failed_steps else None,
"updatedAt": datetime.now(timezone.utc).isoformat()})
write_checkpoint(ROOT / "docs/current/scheduler-context.json", context_checkpoint)
append_log(live_log, json.dumps({"type": "scheduler.context", **context_checkpoint}, ensure_ascii=False) + "\n")
publish_status(
contextRequestUsage=turn_metadata["requestUsage"],
sessionThreadId=turn_metadata.get("threadId"),
sessionSaved=(session_successful and isinstance(turn_metadata.get("threadId"), str)) or bool(turn_metadata.get("previousSessionPreserved")),
previousSessionPreserved=bool(turn_metadata.get("previousSessionPreserved")),
idleContextSaved=retained_business_context,
)
if stream_failed:
stream_retry_count = int(progress_result["provider"].get("consecutiveFailures", 1))
delay = max(0, float(progress_result["provider"].get("retryAt", 0)) - time.time())
publish_status(
status="stream_retry_backoff",
activeTask=active,
childPid=None,
streamRetryCount=stream_retry_count,
streamRetryDelaySeconds=delay,
streamFailureReason=turn_metadata.get("providerFailureReason") or "provider response unavailable",
)
print(
f"SCHEDULER_STREAM_RETRY: cycle={cycle} count={stream_retry_count} delay={delay:.1f}s",
flush=True,
)
continue
if agent_idle_stopped:
publish_status(
status="agent_idle_recovered",
activeTask=active,
childPid=None,
agentIdleLimitSeconds=turn_idle_limit,
)
print(
f"SCHEDULER_AGENT_IDLE_RECOVERED: cycle={cycle} active={active}",
flush=True,
)
continue
if return_code == 0 and not timed_out:
stream_retry_count = 0
if timed_out:
set_phase("CONTINUE")
timeout_result = run_local("tools/rotate_web_task_context.py", "--reason", "timeout", "--next-task", active)
append_log(live_log, "\n" + timeout_result.stdout + timeout_result.stderr)
publish_status(status="timeout_recovered", activeTask=active, lastLog=str(live_log.relative_to(ROOT)))
continue
set_phase("CONTINUE")
remaining_failures = unresolved_failures(active)
publish_status(
status=("cycle_complete" if return_code == 0 and not remaining_failures else "cycle_complete_with_failures" if return_code == 0 else "turn_failed"),
activeTask=active,
childPid=None,
lastLog=str(live_log.relative_to(ROOT)),
exitCode=return_code,
unresolvedFailures=len(remaining_failures),
failureRecovery=failure_recovery_mode(active),
commandBudgetExceeded=budget_exceeded,
)
print(f"SCHEDULER_CYCLE_DONE: cycle={cycle} active={active} exit={return_code} unresolved_failures={len(remaining_failures)} log={live_log}", flush=True)
publish_status(status="stop_signal", childPid=None)
print("SCHEDULER_STOP_SIGNAL", flush=True)
return 130
finally:
heartbeat_stop.set()
heartbeat_thread.join(timeout=2.0)
try:
if PID_PATH.read_text(encoding="ascii").strip() == str(os.getpid()):
PID_PATH.unlink()
except FileNotFoundError:
pass
if __name__ == "__main__":
raise SystemExit(main())