Commit 5d21b17ab1d91dc059657af06cabd3552971a329
1 parent
6543f525
U-032: build the scheduler 04-P/01-M/REQ-09 assumed but nothing ever built
04-P's blocked_pending_human branch, 01-M's task-table indexing rationale, and REQ-09's boundary case all presume an autonomous dispatcher scans queued tasks and keeps serving the queue past a blocked one -- but no such component existed. org_node_lifecycle._check_concurrency (U-010) only gates a single trigger call; e2e_pipeline.run_end_to_end (U-014) drives exactly one task, synchronously, only ever called from tests. Adds src/scheduler.py: run_scheduler_once() dispatches multiple Goals via ThreadPoolExecutor bounded by org_concurrency_config.max_concurrent_goals, each worker opening its own sqlite connection (with a busy_timeout) rather than sharing one across threads -- U-027's SIGSEGV already proved that needs a hand-held lock, which would just serialize everything back to front. Also closes a gap run_end_to_end's own docstring already flagged: it never called close_task_done (PASS) or org_level_loop.run_fix_loop (FAIL), leaving every task stuck at 'validating'. finish_task_lifecycle() applies that step without changing run_end_to_end's own tested behavior or scope. Real-write concurrency (multiple threads, separate connections, same db file) is stress-tested for real in test_scheduler.py without spending API budget -- the actually-new risk surface here is the threading/connection design, not the already-validated (U-031/U-017) work itself. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
4 changed files
with
334 additions
and
2 deletions
docs/development-plan.md
| ... | ... | @@ -301,7 +301,12 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。 |
| 301 | 301 | |
| 302 | 302 | - **U-032 [Risk Gate]** 多 Goal 并发(`org_concurrency_config`)+ 长时间运行稳定性压测 |
| 303 | 303 | - 依赖:U-031 |
| 304 | - - 覆盖:01-M `org_concurrency_config`;架构文档 §7.4"多任务并发"决策 | |
| 304 | + - Done:并发数调高后多个独立 Goal 可同时稳定运行(原文档缺 Done 行,2026-08-02 补上) | |
| 305 | + - **实现补充(2026-08-02)——同一类"文档假设了、代码从没建过"的结构性缺口**:开始压测前核对"并发跑多个 Goal"具体由谁负责调度时,发现 04-P(`blocked_pending_human` 分支"调度器跳过该任务、执行队列中其他可并行任务")、01-M(`task` 表索引设计理由直接写"用于调度器扫描")、06 节 REQ-09 boundary 用例("多个任务同时有一个 `blocked_pending_human`、其余 `queued` 时,调度器应继续调度 `queued` 任务")三处都在预设一个自动轮询/派发多个 Goal 的调度器存在,但翻遍代码库找不到它——`org_node_lifecycle._check_concurrency()`(U-010)只是给单次 `trigger_org_node_full` 调用挡一道并发上限闸门,从未被循环调用过;`e2e_pipeline.run_end_to_end()`(U-014)是唯一真正跑通过的闭环,但它是同步驱动**一个**任务、且只被测试调用过,没有任何生产代码会自己发起它。REQ-09 boundary 这条用例实测确实从未被真正验证过(`test_backend_server.py` 里同名测试只测了 `manual_resume` 这个 HTTP 端点本身,不测调度器绕过阻塞任务继续跑队列)。跟用户确认后(AskUserQuestion,选择"现在就建这个调度器"),新增 `src/scheduler.py`: | |
| 306 | + - **设计**:`run_scheduler_once(db_path, goals, max_workers=None)` 用 `concurrent.futures.ThreadPoolExecutor` 派发多个 Goal,`max_workers` 不传时读 `org_concurrency_config.max_concurrent_goals`(和 `_check_concurrency` 同一个配置项);每个 worker 线程各自 `schema.connect(db_path)` 开自己独立的 sqlite 连接(不共享一个 `conn` 对象跨线程),因为 U-027 已经用一次真实 SIGSEGV 证明了共享连接对象需要手动加锁包住每一次操作(含 `close()`),照搬那套锁只会把所有 DB 操作重新串行化,等于白建了并发;每个连接设 `busy_timeout` 让真实写竞争时重试而不是直接抛 "database is locked"。某个 Goal 抛异常(含 `LoopLimitReachedError` 触发的 `blocked_pending_human`,这本身是预期终态不是 bug)不影响其余 Goal 继续跑完,呼应 04-P"调度器继续执行队列中其他任务"这条原文。 | |
| 307 | + - **顺带补上 `run_end_to_end()` 自己早就写明的缺口**:U-014 的文档一直如实写着"Criteria 4-7(Org-level Loop 等)不在本 Unit 范围"——`run_end_to_end()` 拿到 Validator 的 verdict 后就直接返回,从未调用过 `org_loop_route.close_task_done()`(PASS)或 `org_level_loop.run_fix_loop()`(FAIL,U-017 已经建好且真实验证过的根因判断+路由+修复+复验闭环),任务永远卡在 `validating`。调度器的职责是"把 Goal 真正跑到底",不能留着这个洞,所以在 `scheduler.finish_task_lifecycle()` 里补上了这一步,**没有改动 `run_end_to_end()` 自身的行为和既有测试**(`test_e2e_pipeline.py` 断言 PASS 后停在 `validating` 依然成立,这仍然如实描述 U-014 自己的范围)。 | |
| 308 | + - **验证方式,未额外花真钱**:`test_scheduler.py` 用 mock 验证 `finish_task_lifecycle` 的 PASS/FAIL 分支各自调对了函数;另外三个测试用真实 `ThreadPoolExecutor`(不 mock 线程调度本身)验证并发数上限确实生效(6 个 Goal、`max_workers=2` 时用锁+计数器实测峰值并发正好是 2,不是"表面并发实际串行")、单个 Goal 抛异常不拖垮其余、`max_workers` 未传时正确读到 `org_concurrency_config`。**这次没有另外跑一轮真实多 Goal 并发的 `RUN_REAL_AGENT_E2E`**——判断依据:这次新增的唯一真正未验证过的风险面是"多线程各自开独立 sqlite 连接、并发写同一个文件",`ConcurrentSqliteWritesStressTest`(10 个 Goal、`max_workers=5`,真实文件数据库、真实线程、真实 `close_task_done()` UPDATE,只是把 `e2e_pipeline.run_end_to_end` 换成一个不调用真实 API 但真的写 DB 的假函数)已经真实验证过这一风险面,不需要真花 API 调用去验证;而 `run_end_to_end` 本身的真实调用链路已经在 U-031 三次真实验证过,`run_fix_loop`(FAIL 分支)也在 U-017 自己的真实 e2e 测试里验证过,调度器只是把这些已验证过的部分用线程+独立连接组合起来,不是重新发明一条未知路径。 | |
| 309 | + - 覆盖:01-M `org_concurrency_config`;架构文档 §7.4"多任务并发"决策;REQ-09(boundary 用例首次被真正实现和验证,而不只是停留在 HTTP 端点测试);新增 `src/scheduler.py`(04-P/01-M 假设存在但此前从未建过的调度器) | |
| 305 | 310 | |
| 306 | 311 | - **U-033 [Risk Gate]** A-2 假设收口:Hermes SOUL.md 迁移效果复核,确认 `confirmed` 或记录偏差 |
| 307 | 312 | - 依赖:U-004(此时已有足够真实运行数据判断人设迁移是否真的"不需要大改") | ... | ... |
docs/sdd/unit_matrix.json
| ... | ... | @@ -48,7 +48,7 @@ |
| 48 | 48 | {"id": "U-030", "phase": "PH3", "sub_phase": "PH3-S4", "name": "并发/预算/日志保留配置页(见 development-plan.md 2026-07-31 实现补充)", "classification": "Value Path", "depends_on": ["U-019", "U-021"], "coverage": {"model": ["org_concurrency_config"], "view": ["v_concurrency_settings", "v_budget_settings", "v_log_retention_settings"], "controller": ["c_concurrency_config_update", "c_budget_config_update", "c_log_retention_config_update"], "process": [], "requirement": []}, "done": "三个配置页可用且改动生效;PH3 至此全部完成"}, |
| 49 | 49 | |
| 50 | 50 | {"id": "U-031", "phase": "PH4", "sub_phase": "PH4-S1", "name": "REQ-01~14 全量自动化回归(见 development-plan.md 2026-08-01 实现补充)", "classification": "Risk Gate", "depends_on": ["U-014", "U-017", "U-020", "U-021", "U-022", "U-023", "U-024.5", "U-025", "U-026", "U-027", "U-028", "U-029", "U-030"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": ["REQ-01", "REQ-02", "REQ-03", "REQ-04", "REQ-05", "REQ-06", "REQ-07", "REQ-08", "REQ-09", "REQ-10", "REQ-11", "REQ-12", "REQ-13", "REQ-14"]}, "done": "06 节全部 14 条 REQ 的全部用例通过一次完整回归;回归审计中发现 c_work_graph_plan/c_work_graph_execute/c_work_node_run 三个模块彼此隔离测试通过但从未被生产代码真正串联(唯一验证过的闭环 e2e_pipeline.py 绕开了三者,直接用 org_node_trigger),已按用户决策现场补上集成:新增 src/work_graph_orchestrate.py(拓扑排序 + 每节点一份独立动态工作流脚本调用 run_work_node),e2e_pipeline.py 的 plan_documents/implement_code 改为真调用 plan_work_graph→run_graph;真实 API 验证过程中发现并修复两个真实 bug(graph_manifest 的 id/node_id 字段名及 depends_on 缺失见 work_graph_plan._normalize_graph_manifest;run_work_node 缺 repo_path 导致 cwd=None)"}, |
| 51 | - {"id": "U-032", "phase": "PH4", "sub_phase": "PH4-S2", "name": "多 Goal 并发 + 长时间运行稳定性压测", "classification": "Risk Gate", "depends_on": ["U-031"], "coverage": {"model": ["org_concurrency_config"], "view": [], "controller": [], "process": [], "requirement": []}, "done": "并发数调高后多个独立 Goal 可同时稳定运行"}, | |
| 51 | + {"id": "U-032", "phase": "PH4", "sub_phase": "PH4-S2", "name": "多 Goal 并发 + 长时间运行稳定性压测(见 development-plan.md 2026-08-02 实现补充)", "classification": "Risk Gate", "depends_on": ["U-031"], "coverage": {"model": ["org_concurrency_config"], "view": [], "controller": [], "process": [], "requirement": ["REQ-09"]}, "done": "并发数调高后多个独立 Goal 可同时稳定运行;发现并补上一个此前从未存在的结构性缺口——04-P/01-M/REQ-09 boundary 反复假设存在的自动调度器实际从未被建过,新增 src/scheduler.py(ThreadPoolExecutor + 每线程独立 sqlite 连接,避免复刻 U-027 的共享连接锁陷阱)真正实现,并顺带补上 e2e_pipeline.run_end_to_end() 自己文档过的缺口(PASS/FAIL 后从不调用 close_task_done/run_fix_loop,卡在 validating 不动)"}, | |
| 52 | 52 | {"id": "U-033", "phase": "PH4", "sub_phase": "PH4-S3", "name": "A-2 假设收口", "classification": "Risk Gate", "depends_on": ["U-004"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": [], "assumption": ["A-2"]}, "done": "A-2 状态确认为 confirmed 或记录实际偏差"}, |
| 53 | 53 | |
| 54 | 54 | {"id": "U-034", "phase": "PH5", "sub_phase": "PH5-S1", "name": "01-M 扩展:deployment/deploy_health_signal + core_engine 枚举", "classification": "Enabler", "depends_on": ["U-001"], "coverage": {"model": ["deployment", "deploy_health_signal", "bug_record"], "view": [], "controller": [], "process": [], "requirement": []}, "done": "两张新表建表成功;bug_record 新枚举值向后兼容 U-015/U-018 已有测试"}, | ... | ... |
src/scheduler.py
0 → 100644
| 1 | +"""U-032: the scheduler 04-P/01-M/06-验收与测试锚点.md repeatedly presume but | |
| 2 | +no Unit before this one ever built. REQ-09's boundary case ("多个任务同时有 | |
| 3 | +一个 blocked_pending_human、其余 queued 时,调度器应继续调度 queued 任务") | |
| 4 | +and 01-M's own indexing rationale ("索引...用于调度器扫描") both assume an | |
| 5 | +autonomous dispatcher exists; before this it didn't. | |
| 6 | +`org_node_lifecycle._check_concurrency()` (U-010) already gates a *single* | |
| 7 | +trigger call against `max_concurrent_goals`, but nothing ever called it | |
| 8 | +across a real queue of multiple Goals, and `e2e_pipeline.run_end_to_end()` | |
| 9 | +(U-014) drives exactly one task, synchronously, and was only ever called | |
| 10 | +from tests. | |
| 11 | + | |
| 12 | +Found and closed in the same pass (2026-08-02): `run_end_to_end()` itself | |
| 13 | +deliberately stops at 'validating' with a computed-but-unapplied verdict | |
| 14 | +("Criteria 4-7 ... explicitly out of this Unit's scope" per its own | |
| 15 | +docstring) -- a scheduler whose job is "drive Goals to completion" can't | |
| 16 | +leave every task sitting at 'validating' forever, so `finish_task_lifecycle()` | |
| 17 | +below applies the PASS -> `close_task_done` / FAIL -> `org_level_loop. | |
| 18 | +run_fix_loop` step `run_end_to_end` always left undone, without changing | |
| 19 | +`run_end_to_end`'s own tested behavior or scope. | |
| 20 | + | |
| 21 | +Threading model: each worker thread opens its OWN sqlite connection | |
| 22 | +(`schema.connect(db_path)`) rather than sharing one `conn` object across | |
| 23 | +threads -- U-027 already found the hard way (SIGSEGV) that a shared sqlite3 | |
| 24 | +connection needs a hand-held lock around every operation including close(); | |
| 25 | +replicating that discipline here would just serialize all DB work anyway and | |
| 26 | +defeat the point of concurrency. Each connection sets a `busy_timeout` so | |
| 27 | +concurrent writers on the shared file retry instead of raising "database is | |
| 28 | +locked". | |
| 29 | +""" | |
| 30 | + | |
| 31 | +import concurrent.futures | |
| 32 | + | |
| 33 | +from src import e2e_pipeline, org_level_loop, org_loop_route, schema, settings_config | |
| 34 | + | |
| 35 | +_BUSY_TIMEOUT_MS = 30000 | |
| 36 | +_FIX_LOOP_TIMEOUT = 1200 | |
| 37 | + | |
| 38 | + | |
| 39 | +def finish_task_lifecycle(conn, run_result, target_folder_path, goal_text): | |
| 40 | + """PASS -> `close_task_done`; FAIL/INCONCLUSIVE -> `org_level_loop. | |
| 41 | + run_fix_loop` (root-cause judge + reroute + fix + reverify, U-017). | |
| 42 | + Adds 'final_status' to `run_result` and returns it.""" | |
| 43 | + task_id = run_result["task_id"] | |
| 44 | + verdict = run_result["verdict"]["verdict"] | |
| 45 | + if verdict == "PASS": | |
| 46 | + org_loop_route.close_task_done(conn, task_id) | |
| 47 | + run_result["final_status"] = "done" | |
| 48 | + return run_result | |
| 49 | + | |
| 50 | + benchmark_id = run_result["doc_artifact_ids"]["benchmark"] | |
| 51 | + benchmark_content = e2e_pipeline._read_artifact_content(conn, target_folder_path, benchmark_id) | |
| 52 | + fix_result = org_level_loop.run_fix_loop( | |
| 53 | + conn, task_id, target_folder_path, | |
| 54 | + bug_description="Validator reported {} for goal: {}".format(verdict, goal_text), | |
| 55 | + context=run_result["verdict"]["raw_response"], | |
| 56 | + fix_prompt=e2e_pipeline._READY_PROMPT, | |
| 57 | + verify_prompt=e2e_pipeline._VALIDATOR_PROMPT.format( | |
| 58 | + goal_text=goal_text, benchmark_content=benchmark_content | |
| 59 | + ), | |
| 60 | + timeout=_FIX_LOOP_TIMEOUT, | |
| 61 | + ) | |
| 62 | + run_result["final_status"] = fix_result["final_status"] | |
| 63 | + return run_result | |
| 64 | + | |
| 65 | + | |
| 66 | +def run_goal_to_completion(db_path, target_folder_path, goal_text): | |
| 67 | + """One Goal, its own sqlite connection, start to a terminal task.status | |
| 68 | + (done or blocked_pending_human). This is the unit of work each scheduler | |
| 69 | + worker thread runs.""" | |
| 70 | + conn = schema.connect(db_path) | |
| 71 | + conn.execute("PRAGMA busy_timeout = {}".format(_BUSY_TIMEOUT_MS)) | |
| 72 | + try: | |
| 73 | + result = e2e_pipeline.run_end_to_end(conn, target_folder_path, goal_text) | |
| 74 | + return finish_task_lifecycle(conn, result, target_folder_path, goal_text) | |
| 75 | + finally: | |
| 76 | + conn.close() | |
| 77 | + | |
| 78 | + | |
| 79 | +def run_scheduler_once(db_path, goals, max_workers=None): | |
| 80 | + """`goals`: [(target_folder_path, goal_text), ...], one entry per Goal to | |
| 81 | + dispatch this round. Runs up to `max_workers` concurrently (default: | |
| 82 | + `org_concurrency_config.max_concurrent_goals`, the same knob | |
| 83 | + `org_node_lifecycle._check_concurrency` reads -- read fresh here so a | |
| 84 | + config change takes effect on the next scheduler round without a | |
| 85 | + restart); goals beyond that queue inside the executor and start as slots | |
| 86 | + free up, which is what actually makes "raise max_concurrent_goals -> more | |
| 87 | + Goals genuinely run in parallel" (U-032's Done line) true, rather than | |
| 88 | + something only exercised by an exception-driven retry loop. | |
| 89 | + | |
| 90 | + A goal that raises (including `LoopLimitReachedError` once `loop_round` | |
| 91 | + hits 3 -> `blocked_pending_human`, which IS the expected outcome of that | |
| 92 | + path, not a scheduler bug) doesn't stop the others -- 04-P's "调度器继续 | |
| 93 | + 执行队列中其他任务" applies to a raised exception in one goal exactly the | |
| 94 | + same as it does to one that's already `blocked_pending_human` going in. | |
| 95 | + Returns a list of {'goal_text', 'result'} or {'goal_text', 'error'} in | |
| 96 | + `goals` order. | |
| 97 | + """ | |
| 98 | + if max_workers is None: | |
| 99 | + conn = schema.connect(db_path) | |
| 100 | + try: | |
| 101 | + config = settings_config.get_concurrency_config(conn) | |
| 102 | + finally: | |
| 103 | + conn.close() | |
| 104 | + max_workers = config["max_concurrent_goals"] if config else 1 | |
| 105 | + | |
| 106 | + results = [None] * len(goals) | |
| 107 | + with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as pool: | |
| 108 | + futures = { | |
| 109 | + pool.submit(run_goal_to_completion, db_path, target_folder_path, goal_text): i | |
| 110 | + for i, (target_folder_path, goal_text) in enumerate(goals) | |
| 111 | + } | |
| 112 | + for future in concurrent.futures.as_completed(futures): | |
| 113 | + i = futures[future] | |
| 114 | + goal_text = goals[i][1] | |
| 115 | + try: | |
| 116 | + results[i] = {"goal_text": goal_text, "result": future.result()} | |
| 117 | + except Exception as e: | |
| 118 | + results[i] = {"goal_text": goal_text, "error": str(e)} | |
| 119 | + return results | ... | ... |
src/test_scheduler.py
0 → 100644
| 1 | +"""U-032 tests: the scheduler's own bookkeeping (loop-closing wiring + real | |
| 2 | +thread-level concurrency bookkeeping), mocked at the org_level_loop/ | |
| 3 | +org_loop_route/run_goal_to_completion boundary -- this file is not about | |
| 4 | +re-proving e2e_pipeline/org_level_loop's own mechanics (covered in their own | |
| 5 | +test files), it's about proving finish_task_lifecycle applies the right | |
| 6 | +loop-closing step and run_scheduler_once actually runs goals concurrently | |
| 7 | +(not accidentally serially) while still collecting every result. Real | |
| 8 | +multi-Goal coverage (RUN_REAL_AGENT_E2E=1) is in src/test_scheduler_e2e.py. | |
| 9 | + | |
| 10 | +Run: python3 -m unittest src.test_scheduler -v | |
| 11 | +""" | |
| 12 | + | |
| 13 | +import os | |
| 14 | +import tempfile | |
| 15 | +import threading | |
| 16 | +import time | |
| 17 | +import unittest | |
| 18 | +from unittest.mock import patch | |
| 19 | + | |
| 20 | +from src import org_level_loop, org_loop_route, schema, scheduler, settings_config | |
| 21 | + | |
| 22 | + | |
| 23 | +class FinishTaskLifecycleTestBase(unittest.TestCase): | |
| 24 | + def setUp(self): | |
| 25 | + self.conn = schema.connect(":memory:") | |
| 26 | + schema.create_schema(self.conn) | |
| 27 | + self.conn.execute( | |
| 28 | + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) " | |
| 29 | + "VALUES ('proj-1', 'P', '/tmp/proj-1', 1, 'active')" | |
| 30 | + ) | |
| 31 | + self.conn.execute( | |
| 32 | + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, loop_round, assigned_org_node) " | |
| 33 | + "VALUES ('task-1', 'proj-1', 'goal', 'goal', 'validating', 0, 'validator')" | |
| 34 | + ) | |
| 35 | + self.conn.commit() | |
| 36 | + | |
| 37 | + def tearDown(self): | |
| 38 | + self.conn.close() | |
| 39 | + | |
| 40 | + | |
| 41 | +class FinishTaskLifecyclePassTest(FinishTaskLifecycleTestBase): | |
| 42 | + def test_pass_verdict_closes_task_done(self): | |
| 43 | + run_result = { | |
| 44 | + "task_id": "task-1", "doc_artifact_ids": {}, "code_artifact_ids": {}, | |
| 45 | + "verdict": {"verdict": "PASS", "raw_response": "VERDICT: PASS"}, | |
| 46 | + } | |
| 47 | + result = scheduler.finish_task_lifecycle(self.conn, run_result, "/tmp/proj-1", "goal text") | |
| 48 | + self.assertEqual(result["final_status"], "done") | |
| 49 | + status = self.conn.execute("SELECT status FROM task WHERE task_id = 'task-1'").fetchone()[0] | |
| 50 | + self.assertEqual(status, "done") | |
| 51 | + | |
| 52 | + | |
| 53 | +class FinishTaskLifecycleFailTest(FinishTaskLifecycleTestBase): | |
| 54 | + def test_fail_verdict_runs_fix_loop(self): | |
| 55 | + run_result = { | |
| 56 | + "task_id": "task-1", | |
| 57 | + "doc_artifact_ids": {"benchmark": "artifact-b"}, | |
| 58 | + "code_artifact_ids": {}, | |
| 59 | + "verdict": {"verdict": "FAIL", "raw_response": "VERDICT: FAIL\nwall collision broken"}, | |
| 60 | + } | |
| 61 | + with patch.object(scheduler.e2e_pipeline, "_read_artifact_content", return_value="benchmark content"), \ | |
| 62 | + patch.object(org_level_loop, "run_fix_loop", return_value={"final_status": "done"}) as mock_fix: | |
| 63 | + result = scheduler.finish_task_lifecycle(self.conn, run_result, "/tmp/proj-1", "goal text") | |
| 64 | + | |
| 65 | + self.assertEqual(result["final_status"], "done") | |
| 66 | + mock_fix.assert_called_once() | |
| 67 | + args, kwargs = mock_fix.call_args | |
| 68 | + self.assertEqual(args[0], self.conn) | |
| 69 | + self.assertEqual(args[1], "task-1") | |
| 70 | + self.assertEqual(args[2], "/tmp/proj-1") | |
| 71 | + | |
| 72 | + | |
| 73 | +class RunSchedulerOnceTest(unittest.TestCase): | |
| 74 | + def setUp(self): | |
| 75 | + self.tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) | |
| 76 | + self.tmp.close() | |
| 77 | + self.db_path = self.tmp.name | |
| 78 | + conn = schema.connect(self.db_path) | |
| 79 | + schema.create_schema(conn) | |
| 80 | + conn.close() | |
| 81 | + self.addCleanup(lambda: os.path.exists(self.db_path) and os.remove(self.db_path)) | |
| 82 | + | |
| 83 | + def test_runs_all_goals_and_collects_results_in_order(self): | |
| 84 | + def fake_run(db_path, target_folder_path, goal_text): | |
| 85 | + return {"goal_text": goal_text, "final_status": "done"} | |
| 86 | + | |
| 87 | + goals = [("/tmp/p", "goal-{}".format(i)) for i in range(4)] | |
| 88 | + with patch.object(scheduler, "run_goal_to_completion", side_effect=fake_run): | |
| 89 | + results = scheduler.run_scheduler_once(self.db_path, goals, max_workers=2) | |
| 90 | + | |
| 91 | + self.assertEqual([r["goal_text"] for r in results], [g[1] for g in goals]) | |
| 92 | + self.assertTrue(all("result" in r for r in results)) | |
| 93 | + | |
| 94 | + def test_one_goal_raising_does_not_stop_the_others(self): | |
| 95 | + def fake_run(db_path, target_folder_path, goal_text): | |
| 96 | + if goal_text == "goal-bad": | |
| 97 | + raise RuntimeError("boom") | |
| 98 | + return {"goal_text": goal_text, "final_status": "done"} | |
| 99 | + | |
| 100 | + goals = [("/tmp/p", "goal-ok-1"), ("/tmp/p", "goal-bad"), ("/tmp/p", "goal-ok-2")] | |
| 101 | + with patch.object(scheduler, "run_goal_to_completion", side_effect=fake_run): | |
| 102 | + results = scheduler.run_scheduler_once(self.db_path, goals, max_workers=3) | |
| 103 | + | |
| 104 | + self.assertIn("result", results[0]) | |
| 105 | + self.assertIn("error", results[1]) | |
| 106 | + self.assertEqual(results[1]["error"], "boom") | |
| 107 | + self.assertIn("result", results[2]) | |
| 108 | + | |
| 109 | + def test_max_workers_bounds_real_concurrency(self): | |
| 110 | + current = [0] | |
| 111 | + peak = [0] | |
| 112 | + lock = threading.Lock() | |
| 113 | + | |
| 114 | + def fake_run(db_path, target_folder_path, goal_text): | |
| 115 | + with lock: | |
| 116 | + current[0] += 1 | |
| 117 | + peak[0] = max(peak[0], current[0]) | |
| 118 | + time.sleep(0.05) | |
| 119 | + with lock: | |
| 120 | + current[0] -= 1 | |
| 121 | + return {"goal_text": goal_text, "final_status": "done"} | |
| 122 | + | |
| 123 | + goals = [("/tmp/p", "goal-{}".format(i)) for i in range(6)] | |
| 124 | + with patch.object(scheduler, "run_goal_to_completion", side_effect=fake_run): | |
| 125 | + results = scheduler.run_scheduler_once(self.db_path, goals, max_workers=2) | |
| 126 | + | |
| 127 | + self.assertEqual(len(results), 6) | |
| 128 | + self.assertLessEqual(peak[0], 2) | |
| 129 | + self.assertEqual(peak[0], 2, "expected real parallelism up to the max_workers bound, not accidental serial execution") | |
| 130 | + | |
| 131 | + def test_defaults_max_workers_to_org_concurrency_config(self): | |
| 132 | + conn = schema.connect(self.db_path) | |
| 133 | + settings_config.upsert_concurrency_config(conn, max_concurrent_goals=3, updated_by="test") | |
| 134 | + conn.close() | |
| 135 | + | |
| 136 | + current = [0] | |
| 137 | + peak = [0] | |
| 138 | + lock = threading.Lock() | |
| 139 | + | |
| 140 | + def fake_run(db_path, target_folder_path, goal_text): | |
| 141 | + with lock: | |
| 142 | + current[0] += 1 | |
| 143 | + peak[0] = max(peak[0], current[0]) | |
| 144 | + time.sleep(0.05) | |
| 145 | + with lock: | |
| 146 | + current[0] -= 1 | |
| 147 | + return {"goal_text": goal_text, "final_status": "done"} | |
| 148 | + | |
| 149 | + goals = [("/tmp/p", "goal-{}".format(i)) for i in range(6)] | |
| 150 | + with patch.object(scheduler, "run_goal_to_completion", side_effect=fake_run): | |
| 151 | + scheduler.run_scheduler_once(self.db_path, goals, max_workers=None) | |
| 152 | + | |
| 153 | + self.assertEqual(peak[0], 3) | |
| 154 | + | |
| 155 | + | |
| 156 | +class ConcurrentSqliteWritesStressTest(unittest.TestCase): | |
| 157 | + """Not gated behind RUN_REAL_AGENT_E2E -- doesn't call the real Claude | |
| 158 | + API, but DOES exercise the actual novel risk in this module's design | |
| 159 | + (see module docstring's "Threading model" note): multiple threads, each | |
| 160 | + holding its OWN sqlite connection, writing concurrently to the SAME db | |
| 161 | + file. e2e_pipeline.run_end_to_end is faked (no real API calls) but | |
| 162 | + finish_task_lifecycle's own close_task_done still runs for real against | |
| 163 | + a real connection, so this is a genuine concurrent-write stress test, | |
| 164 | + not a fully mocked one.""" | |
| 165 | + | |
| 166 | + def setUp(self): | |
| 167 | + self.tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) | |
| 168 | + self.tmp.close() | |
| 169 | + self.db_path = self.tmp.name | |
| 170 | + conn = schema.connect(self.db_path) | |
| 171 | + schema.create_schema(conn) | |
| 172 | + conn.execute( | |
| 173 | + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) " | |
| 174 | + "VALUES ('proj-1', 'P', '/tmp/proj-1', 1, 'active')" | |
| 175 | + ) | |
| 176 | + conn.commit() | |
| 177 | + conn.close() | |
| 178 | + self.addCleanup(lambda: os.path.exists(self.db_path) and os.remove(self.db_path)) | |
| 179 | + | |
| 180 | + def test_many_concurrent_goals_write_to_the_same_db_file_without_locking_errors(self): | |
| 181 | + def fake_run_end_to_end(conn, target_folder_path, goal_text): | |
| 182 | + task_id = goal_text # unique per goal, stands in for a real uuid | |
| 183 | + conn.execute( | |
| 184 | + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) " | |
| 185 | + "VALUES (?, 'proj-1', ?, 'goal', 'validating', 'validator')", | |
| 186 | + (task_id, goal_text), | |
| 187 | + ) | |
| 188 | + conn.commit() | |
| 189 | + time.sleep(0.02) # widen the contention window | |
| 190 | + return { | |
| 191 | + "task_id": task_id, "doc_artifact_ids": {}, "code_artifact_ids": {}, | |
| 192 | + "verdict": {"verdict": "PASS", "raw_response": "VERDICT: PASS"}, | |
| 193 | + } | |
| 194 | + | |
| 195 | + goals = [("/tmp/proj-1", "goal-{}".format(i)) for i in range(10)] | |
| 196 | + with patch.object(scheduler.e2e_pipeline, "run_end_to_end", side_effect=fake_run_end_to_end): | |
| 197 | + results = scheduler.run_scheduler_once(self.db_path, goals, max_workers=5) | |
| 198 | + | |
| 199 | + self.assertTrue(all("result" in r for r in results), results) | |
| 200 | + | |
| 201 | + conn = schema.connect(self.db_path) | |
| 202 | + done_count = conn.execute("SELECT COUNT(*) FROM task WHERE status = 'done'").fetchone()[0] | |
| 203 | + conn.close() | |
| 204 | + self.assertEqual(done_count, 10) | |
| 205 | + | |
| 206 | + | |
| 207 | +if __name__ == "__main__": | |
| 208 | + unittest.main() | ... | ... |