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())