Files
ai-exchange/scripts/run_until_blocked.py
T
2026-05-08 08:09:00 +08:00

492 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
import json
import os
import re
import subprocess
import sys
import time
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from scripts.run_trading_delivery_rehearsal import run_trading_delivery_rehearsal
SESSION_PATH = ROOT / "SESSION.md"
TASKS_PATH = ROOT / "TASKS.md"
RUNNER_STATUS_PATH = ROOT / ".claude" / "automation_status.json"
RUNNER_RESUME_WATCH_PATHS = [
ROOT / "src",
ROOT / "scripts",
ROOT / ".env",
ROOT / "SESSION.md",
ROOT / "TASKS.md",
]
STAGE_SEQUENCE = ["P0-2", "P0-3", "P0-4", "P0-5", "P1-1", "P1-2", "P1-3"]
STAGE_TITLES = {
"P0-1": "实时市场真值层",
"P0-2": "真实账户与订单只读同步",
"P0-3": "盘中持续调度与运行监督",
"P0-4": "决策包与主动提醒",
"P0-5": "真实交易映射与盘后闭环",
"P1-1": "辅助可信度与模型白名单",
"P1-2": "操作体验与运营工具",
"P1-3": "live adapter 前准备",
}
STAGE_GOALS = {
"P0-2": [
"建立 `BrokerReadModelService`",
"读取真实账户余额 / 持仓 / 挂单 / 成交",
"扩展 `RiskContext`",
"建立 reconciliation 读模型",
"前端展示真实 `open position / pending order`",
"focused smoke: `/trading/broker-state` 与 `/trading/market-state` 返回 broker state",
],
"P0-3": [
"建立 `TradingRuntimeScheduler`",
"建立 `TradingRuntimeSupervisor`",
"提供 runtime health API",
"前端展示 runtime status",
"focused smoke: runtime 状态 API / workbench runtime status 可见",
],
"P0-4": [
"建立 `DecisionPacketService`",
"输出统一决策包字段",
"接入主动提醒通道",
"workbench 展示决策卡",
],
"P0-5": [
"建立 execution 与真实 order/fill 映射",
"真实持仓变化触发 journal entry",
"增强真实交易归因与周报闭环",
],
"P1-1": [
"建立 symbol / session / setup 表现统计",
"增加辅助可信度评分与白名单",
],
"P1-2": [
"补齐 workbench 快捷筛选、session 视图、alert history、operator notes",
"支持一键生成盘后 review packet",
],
"P1-3": [
"校准真实 order schema",
"补全 bracket / OCO reconciliation 语义",
"建立 live readiness checklist 与 operator confirmation protocol",
],
}
DEFAULT_VALIDATION_COMMANDS = ["npm run lint", "npm run build", "npm run visual:snapshots"]
BLOCKED_MARKERS = [
"缺凭据",
"权限不足",
"不可逆",
"网络/供应商限制",
"外部阻塞",
]
def run_until_blocked(env=None, runner=None, sleeper=None) -> dict:
env = dict(os.environ if env is None else env)
runner = runner or subprocess.run
output_json = _get_bool(env, "RUN_UNTIL_BLOCKED_OUTPUT_JSON", False)
max_rounds = max(_get_int(env, "RUN_UNTIL_BLOCKED_MAX_ROUNDS", 8), 1)
max_idle_rounds = max(_get_int(env, "RUN_UNTIL_BLOCKED_MAX_IDLE_ROUNDS", 1), 0)
completed_stages = parse_done_stages(TASKS_PATH.read_text(encoding="utf-8"))
current_stage = detect_current_stage(SESSION_PATH.read_text(encoding="utf-8"), completed_stages)
idle_rounds = 0
rounds = []
blocked_reason = None
started_ts = datetime.now(timezone.utc).isoformat()
write_runner_status(
{
"state": "running",
"started_ts": started_ts,
"heartbeat_ts": started_ts,
"current_stage": current_stage,
"completed_stages": completed_stages,
"blocked_reason": None,
"rounds_completed": 0,
}
)
try:
while current_stage is not None and len(rounds) < max_rounds:
write_runner_status(
{
"state": "running",
"started_ts": started_ts,
"heartbeat_ts": datetime.now(timezone.utc).isoformat(),
"current_stage": current_stage,
"completed_stages": completed_stages,
"blocked_reason": blocked_reason,
"rounds_completed": len(rounds),
}
)
round_env = dict(env)
round_env["RUN_UNTIL_BLOCKED_STAGE"] = current_stage
round_env["RUN_UNTIL_BLOCKED_STAGE_TITLE"] = STAGE_TITLES[current_stage]
summary = run_trading_delivery_rehearsal(env=round_env, runner=runner, sleeper=sleeper)
round_result = {
"round": len(rounds) + 1,
"stage": current_stage,
"stage_title": STAGE_TITLES[current_stage],
"continuation_decision": summary.get("continuation_decision", "continue"),
"mode": summary.get("mode"),
"passed": summary.get("passed", False),
"blockers": list(summary.get("blockers") or []),
"promotion_decision": summary.get("promotion_decision"),
}
rounds.append(round_result)
if summary.get("continuation_decision") == "advance":
completed_stages.append(current_stage)
next_stage = get_next_stage(current_stage, completed_stages)
write_anchor_files(
completed_stages=completed_stages,
current_stage=next_stage,
last_result=summary,
)
current_stage = next_stage
idle_rounds = 0
continue
blocked_reason = first_blocked_reason(summary)
if blocked_reason is not None or summary.get("continuation_decision") == "blocked":
write_anchor_files(
completed_stages=completed_stages,
current_stage=current_stage,
last_result=summary,
blocked_reason=blocked_reason or ", ".join(summary.get("blockers") or []) or summary.get("mode") or "blocked",
)
break
idle_rounds += 1
write_anchor_files(
completed_stages=completed_stages,
current_stage=current_stage,
last_result=summary,
blocked_reason=None,
)
if idle_rounds > max_idle_rounds:
blocked_reason = f"idle_round_limit_exceeded:{summary.get('mode') or 'unknown'}"
break
final_stage = current_stage
summary = {
"started_ts": started_ts,
"finished_ts": datetime.now(timezone.utc).isoformat(),
"rounds": rounds,
"completed_stages": completed_stages,
"current_stage": final_stage,
"current_stage_title": STAGE_TITLES.get(final_stage),
"blocked_reason": blocked_reason,
"status": "completed" if final_stage is None and blocked_reason is None else "blocked" if blocked_reason else "stopped",
}
write_runner_status(
{
"state": summary["status"],
"started_ts": started_ts,
"heartbeat_ts": datetime.now(timezone.utc).isoformat(),
"current_stage": final_stage,
"completed_stages": completed_stages,
"blocked_reason": blocked_reason,
"rounds_completed": len(rounds),
}
)
except Exception:
write_runner_status(
{
"state": "failed",
"started_ts": started_ts,
"heartbeat_ts": datetime.now(timezone.utc).isoformat(),
"current_stage": current_stage,
"completed_stages": completed_stages,
"blocked_reason": blocked_reason or "runner_exception",
"rounds_completed": len(rounds),
}
)
raise
if output_json:
print(json.dumps(summary, ensure_ascii=False, sort_keys=True))
else:
print(f"Run-until-blocked status: {summary['status']}")
print(f"Completed stages: {', '.join(completed_stages) if completed_stages else 'none'}")
if final_stage:
print(f"Current stage: {final_stage} {STAGE_TITLES[final_stage]}")
if blocked_reason:
print(f"Blocked reason: {blocked_reason}")
return summary
def run_supervised_until_stopped(env=None, runner=None, sleeper=None) -> dict:
env = dict(os.environ if env is None else env)
sleeper = sleeper or time.sleep
loop_sleep_seconds = max(_get_int(env, "RUN_UNTIL_BLOCKED_LOOP_SLEEP_SECONDS", 30), 1)
max_supervisor_cycles = max(_get_int(env, "RUN_UNTIL_BLOCKED_SUPERVISOR_CYCLES", 0), 0)
cycles = 0
last_summary = None
resume_fingerprint = compute_resume_fingerprint()
while True:
last_summary = run_until_blocked(env=env, runner=runner, sleeper=sleeper)
cycles += 1
if last_summary["status"] == "completed":
return last_summary
supervisor_state = classify_supervisor_state(last_summary)
write_runner_status(
{
"state": supervisor_state,
"started_ts": last_summary["started_ts"],
"heartbeat_ts": datetime.now(timezone.utc).isoformat(),
"current_stage": last_summary.get("current_stage"),
"completed_stages": last_summary.get("completed_stages") or [],
"blocked_reason": last_summary.get("blocked_reason"),
"rounds_completed": len(last_summary.get("rounds") or []),
"sleep_seconds": loop_sleep_seconds,
"supervisor_cycles": cycles,
"resume_fingerprint": resume_fingerprint,
}
)
if max_supervisor_cycles and cycles >= max_supervisor_cycles:
return last_summary
if supervisor_state in {"paused", "needs_fix"}:
if wait_for_resume_condition(
previous_fingerprint=resume_fingerprint,
sleeper=sleeper,
loop_sleep_seconds=loop_sleep_seconds,
):
resume_fingerprint = compute_resume_fingerprint()
continue
return last_summary
sleeper(loop_sleep_seconds)
def classify_supervisor_state(summary: dict) -> str:
blocked_reason = str(summary.get("blocked_reason") or "")
if summary.get("status") == "completed":
return "completed"
if blocked_reason.startswith("autonomous_mode:pipeline_failed"):
return "needs_fix"
if blocked_reason.startswith("autonomous_mode:offline_checks_failed"):
return "needs_fix"
if blocked_reason.startswith("autonomous_mode:migration_failed"):
return "needs_fix"
if blocked_reason.startswith("autonomous_mode:backfill_failed"):
return "paused"
if blocked_reason.startswith("autonomous_mode:waiting_for_database"):
return "sleeping"
if blocked_reason:
return "paused"
return "sleeping"
def wait_for_resume_condition(*, previous_fingerprint: str, sleeper, loop_sleep_seconds: int) -> bool:
current_fingerprint = compute_resume_fingerprint()
if current_fingerprint != previous_fingerprint:
return True
sleeper(loop_sleep_seconds)
return compute_resume_fingerprint() != previous_fingerprint
def compute_resume_fingerprint() -> str:
entries = []
for path in RUNNER_RESUME_WATCH_PATHS:
if not path.exists():
entries.append(f"{path}:missing")
continue
if path.is_file():
stat = path.stat()
entries.append(f"{path}:{stat.st_mtime_ns}:{stat.st_size}")
continue
for child in sorted(path.rglob("*")):
if not child.is_file():
continue
stat = child.stat()
entries.append(f"{child}:{stat.st_mtime_ns}:{stat.st_size}")
return "|".join(entries)
def write_runner_status(payload: dict) -> None:
RUNNER_STATUS_PATH.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
def detect_current_stage(session_text: str, completed_stages: list[str]) -> Optional[str]:
exact_next_step = re.search(r"###\s+`(P\d-\d)`", session_text)
if exact_next_step and exact_next_step.group(1) in STAGE_SEQUENCE:
return exact_next_step.group(1)
for stage in STAGE_SEQUENCE:
if stage not in completed_stages:
return stage
return None
def get_next_stage(current_stage: str, completed_stages: list[str]) -> Optional[str]:
for stage in STAGE_SEQUENCE:
if stage not in completed_stages:
return stage
return None
def parse_done_stages(tasks_text: str) -> list[str]:
return re.findall(r"- \[x\] `(P\d-\d)`", tasks_text)
def first_blocked_reason(summary: dict) -> Optional[str]:
blockers = [str(item) for item in summary.get("blockers") or []]
for blocker in blockers:
if any(marker in blocker for marker in BLOCKED_MARKERS):
return blocker
if summary.get("continuation_decision") == "blocked":
return blockers[0] if blockers else summary.get("mode")
return None
def write_anchor_files(*, completed_stages: list[str], current_stage: Optional[str], last_result: dict, blocked_reason: Optional[str] = None) -> None:
SESSION_PATH.write_text(render_session(completed_stages, current_stage, last_result, blocked_reason), encoding="utf-8")
TASKS_PATH.write_text(render_tasks(completed_stages, current_stage, blocked_reason), encoding="utf-8")
def render_session(completed_stages: list[str], current_stage: Optional[str], last_result: dict, blocked_reason: Optional[str]) -> str:
completed_lines = [f"- 已完成 `{stage}`:{STAGE_TITLES[stage]}" for stage in completed_stages] or ["- 本轮尚无新增完成阶段"]
next_step_block = render_next_step_block(current_stage)
risk_lines = [
"- 当前市场真值链路依赖本机 `192.168.3.73:10808` 持续可达;如本机关机或代理退出,远端将退化",
"- 真实账户只读同步下一阶段可能需要额外凭据或账号权限;若现有环境缺失,将构成唯一允许的用户介入点",
]
if blocked_reason:
risk_lines.append(f"- 当前自动化已阻塞:{blocked_reason}")
return "\n".join(
[
"# ai-exchange Session",
"",
"## Current Goal",
"",
"从现在开始按全自动模式持续推进交易模块主线,不再等待阶段确认,直到完成 `docs/AI_ICT_实盘辅助决策系统开发方案_v2.md` 中剩余开发目标。",
"",
"## Current Branch",
"",
"`main`",
"",
"## Last Updated",
"",
f"{datetime.now(timezone.utc).strftime('%Y-%m-%d UTC')}",
"",
"## Completed In Last Session",
"",
*completed_lines,
"",
"## Current Working State",
"",
"- 当前主线执行协议已固化到:",
" - `docs/AI_ICT_全自动开发执行流程_v1.md`",
"- 当前阶段队列来源于:",
" - `docs/AI_ICT_实盘辅助决策系统开发方案_v2.md`",
"- 默认不再等待用户中途确认",
"- 每阶段必须完成:",
" - 实现",
" - focused tests",
" - `npm run lint`",
" - `npm run build`",
" - `npm run visual:snapshots`",
" - Docker 部署",
" - 远端 smoke",
" - 状态回写",
"",
"## Exact Next Step",
"",
next_step_block,
"",
"## Validation Commands",
"",
"```bash",
*DEFAULT_VALIDATION_COMMANDS,
"```",
"",
"## Risks Or Open Questions",
"",
*risk_lines,
"",
]
)
def render_next_step_block(current_stage: Optional[str]) -> str:
if current_stage is None:
return "所有阶段已完成,无下一阶段。"
goal_lines = STAGE_GOALS.get(current_stage, [])
rendered_goals = "\n".join(f"- {line}" for line in goal_lines)
return f"直接进入:\n\n### `{current_stage}` {STAGE_TITLES[current_stage]}\n\n目标:\n\n{rendered_goals}"
def render_tasks(completed_stages: list[str], current_stage: Optional[str], blocked_reason: Optional[str]) -> str:
in_progress_lines = [f"- [ ] `{current_stage}` {STAGE_TITLES[current_stage]}"] if current_stage else ["- [x] 所有阶段已完成"]
next_up_lines = [
f"- [ ] `{stage}` {STAGE_TITLES[stage]}"
for stage in STAGE_SEQUENCE
if stage != current_stage and stage not in completed_stages
] or ["- [x] 当前无后续阶段"]
blocked_lines = [f"- [ ] {blocked_reason}"] if blocked_reason else ["- [ ] 当前无阻塞;如缺凭据或外部权限,再转入此区"]
done_lines = [f"- [x] `{stage}` {STAGE_TITLES[stage]}" for stage in completed_stages] or ["- [ ] 当前尚无完成阶段"]
return "\n".join(
[
"# ai-exchange Tasks",
"",
"## Execution Mode",
"",
"- [x] 全自动模式已启用",
"- [x] 默认不等待阶段确认",
"- [x] 每阶段必须完成实现、验证、部署、smoke、状态回写",
"",
"## In Progress",
"",
*in_progress_lines,
"",
"## Next Up",
"",
*next_up_lines,
"",
"## Backlog",
"",
"- [ ] 长期目标:在不默认开启真实自动下单的前提下,完成 `Real-time Assisted Trading V1`",
"",
"## Blocked",
"",
*blocked_lines,
"",
"## Done",
"",
*done_lines,
"",
]
)
def _get_bool(env, key: str, default: bool) -> bool:
value = env.get(key)
if value is None or value == "":
return default
return str(value).strip().lower() in {"1", "true", "yes", "on"}
def _get_int(env, key: str, default: int) -> int:
value = env.get(key)
if value is None or value == "":
return default
return int(value)
def main() -> int:
env = dict(os.environ)
summary = run_supervised_until_stopped(env=env) if _get_bool(env, "RUN_UNTIL_BLOCKED_SUPERVISOR", False) else run_until_blocked(env=env)
return 0 if summary["status"] == "completed" else 2 if summary["status"] == "blocked" else 1
if __name__ == "__main__":
raise SystemExit(main())