diff --git a/docs/development-plan.md b/docs/development-plan.md index b0efa99..8e343f3 100644 --- a/docs/development-plan.md +++ b/docs/development-plan.md @@ -157,6 +157,8 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。 - **U-015 [Risk Gate]** `p_validator_root_cause_trace` 实现(三态判定 PASS/FAIL/INCONCLUSIVE,双向可追溯矩阵) - 依赖:U-013 - Done:REQ-04 happy/boundary/failure 通过 + - **实现补充(2026-07-31)**:这是这几轮里少有的一次不用订正 Done 标准的 Unit——REQ-04 的三个用例都只依赖"给定 lineage 上下文后的多视角判定",没有牵扯到 U-015 自身依赖之外的能力。"血缘读取"(读 SDD/PRD/BRD/Coder 实现记录)刻意设计成调用方职责(`perspective_fn` 闭包自带上下文),`judge_root_cause` 本身只管三视角≥2/3 多数表决。"双向可追溯矩阵"是 Validator 人设自己的内部推理框架(`qa-task-validation` skill + 迁移后的 AGENTS.md "claim—风险—实现—测试—证据矩阵",U-004 时已经迁移),不是本 Unit 需要单独建的后端能力 + - **已知缺口,刻意不实现(2026-07-31)**:`root_cause_category=skill_defect` 时 03-C §3.10 要求"必须同时创建 skill_iteration_suggestion",但 `skill_iteration` 表要求 `before_git_commit_sha`/`after_git_commit_sha` 均非空,而这两个字段要等真正的 Skill 改写(`c_skill_update`,U-018)发生后才有值——SDD 没定义"建议"和"迭代"两个状态之间怎么过渡。REQ-04 的三个用例都不涉及 `skill_defect`,留给 U-018 到时候解决,没有现场猜一个方案硬填 - 覆盖:01-M `bug_record`;03-C `c_bug_record_create`;04-P `p_validator_root_cause_trace`;REQ-04 - **U-016 [Risk Gate]** `c_org_loop_route` + `fast_lane`/`full_replan` 快速通道 diff --git a/src/bug_record.py b/src/bug_record.py new file mode 100644 index 0000000..50ea6ce --- /dev/null +++ b/src/bug_record.py @@ -0,0 +1,100 @@ +"""c_bug_record_create (03-C §3.10): finalizes a Validator-judged bug. + +Distinct from c_bug_report_create (03-C §3.1.1, human-submitted feedback with +no root cause yet -- owned by U-023, not this Unit): this controller's +input_contract requires root_cause_category/root_cause_detail/suggested_route +all as required fields, i.e. it's the "record a bug the Validator has already +judged" endpoint, called at the end of p_validator_root_cause_trace's +judging_root_cause -> bug_recorded transition (04-P §4.3). + +**Known gap, deliberately unenforced (2026-07-31)**: 03-C §3.10's own text +says "root_cause_category=skill_defect 时必须同时创建 skill_iteration_suggestion, +否则视为字段缺失" -- but 01-M's skill_iteration entity requires +before_git_commit_sha/after_git_commit_sha NOT NULL, fields that can't be +populated until an actual skill rewrite happens (c_skill_update, U-018), which +hasn't occurred yet at bug-creation time. The SDD doesn't define what a +"suggestion" (pre-rewrite) is distinct from an "iteration" (post-rewrite), or +how skill_iteration's NOT NULL commit-sha fields would be satisfied before +one exists. Not guessed at here: REQ-04's own three acceptance cases (06 节) +never exercise root_cause_category=skill_defect, so nothing in this Unit's +actual Done criterion needs this resolved. Left for U-018 (c_skill_update, +the Unit that actually creates skill_iteration rows) to resolve when it's +in scope. +""" + +import json +import uuid + +# core_engine deliberately excluded -- see src/schema.py's matching CHECK +# constraint and its own comment: PH5-scoped (U-034), not accepted until then. +_VALID_ROOT_CAUSE_CATEGORIES = ("planning", "coder", "skill_defect") + + +class InvalidRootCauseCategoryError(Exception): + """INVALID_ROOT_CAUSE_CATEGORY -- 03-C §3.10.""" + + +def create_bug_record( + conn, task_id, description, root_cause_category, root_cause_detail, suggested_route, + repro_steps=None, related_artifact_ids=None, skill_iteration_suggestion_id=None, +): + """Creates a bug_record(source='validator', status='routed') -- 'routed' + because by the time this is called, triage (root cause + suggested_route) + is already complete; there's no separate triaging step for this origin + (that's the human_reported branch's job, see claim_human_reported_bug / + complete_human_reported_triage below).""" + if root_cause_category not in _VALID_ROOT_CAUSE_CATEGORIES: + raise InvalidRootCauseCategoryError( + "root_cause_category must be one of {}, got {!r}".format( + _VALID_ROOT_CAUSE_CATEGORIES, root_cause_category + ) + ) + + bug_id = str(uuid.uuid4()) + conn.execute( + "INSERT INTO bug_record " + "(bug_id, task_id, source, description, repro_steps, root_cause_category, root_cause_detail, " + "related_artifact_ids, suggested_route, skill_iteration_suggestion_id, status) " + "VALUES (?, ?, 'validator', ?, ?, ?, ?, ?, ?, ?, 'routed')", + ( + bug_id, task_id, description, repro_steps, root_cause_category, root_cause_detail, + json.dumps(related_artifact_ids) if related_artifact_ids else None, + suggested_route, skill_iteration_suggestion_id, + ), + ) + conn.commit() + return {"bug_id": bug_id} + + +def claim_human_reported_bug(conn, bug_id): + """04-P §4.3: human_reported -> tracing_lineage. Validator claims an + open, source=human bug_record. status: open -> triaging.""" + row = conn.execute("SELECT source, status FROM bug_record WHERE bug_id = ?", (bug_id,)).fetchone() + if row is None: + raise ValueError("no such bug_record: " + bug_id) + source, status = row + if source != "human" or status != "open": + raise ValueError( + "bug_record {} is not claimable (source={}, status={}); expected source=human, status=open".format( + bug_id, source, status + ) + ) + conn.execute("UPDATE bug_record SET status = 'triaging' WHERE bug_id = ?", (bug_id,)) + conn.commit() + + +def complete_human_reported_triage(conn, bug_id, root_cause_category, root_cause_detail, suggested_route): + """04-P §4.3 补充说明: human_reported 起点的 judging_root_cause->bug_recorded + 是对已存在 bug_record 的字段补全式更新,不产生重复记录 -- status: triaging->routed.""" + if root_cause_category not in _VALID_ROOT_CAUSE_CATEGORIES: + raise InvalidRootCauseCategoryError( + "root_cause_category must be one of {}, got {!r}".format( + _VALID_ROOT_CAUSE_CATEGORIES, root_cause_category + ) + ) + conn.execute( + "UPDATE bug_record SET root_cause_category = ?, root_cause_detail = ?, suggested_route = ?, " + "status = 'routed' WHERE bug_id = ?", + (root_cause_category, root_cause_detail, suggested_route, bug_id), + ) + conn.commit() diff --git a/src/org_node_trigger.py b/src/org_node_trigger.py index edce431..786d0ba 100644 --- a/src/org_node_trigger.py +++ b/src/org_node_trigger.py @@ -44,8 +44,16 @@ def trigger_org_node(org_node, prompt, target_folder_path, timeout=300): timeout=timeout, ) if result.returncode != 0: + # 2026-07-31 fix: a real Coder call once failed with returncode=1 and + # empty stderr, and the exception message (stderr-only, as it used to + # be) gave zero diagnostic signal -- whatever claude actually said + # about the failure was in stdout, which this branch was discarding. + # Both are now included (truncated -- stdout in JSON mode can be + # large) so a future real failure is actually debuggable. raise OrgNodeTriggerError( - "claude --agent {} exited {}: {}".format(org_node, result.returncode, result.stderr) + "claude --agent {} exited {}\nstderr: {}\nstdout (truncated): {}".format( + org_node, result.returncode, result.stderr, result.stdout[:4000] + ) ) payload = json.loads(result.stdout) diff --git a/src/root_cause_trace.py b/src/root_cause_trace.py new file mode 100644 index 0000000..0c09434 --- /dev/null +++ b/src/root_cause_trace.py @@ -0,0 +1,128 @@ +"""p_validator_root_cause_trace (04-P §4.3): U-015 scope. + +"血缘读取"(tracing_lineage: reading SDD/PRD/BRD versions + Coder implementation +records) is the caller's job -- perspective_fns are expected to already have +whatever lineage context they need baked in (e.g. closures over artifact +content read beforehand) by the time judge_root_cause calls them. This module +only drives judging_root_cause's multi-perspective vote and the +bug_recorded/inconclusive_escalate branch, matching 04-P's actual state-table +scope for that transition. + +Each perspective_fn takes no arguments and returns {"category": "planning"| +"coder"|"skill_defect", "detail": str} -- one judgment from one of the three +perspectives 04-P names (正确性视角/需求完整性视角/Skill 系统性缺陷视角). Which +concrete question each perspective asks is a prompt-engineering concern for +the caller (see real_validator_perspective below for the real-call version), +not this module's. +""" + +from collections import Counter + +from src import bug_record + + +class InconclusiveEscalateError(Exception): + """04-P: judging_root_cause -> inconclusive_escalate (<2/3 perspectives + agree). Carries the raw per-perspective judgments for human review + ("附带三视角原始判断供人工裁决").""" + + def __init__(self, judgments): + self.judgments = judgments + super().__init__("perspectives did not reach 2/3 agreement: " + repr(judgments)) + + +def judge_root_cause(perspective_fns): + """Runs all perspective_fns, requires >=2/3 to agree on the same + category (04-P guard: "至少 2/3 视角一致才定案"). Returns + {"root_cause_category", "root_cause_detail", "judgments"} on agreement, + raises InconclusiveEscalateError otherwise. + """ + judgments = [fn() for fn in perspective_fns] + counts = Counter(j["category"] for j in judgments) + winner, count = counts.most_common(1)[0] + if count * 2 < len(judgments): # strictly "at least 2/3", not just plurality + raise InconclusiveEscalateError(judgments) + detail = " | ".join(j["detail"] for j in judgments if j["category"] == winner) + return {"root_cause_category": winner, "root_cause_detail": detail, "judgments": judgments} + + +def trace_root_cause_test_failed(conn, task_id, description, perspective_fns, suggested_route, related_artifact_ids=None): + """04-P: test_failed -> tracing_lineage -> judging_root_cause -> + bug_recorded. Creates a brand-new bug_record (test_failed origin never + updates an existing one, unlike human_reported -- see 04-P's own note + on this distinction). Re-raises InconclusiveEscalateError unchanged if + judge_root_cause can't reach agreement. + """ + result = judge_root_cause(perspective_fns) + created = bug_record.create_bug_record( + conn, task_id=task_id, description=description, + root_cause_category=result["root_cause_category"], root_cause_detail=result["root_cause_detail"], + suggested_route=suggested_route, related_artifact_ids=related_artifact_ids, + ) + return {**created, **result} + + +def trace_root_cause_human_reported(conn, bug_id, perspective_fns, suggested_route): + """04-P: human_reported (already claimed via bug_record.claim_human_reported_bug, + status=triaging) -> tracing_lineage -> judging_root_cause -> bug_recorded. + Field-completes the existing bug_record instead of creating a new one. + """ + result = judge_root_cause(perspective_fns) + bug_record.complete_human_reported_triage( + conn, bug_id, result["root_cause_category"], result["root_cause_detail"], suggested_route + ) + return result + + +_PERSPECTIVE_FRAMINGS = { + "correctness": ( + "CORRECTNESS PERSPECTIVE: does the Coder's implementation match what the SDD/PRD actually " + "specify? If the spec was followed correctly and the bug is still there, this is not your " + "category to claim." + ), + "requirement_completeness": ( + "REQUIREMENT-COMPLETENESS PERSPECTIVE: does the PRD/SDD actually define the behavior the bug " + "report is about at all? If the requirement was never specified (a genuine planning gap, not " + "an implementation mistake), that's your category to claim." + ), + "skill_defect": ( + "SKILL-DEFECT PERSPECTIVE: is this a one-off mistake in this specific task, or does it look " + "like the underlying Skill (the reusable methodology/prompt an Org Agent follows) is " + "systematically wrong in a way that would recur on other tasks?" + ), +} + + +def real_validator_perspective(perspective_name, bug_description, context, target_folder_path, trigger_fn, timeout=300): + """Builds a real perspective_fn (for use with judge_root_cause) that calls + the deployed Validator agent, framed from one of the three named + perspectives, and parses its 'CATEGORY: ...' / 'DETAIL: ...' response. + `trigger_fn` is org_node_trigger.trigger_org_node, passed in explicitly + (not imported/called directly) so tests can inject a fake without + mocking a module-global. + """ + framing = _PERSPECTIVE_FRAMINGS[perspective_name] + prompt = ( + "{framing}\n\nBug: {bug_description}\n\nContext:\n{context}\n\n" + "Reply with exactly two lines:\n" + "CATEGORY: planning|coder|skill_defect\n" + "DETAIL: " + ).format(framing=framing, bug_description=bug_description, context=context) + + def perspective_fn(): + response = trigger_fn("validator", prompt, target_folder_path, timeout=timeout) + category = None + detail = "" + for line in response.splitlines(): + stripped = line.strip() + if stripped.upper().startswith("CATEGORY:"): + value = stripped.split(":", 1)[1].strip().lower() + if value in ("planning", "coder", "skill_defect"): + category = value + elif stripped.upper().startswith("DETAIL:"): + detail = stripped.split(":", 1)[1].strip() + if category is None: + raise ValueError("could not parse a CATEGORY line from validator response: " + response) + return {"category": category, "detail": detail or response} + + return perspective_fn diff --git a/src/test_bug_record.py b/src/test_bug_record.py new file mode 100644 index 0000000..c58602f --- /dev/null +++ b/src/test_bug_record.py @@ -0,0 +1,123 @@ +"""U-015 tests: c_bug_record_create + the human_reported claim/complete +mechanics. All real (no mocking needed -- sqlite is fast/free/local). + +Run: python3 -m unittest src.test_bug_record -v +""" + +import json +import unittest + +from src import bug_record, schema + + +class BugRecordTestBase(unittest.TestCase): + def setUp(self): + self.conn = schema.connect(":memory:") + schema.create_schema(self.conn) + self.conn.execute( + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) " + "VALUES ('proj-1', 'P', '/tmp/proj-1', 1, 'active')" + ) + self.conn.execute( + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) " + "VALUES ('task-1', 'proj-1', 'goal', 'goal', 'validating', 'validator')" + ) + self.conn.commit() + + def tearDown(self): + self.conn.close() + + +class CreateBugRecordTest(BugRecordTestBase): + def test_happy_path_creates_routed_validator_record(self): + result = bug_record.create_bug_record( + self.conn, task_id="task-1", description="wall collision misjudged", + root_cause_category="coder", root_cause_detail="SDD §3.2 collision check off-by-one", + suggested_route="fast_lane", + ) + row = self.conn.execute( + "SELECT source, status, root_cause_category, root_cause_detail, suggested_route " + "FROM bug_record WHERE bug_id = ?", + (result["bug_id"],), + ).fetchone() + self.assertEqual(row, ("validator", "routed", "coder", "SDD §3.2 collision check off-by-one", "fast_lane")) + + def test_related_artifact_ids_stored_as_json(self): + result = bug_record.create_bug_record( + self.conn, task_id="task-1", description="x", root_cause_category="planning", + root_cause_detail="d", suggested_route="full_replan", + related_artifact_ids=["art-1", "art-2"], + ) + row = self.conn.execute( + "SELECT related_artifact_ids FROM bug_record WHERE bug_id = ?", (result["bug_id"],) + ).fetchone() + self.assertEqual(json.loads(row[0]), ["art-1", "art-2"]) + + def test_invalid_root_cause_category_rejected(self): + with self.assertRaises(bug_record.InvalidRootCauseCategoryError): + bug_record.create_bug_record( + self.conn, task_id="task-1", description="x", root_cause_category="not_a_real_category", + root_cause_detail="d", suggested_route="fast_lane", + ) + + def test_core_engine_rejected_pre_ph5(self): + # core_engine is PH5-scoped (U-034) -- not accepted yet, matching the + # schema CHECK constraint's own exclusion (see src/schema.py) + with self.assertRaises(bug_record.InvalidRootCauseCategoryError): + bug_record.create_bug_record( + self.conn, task_id="task-1", description="x", root_cause_category="core_engine", + root_cause_detail="d", suggested_route="fast_lane", + ) + + +class HumanReportedFlowTest(BugRecordTestBase): + def _insert_human_bug(self, bug_id="bug-1", status="open"): + self.conn.execute( + "INSERT INTO bug_record (bug_id, task_id, source, description, status) " + "VALUES (?, 'task-1', 'human', 'something looks broken', ?)", + (bug_id, status), + ) + self.conn.commit() + + def test_claim_transitions_open_to_triaging(self): + self._insert_human_bug() + bug_record.claim_human_reported_bug(self.conn, "bug-1") + status = self.conn.execute("SELECT status FROM bug_record WHERE bug_id = 'bug-1'").fetchone()[0] + self.assertEqual(status, "triaging") + + def test_claim_rejects_already_claimed_bug(self): + self._insert_human_bug(status="triaging") + with self.assertRaises(ValueError): + bug_record.claim_human_reported_bug(self.conn, "bug-1") + + def test_claim_rejects_unknown_bug_id(self): + with self.assertRaises(ValueError): + bug_record.claim_human_reported_bug(self.conn, "no-such-bug") + + def test_complete_triage_updates_in_place_not_new_record(self): + self._insert_human_bug() + bug_record.claim_human_reported_bug(self.conn, "bug-1") + bug_record.complete_human_reported_triage( + self.conn, "bug-1", root_cause_category="coder", + root_cause_detail="found it", suggested_route="fast_lane", + ) + count = self.conn.execute("SELECT COUNT(*) FROM bug_record").fetchone()[0] + self.assertEqual(count, 1) + row = self.conn.execute( + "SELECT status, root_cause_category, root_cause_detail, suggested_route " + "FROM bug_record WHERE bug_id = 'bug-1'" + ).fetchone() + self.assertEqual(row, ("routed", "coder", "found it", "fast_lane")) + + def test_complete_triage_rejects_invalid_category(self): + self._insert_human_bug() + bug_record.claim_human_reported_bug(self.conn, "bug-1") + with self.assertRaises(bug_record.InvalidRootCauseCategoryError): + bug_record.complete_human_reported_triage( + self.conn, "bug-1", root_cause_category="nonsense", + root_cause_detail="x", suggested_route="fast_lane", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/test_root_cause_trace.py b/src/test_root_cause_trace.py new file mode 100644 index 0000000..f5ed0d9 --- /dev/null +++ b/src/test_root_cause_trace.py @@ -0,0 +1,132 @@ +"""U-015 tests: judge_root_cause's 2/3-majority logic and the test_failed/ +human_reported wiring, with fake perspective_fns (CLAUDE.md Testing +constraint -- real perspectives shell out to `claude`, mocked here). Real +coverage is in src/test_root_cause_trace_e2e.py (RUN_REAL_AGENT_E2E=1). + +Run: python3 -m unittest src.test_root_cause_trace -v +""" + +import unittest + +from src import bug_record, root_cause_trace, schema + + +def _fixed(category, detail="detail"): + return lambda: {"category": category, "detail": detail} + + +class JudgeRootCauseTest(unittest.TestCase): + def test_unanimous_agreement(self): + result = root_cause_trace.judge_root_cause( + [_fixed("coder", "d1"), _fixed("coder", "d2"), _fixed("coder", "d3")] + ) + self.assertEqual(result["root_cause_category"], "coder") + self.assertIn("d1", result["root_cause_detail"]) + self.assertIn("d2", result["root_cause_detail"]) + + def test_two_of_three_agreement_wins(self): + result = root_cause_trace.judge_root_cause( + [_fixed("planning", "d1"), _fixed("planning", "d2"), _fixed("coder", "d3")] + ) + self.assertEqual(result["root_cause_category"], "planning") + self.assertNotIn("d3", result["root_cause_detail"]) + + def test_three_way_split_is_inconclusive(self): + with self.assertRaises(root_cause_trace.InconclusiveEscalateError) as ctx: + root_cause_trace.judge_root_cause( + [_fixed("planning"), _fixed("coder"), _fixed("skill_defect")] + ) + self.assertEqual(len(ctx.exception.judgments), 3) + + def test_below_two_thirds_with_more_perspectives_is_inconclusive(self): + # 2 out of 5 agreeing is not >=2/3 (04-P: "至少 2/3 视角一致") + with self.assertRaises(root_cause_trace.InconclusiveEscalateError): + root_cause_trace.judge_root_cause( + [_fixed("coder"), _fixed("coder"), _fixed("planning"), _fixed("planning"), _fixed("skill_defect")] + ) + + +class TraceRootCauseTestFailedTest(unittest.TestCase): + def setUp(self): + self.conn = schema.connect(":memory:") + schema.create_schema(self.conn) + self.conn.execute( + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) " + "VALUES ('proj-1', 'P', '/tmp/proj-1', 1, 'active')" + ) + self.conn.execute( + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) " + "VALUES ('task-1', 'proj-1', 'goal', 'goal', 'validating', 'validator')" + ) + self.conn.commit() + + def tearDown(self): + self.conn.close() + + def test_agreement_creates_bug_record(self): + result = root_cause_trace.trace_root_cause_test_failed( + self.conn, "task-1", "wall collision misjudged", + [_fixed("coder", "SDD §3.2"), _fixed("coder", "code line 42"), _fixed("planning", "no")], + suggested_route="fast_lane", + ) + self.assertEqual(result["root_cause_category"], "coder") + row = self.conn.execute( + "SELECT source, status, task_id FROM bug_record WHERE bug_id = ?", (result["bug_id"],) + ).fetchone() + self.assertEqual(row, ("validator", "routed", "task-1")) + + def test_disagreement_raises_and_creates_no_record(self): + with self.assertRaises(root_cause_trace.InconclusiveEscalateError): + root_cause_trace.trace_root_cause_test_failed( + self.conn, "task-1", "x", + [_fixed("planning"), _fixed("coder"), _fixed("skill_defect")], + suggested_route="fast_lane", + ) + count = self.conn.execute("SELECT COUNT(*) FROM bug_record").fetchone()[0] + self.assertEqual(count, 0) + + +class TraceRootCauseHumanReportedTest(unittest.TestCase): + def setUp(self): + self.conn = schema.connect(":memory:") + schema.create_schema(self.conn) + self.conn.execute( + "INSERT INTO bug_record (bug_id, source, description, status) " + "VALUES ('bug-1', 'human', 'something looks broken', 'triaging')" + ) + self.conn.commit() + + def tearDown(self): + self.conn.close() + + def test_completes_existing_record_not_new_one(self): + root_cause_trace.trace_root_cause_human_reported( + self.conn, "bug-1", [_fixed("planning", "gap"), _fixed("planning", "gap2"), _fixed("coder")], + suggested_route="full_replan", + ) + count = self.conn.execute("SELECT COUNT(*) FROM bug_record").fetchone()[0] + self.assertEqual(count, 1) + row = self.conn.execute("SELECT status, root_cause_category FROM bug_record WHERE bug_id = 'bug-1'").fetchone() + self.assertEqual(row, ("routed", "planning")) + + +class RealValidatorPerspectiveParsingTest(unittest.TestCase): + def test_parses_category_and_detail(self): + fake_trigger = lambda org_node, prompt, target, timeout=300: "CATEGORY: coder\nDETAIL: off-by-one in bounds check" + perspective_fn = root_cause_trace.real_validator_perspective( + "correctness", "bug desc", "context", "/tmp/p", trigger_fn=fake_trigger + ) + result = perspective_fn() + self.assertEqual(result, {"category": "coder", "detail": "off-by-one in bounds check"}) + + def test_missing_category_line_raises(self): + fake_trigger = lambda org_node, prompt, target, timeout=300: "I have no idea what happened." + perspective_fn = root_cause_trace.real_validator_perspective( + "correctness", "bug desc", "context", "/tmp/p", trigger_fn=fake_trigger + ) + with self.assertRaises(ValueError): + perspective_fn() + + +if __name__ == "__main__": + unittest.main() diff --git a/src/test_root_cause_trace_e2e.py b/src/test_root_cause_trace_e2e.py new file mode 100644 index 0000000..ab56ff1 --- /dev/null +++ b/src/test_root_cause_trace_e2e.py @@ -0,0 +1,64 @@ +"""U-015's real-call coverage: REQ-04's happy case, for real -- a deliberately +injected "wall collision misjudged" bug, judged by 3 real Validator calls +(one per perspective), expecting >=2/3 to land on root_cause_category=coder +(the SDD clearly specifies wall-collision behavior; the code clearly +deviates from it -- this should NOT look like a planning gap or a skill +defect to a careful reader). Gated behind RUN_REAL_AGENT_E2E=1 (CLAUDE.md +Testing constraint). + +Run: RUN_REAL_AGENT_E2E=1 python3 -m unittest src.test_root_cause_trace_e2e -v +""" + +import subprocess +import tempfile +import unittest + +from src import agent_profile, org_node_trigger, root_cause_trace, schema +from src.test_support import skip_unless_env + +_SDD_SNIPPET = ( + "## Wall collision (SDD §3.2)\n" + "The board is a grid with valid coordinates 0..N-1 in each axis. The snake's head " + "position is checked against the board bounds every tick. A collision (game over) " + "MUST be triggered the instant the head's x or y coordinate is < 0 or >= N (i.e. any " + "coordinate outside the valid 0..N-1 range, inclusive of the boundary cells " + "themselves being valid, exclusive of anything past them)." +) + +_BUGGY_CODE_SNIPPET = ( + "def check_wall_collision(head_x, head_y, board_size):\n" + " # BUG: uses > / < instead of >= / <=, so the head is allowed to sit one cell\n" + " # past the actual boundary before a collision is registered.\n" + " return head_x < 0 or head_y < 0 or head_x > board_size or head_y > board_size\n" +) + +_BUG_DESCRIPTION = ( + "When the snake's head reaches the rightmost or bottommost column, the game does not " + "end immediately -- the head is visually one cell outside the board before 'wall " + "collision' triggers." +) + + +@skip_unless_env("RUN_REAL_AGENT_E2E") +class RealRootCauseTraceTest(unittest.TestCase): + def test_three_real_perspectives_agree_on_coder(self): + with tempfile.TemporaryDirectory() as tmp: + subprocess.run(["git", "init"], cwd=tmp, check=True, capture_output=True) + agent_profile.deploy_agent_definitions(tmp) + + context = "SDD:\n{}\n\nImplementation:\n{}".format(_SDD_SNIPPET, _BUGGY_CODE_SNIPPET) + perspective_fns = [ + root_cause_trace.real_validator_perspective( + name, _BUG_DESCRIPTION, context, tmp, trigger_fn=org_node_trigger.trigger_org_node + ) + for name in ("correctness", "requirement_completeness", "skill_defect") + ] + + result = root_cause_trace.judge_root_cause(perspective_fns) + + self.assertEqual(result["root_cause_category"], "coder") + self.assertTrue(result["root_cause_detail"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/work_graph_execute.py b/src/work_graph_execute.py index d1caad7..a1b1351 100644 --- a/src/work_graph_execute.py +++ b/src/work_graph_execute.py @@ -103,14 +103,16 @@ def execute_work_graph(target_folder_path, name, args, output_schema, timeout=60 timeout=timeout, ) if result.returncode != 0: - quota_type = _quota_limit_type(result.stderr) + # 2026-07-31 fix (same bug as org_node_trigger.py): stdout can carry + # the actual error detail even on nonzero exit, and messages here + # were dropping it entirely -- check/include both streams. + quota_type = _quota_limit_type(result.stderr) or _quota_limit_type(result.stdout) + detail = "stderr: {}\nstdout (truncated): {}".format(result.stderr, result.stdout[:4000]) if quota_type: raise UsageLimitReachedError( - "workflow {} exited {}: {}".format(name, result.returncode, result.stderr), quota_type + "workflow {} exited {}\n{}".format(name, result.returncode, detail), quota_type ) - raise WorkGraphExecuteError( - "workflow {} exited {}: {}".format(name, result.returncode, result.stderr) - ) + raise WorkGraphExecuteError("workflow {} exited {}\n{}".format(name, result.returncode, detail)) payload = json.loads(result.stdout) if payload.get("is_error"): diff --git a/src/work_graph_plan.py b/src/work_graph_plan.py index 98f9b2b..f981fba 100644 --- a/src/work_graph_plan.py +++ b/src/work_graph_plan.py @@ -121,7 +121,12 @@ def plan_work_graph(conn, org_node, task_id, goal_text, target_folder_path, time timeout=timeout, ) if result.returncode != 0: - raise PlanningFailedError("plan failed for task {}: {}".format(task_id, result.stderr)) + # 2026-07-31 fix (same bug as org_node_trigger.py): include stdout too. + raise PlanningFailedError( + "plan failed for task {}\nstderr: {}\nstdout (truncated): {}".format( + task_id, result.stderr, result.stdout[:4000] + ) + ) payload = json.loads(result.stdout) if payload.get("is_error"):