237 lines
12 KiB
Python
237 lines
12 KiB
Python
import contextlib
|
|
import io
|
|
import json
|
|
import os
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest.mock import Mock
|
|
|
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test")
|
|
|
|
from scripts import run_until_blocked
|
|
|
|
|
|
class RunUntilBlockedTests(unittest.TestCase):
|
|
def test_advances_to_next_stage_and_updates_anchors(self) -> None:
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
session_path = Path(tmp) / "SESSION.md"
|
|
tasks_path = Path(tmp) / "TASKS.md"
|
|
session_path.write_text("# ai-exchange Session\n\n### `P0-2` 真实账户与订单只读同步\n", encoding="utf-8")
|
|
tasks_path.write_text("# ai-exchange Tasks\n\n## Done\n\n- [x] `P0-1` 实时市场真值层\n", encoding="utf-8")
|
|
|
|
original_session = run_until_blocked.SESSION_PATH
|
|
original_tasks = run_until_blocked.TASKS_PATH
|
|
original_runner = run_until_blocked.run_trading_delivery_rehearsal
|
|
try:
|
|
run_until_blocked.SESSION_PATH = session_path
|
|
run_until_blocked.TASKS_PATH = tasks_path
|
|
run_until_blocked.run_trading_delivery_rehearsal = lambda **kwargs: {
|
|
"mode": "rehearsal_completed",
|
|
"passed": True,
|
|
"continuation_decision": "advance",
|
|
"promotion_decision": "promote_candidate",
|
|
"blockers": [],
|
|
}
|
|
|
|
summary = run_until_blocked.run_until_blocked(env={"RUN_UNTIL_BLOCKED_MAX_ROUNDS": "1"})
|
|
session_text = session_path.read_text(encoding="utf-8")
|
|
tasks_text = tasks_path.read_text(encoding="utf-8")
|
|
finally:
|
|
run_until_blocked.SESSION_PATH = original_session
|
|
run_until_blocked.TASKS_PATH = original_tasks
|
|
run_until_blocked.run_trading_delivery_rehearsal = original_runner
|
|
|
|
self.assertEqual(summary["status"], "stopped")
|
|
self.assertEqual(summary["completed_stages"], ["P0-1", "P0-2"])
|
|
self.assertEqual(summary["current_stage"], "P0-3")
|
|
self.assertIn("### `P0-3` 盘中持续调度与运行监督", session_text)
|
|
self.assertIn("- [x] `P0-2` 真实账户与订单只读同步", tasks_text)
|
|
self.assertIn("- [ ] `P0-3` 盘中持续调度与运行监督", tasks_text)
|
|
|
|
def test_stops_when_rehearsal_reports_blocked(self) -> None:
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
session_path = Path(tmp) / "SESSION.md"
|
|
tasks_path = Path(tmp) / "TASKS.md"
|
|
session_path.write_text("# ai-exchange Session\n\n### `P0-2` 真实账户与订单只读同步\n", encoding="utf-8")
|
|
tasks_path.write_text("# ai-exchange Tasks\n\n## Done\n\n", encoding="utf-8")
|
|
|
|
original_session = run_until_blocked.SESSION_PATH
|
|
original_tasks = run_until_blocked.TASKS_PATH
|
|
original_runner = run_until_blocked.run_trading_delivery_rehearsal
|
|
try:
|
|
run_until_blocked.SESSION_PATH = session_path
|
|
run_until_blocked.TASKS_PATH = tasks_path
|
|
run_until_blocked.run_trading_delivery_rehearsal = lambda **kwargs: {
|
|
"mode": "autonomous_cycle_failed",
|
|
"passed": False,
|
|
"continuation_decision": "blocked",
|
|
"promotion_decision": "hold",
|
|
"blockers": ["缺凭据:broker_readonly_api_key"],
|
|
}
|
|
|
|
summary = run_until_blocked.run_until_blocked(env={"RUN_UNTIL_BLOCKED_MAX_ROUNDS": "2"})
|
|
tasks_text = tasks_path.read_text(encoding="utf-8")
|
|
session_text = session_path.read_text(encoding="utf-8")
|
|
finally:
|
|
run_until_blocked.SESSION_PATH = original_session
|
|
run_until_blocked.TASKS_PATH = original_tasks
|
|
run_until_blocked.run_trading_delivery_rehearsal = original_runner
|
|
|
|
self.assertEqual(summary["status"], "blocked")
|
|
self.assertEqual(summary["blocked_reason"], "缺凭据:broker_readonly_api_key")
|
|
self.assertEqual(summary["current_stage"], "P0-2")
|
|
self.assertIn("缺凭据:broker_readonly_api_key", tasks_text)
|
|
self.assertIn("当前自动化已阻塞:缺凭据:broker_readonly_api_key", session_text)
|
|
|
|
def test_json_output_is_machine_readable(self) -> None:
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
session_path = Path(tmp) / "SESSION.md"
|
|
tasks_path = Path(tmp) / "TASKS.md"
|
|
session_path.write_text("# ai-exchange Session\n\n### `P0-2` 真实账户与订单只读同步\n", encoding="utf-8")
|
|
tasks_path.write_text("# ai-exchange Tasks\n\n## Done\n\n", encoding="utf-8")
|
|
|
|
original_session = run_until_blocked.SESSION_PATH
|
|
original_tasks = run_until_blocked.TASKS_PATH
|
|
original_runner = run_until_blocked.run_trading_delivery_rehearsal
|
|
try:
|
|
run_until_blocked.SESSION_PATH = session_path
|
|
run_until_blocked.TASKS_PATH = tasks_path
|
|
run_until_blocked.run_trading_delivery_rehearsal = lambda **kwargs: {
|
|
"mode": "autonomous_cycle_failed",
|
|
"passed": False,
|
|
"continuation_decision": "blocked",
|
|
"promotion_decision": "hold",
|
|
"blockers": ["缺凭据:broker_readonly_api_key"],
|
|
}
|
|
output = io.StringIO()
|
|
with contextlib.redirect_stdout(output):
|
|
summary = run_until_blocked.run_until_blocked(env={"RUN_UNTIL_BLOCKED_OUTPUT_JSON": "1"})
|
|
finally:
|
|
run_until_blocked.SESSION_PATH = original_session
|
|
run_until_blocked.TASKS_PATH = original_tasks
|
|
run_until_blocked.run_trading_delivery_rehearsal = original_runner
|
|
|
|
payload = json.loads(output.getvalue())
|
|
self.assertEqual(payload["status"], "blocked")
|
|
self.assertEqual(payload["blocked_reason"], "缺凭据:broker_readonly_api_key")
|
|
self.assertEqual(summary["status"], "blocked")
|
|
|
|
def test_run_until_blocked_writes_runner_status_file(self) -> None:
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
session_path = Path(tmp) / "SESSION.md"
|
|
tasks_path = Path(tmp) / "TASKS.md"
|
|
status_path = Path(tmp) / "automation_status.json"
|
|
session_path.write_text("# ai-exchange Session\n\n### `P0-2` 真实账户与订单只读同步\n", encoding="utf-8")
|
|
tasks_path.write_text("# ai-exchange Tasks\n\n## Done\n\n", encoding="utf-8")
|
|
|
|
original_session = run_until_blocked.SESSION_PATH
|
|
original_tasks = run_until_blocked.TASKS_PATH
|
|
original_status = run_until_blocked.RUNNER_STATUS_PATH
|
|
original_runner = run_until_blocked.run_trading_delivery_rehearsal
|
|
try:
|
|
run_until_blocked.SESSION_PATH = session_path
|
|
run_until_blocked.TASKS_PATH = tasks_path
|
|
run_until_blocked.RUNNER_STATUS_PATH = status_path
|
|
run_until_blocked.run_trading_delivery_rehearsal = lambda **kwargs: {
|
|
"mode": "autonomous_cycle_failed",
|
|
"passed": False,
|
|
"continuation_decision": "blocked",
|
|
"promotion_decision": "hold",
|
|
"blockers": ["缺凭据:broker_readonly_api_key"],
|
|
}
|
|
run_until_blocked.run_until_blocked(env={})
|
|
payload = json.loads(status_path.read_text(encoding="utf-8"))
|
|
finally:
|
|
run_until_blocked.SESSION_PATH = original_session
|
|
run_until_blocked.TASKS_PATH = original_tasks
|
|
run_until_blocked.RUNNER_STATUS_PATH = original_status
|
|
run_until_blocked.run_trading_delivery_rehearsal = original_runner
|
|
|
|
self.assertEqual(payload["state"], "blocked")
|
|
self.assertEqual(payload["current_stage"], "P0-2")
|
|
self.assertEqual(payload["blocked_reason"], "缺凭据:broker_readonly_api_key")
|
|
|
|
def test_supervisor_pauses_on_code_fix_blocker(self) -> None:
|
|
sleeper = Mock()
|
|
original_run_until_blocked = run_until_blocked.run_until_blocked
|
|
original_fingerprint = run_until_blocked.compute_resume_fingerprint
|
|
try:
|
|
run_until_blocked.run_until_blocked = lambda **kwargs: {
|
|
"status": "blocked",
|
|
"started_ts": "2026-05-07T00:00:00+00:00",
|
|
"current_stage": "P0-2",
|
|
"completed_stages": ["P0-1"],
|
|
"blocked_reason": "autonomous_mode:pipeline_failed",
|
|
"rounds": [{"round": 1}],
|
|
}
|
|
run_until_blocked.compute_resume_fingerprint = lambda: "same"
|
|
result = run_until_blocked.run_supervised_until_stopped(
|
|
env={"RUN_UNTIL_BLOCKED_LOOP_SLEEP_SECONDS": "1", "RUN_UNTIL_BLOCKED_SUPERVISOR_CYCLES": "2"},
|
|
sleeper=sleeper,
|
|
)
|
|
finally:
|
|
run_until_blocked.run_until_blocked = original_run_until_blocked
|
|
run_until_blocked.compute_resume_fingerprint = original_fingerprint
|
|
|
|
sleeper.assert_called_once_with(1)
|
|
self.assertEqual(result["status"], "blocked")
|
|
|
|
def test_supervisor_resumes_after_fingerprint_change(self) -> None:
|
|
summaries = [
|
|
{
|
|
"status": "blocked",
|
|
"started_ts": "2026-05-07T00:00:00+00:00",
|
|
"current_stage": "P0-2",
|
|
"completed_stages": ["P0-1"],
|
|
"blocked_reason": "autonomous_mode:pipeline_failed",
|
|
"rounds": [{"round": 1}],
|
|
},
|
|
{
|
|
"status": "completed",
|
|
"started_ts": "2026-05-07T00:10:00+00:00",
|
|
"current_stage": None,
|
|
"completed_stages": ["P0-1", "P0-2"],
|
|
"blocked_reason": None,
|
|
"rounds": [{"round": 1}],
|
|
},
|
|
]
|
|
fingerprints = ["a", "a", "b", "b"]
|
|
sleeper = Mock()
|
|
original_run_until_blocked = run_until_blocked.run_until_blocked
|
|
original_fingerprint = run_until_blocked.compute_resume_fingerprint
|
|
try:
|
|
run_until_blocked.run_until_blocked = lambda **kwargs: summaries.pop(0)
|
|
run_until_blocked.compute_resume_fingerprint = lambda: fingerprints.pop(0)
|
|
result = run_until_blocked.run_supervised_until_stopped(
|
|
env={"RUN_UNTIL_BLOCKED_LOOP_SLEEP_SECONDS": "1", "RUN_UNTIL_BLOCKED_SUPERVISOR_CYCLES": "2"},
|
|
sleeper=sleeper,
|
|
)
|
|
finally:
|
|
run_until_blocked.run_until_blocked = original_run_until_blocked
|
|
run_until_blocked.compute_resume_fingerprint = original_fingerprint
|
|
|
|
sleeper.assert_called_once_with(1)
|
|
self.assertEqual(result["status"], "completed")
|
|
self.assertEqual(result["completed_stages"], ["P0-1", "P0-2"])
|
|
|
|
def test_main_uses_supervisor_mode_when_enabled(self) -> None:
|
|
original_run_until_blocked = run_until_blocked.run_until_blocked
|
|
original_supervisor = run_until_blocked.run_supervised_until_stopped
|
|
original_environ = dict(os.environ)
|
|
try:
|
|
os.environ["RUN_UNTIL_BLOCKED_SUPERVISOR"] = "1"
|
|
run_until_blocked.run_until_blocked = lambda **kwargs: (_ for _ in ()).throw(AssertionError("should not call one-shot runner"))
|
|
run_until_blocked.run_supervised_until_stopped = lambda **kwargs: {"status": "completed"}
|
|
exit_code = run_until_blocked.main()
|
|
finally:
|
|
os.environ.clear()
|
|
os.environ.update(original_environ)
|
|
run_until_blocked.run_until_blocked = original_run_until_blocked
|
|
run_until_blocked.run_supervised_until_stopped = original_supervisor
|
|
|
|
self.assertEqual(exit_code, 0)
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|