3503 lines
217 KiB
Python
3503 lines
217 KiB
Python
#!/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())
|