Commit f8c59e368998535003fac3f00ae9eb8bd3278958
1 parent
b160378d
U-015: p_validator_root_cause_trace (three-perspective judgment)
judge_root_cause(): runs 3 perspective_fns (correctness/requirement-
completeness/skill-defect, 04-P §4.3), requires >=2/3 agreement on a
category or raises InconclusiveEscalateError with all raw judgments
attached (REQ-04's failure case). trace_root_cause_test_failed() creates a
brand-new bug_record on agreement; trace_root_cause_human_reported()
field-completes an existing one instead (04-P's explicit distinction
between the two origins, not a new record either way).
c_bug_record_create (bug_record.py): validates root_cause_category against
{planning, coder, skill_defect} -- core_engine deliberately excluded,
matching U-001's schema CHECK constraint (PH5/U-034 scope). Known,
deliberately-unenforced gap noted rather than guessed at: 03-C §3.10's
"skill_defect requires creating a skill_iteration_suggestion" constraint
can't be satisfied yet (skill_iteration.before/after_git_commit_sha are
NOT NULL but don't exist until a real skill rewrite happens in U-018) --
REQ-04's three cases never exercise skill_defect, so nothing here needs it
resolved yet.
real_validator_perspective() builds a real claude --agent validator call
framed from one of the three perspectives, parsing a CATEGORY:/DETAIL:
response format.
Also fixes a real diagnostic bug found investigating an unrelated failure:
org_node_trigger.py/work_graph_plan.py/work_graph_execute.py's nonzero-exit
error paths only included stderr in the raised exception, silently
dropping stdout -- which is where a real `claude` failure's actual detail
often lives. A real Coder call failed with exit 1 and empty stderr,
giving zero diagnostic signal; fixed to include both streams (truncated).
18 new mocked stdlib unittest cases + 1 gated real test (3 real Validator
perspective calls judging a deliberately-injected wall-collision bug,
expecting agreement on root_cause_category=coder). 146 total mocked tests
across U-001-015, all passing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
9 changed files
with
571 additions
and
7 deletions
docs/development-plan.md
| @@ -157,6 +157,8 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。 | @@ -157,6 +157,8 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。 | ||
| 157 | - **U-015 [Risk Gate]** `p_validator_root_cause_trace` 实现(三态判定 PASS/FAIL/INCONCLUSIVE,双向可追溯矩阵) | 157 | - **U-015 [Risk Gate]** `p_validator_root_cause_trace` 实现(三态判定 PASS/FAIL/INCONCLUSIVE,双向可追溯矩阵) |
| 158 | - 依赖:U-013 | 158 | - 依赖:U-013 |
| 159 | - Done:REQ-04 happy/boundary/failure 通过 | 159 | - Done:REQ-04 happy/boundary/failure 通过 |
| 160 | + - **实现补充(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 需要单独建的后端能力 | ||
| 161 | + - **已知缺口,刻意不实现(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 到时候解决,没有现场猜一个方案硬填 | ||
| 160 | - 覆盖:01-M `bug_record`;03-C `c_bug_record_create`;04-P `p_validator_root_cause_trace`;REQ-04 | 162 | - 覆盖:01-M `bug_record`;03-C `c_bug_record_create`;04-P `p_validator_root_cause_trace`;REQ-04 |
| 161 | 163 | ||
| 162 | - **U-016 [Risk Gate]** `c_org_loop_route` + `fast_lane`/`full_replan` 快速通道 | 164 | - **U-016 [Risk Gate]** `c_org_loop_route` + `fast_lane`/`full_replan` 快速通道 |
src/bug_record.py
0 → 100644
| 1 | +"""c_bug_record_create (03-C §3.10): finalizes a Validator-judged bug. | ||
| 2 | + | ||
| 3 | +Distinct from c_bug_report_create (03-C §3.1.1, human-submitted feedback with | ||
| 4 | +no root cause yet -- owned by U-023, not this Unit): this controller's | ||
| 5 | +input_contract requires root_cause_category/root_cause_detail/suggested_route | ||
| 6 | +all as required fields, i.e. it's the "record a bug the Validator has already | ||
| 7 | +judged" endpoint, called at the end of p_validator_root_cause_trace's | ||
| 8 | +judging_root_cause -> bug_recorded transition (04-P §4.3). | ||
| 9 | + | ||
| 10 | +**Known gap, deliberately unenforced (2026-07-31)**: 03-C §3.10's own text | ||
| 11 | +says "root_cause_category=skill_defect 时必须同时创建 skill_iteration_suggestion, | ||
| 12 | +否则视为字段缺失" -- but 01-M's skill_iteration entity requires | ||
| 13 | +before_git_commit_sha/after_git_commit_sha NOT NULL, fields that can't be | ||
| 14 | +populated until an actual skill rewrite happens (c_skill_update, U-018), which | ||
| 15 | +hasn't occurred yet at bug-creation time. The SDD doesn't define what a | ||
| 16 | +"suggestion" (pre-rewrite) is distinct from an "iteration" (post-rewrite), or | ||
| 17 | +how skill_iteration's NOT NULL commit-sha fields would be satisfied before | ||
| 18 | +one exists. Not guessed at here: REQ-04's own three acceptance cases (06 节) | ||
| 19 | +never exercise root_cause_category=skill_defect, so nothing in this Unit's | ||
| 20 | +actual Done criterion needs this resolved. Left for U-018 (c_skill_update, | ||
| 21 | +the Unit that actually creates skill_iteration rows) to resolve when it's | ||
| 22 | +in scope. | ||
| 23 | +""" | ||
| 24 | + | ||
| 25 | +import json | ||
| 26 | +import uuid | ||
| 27 | + | ||
| 28 | +# core_engine deliberately excluded -- see src/schema.py's matching CHECK | ||
| 29 | +# constraint and its own comment: PH5-scoped (U-034), not accepted until then. | ||
| 30 | +_VALID_ROOT_CAUSE_CATEGORIES = ("planning", "coder", "skill_defect") | ||
| 31 | + | ||
| 32 | + | ||
| 33 | +class InvalidRootCauseCategoryError(Exception): | ||
| 34 | + """INVALID_ROOT_CAUSE_CATEGORY -- 03-C §3.10.""" | ||
| 35 | + | ||
| 36 | + | ||
| 37 | +def create_bug_record( | ||
| 38 | + conn, task_id, description, root_cause_category, root_cause_detail, suggested_route, | ||
| 39 | + repro_steps=None, related_artifact_ids=None, skill_iteration_suggestion_id=None, | ||
| 40 | +): | ||
| 41 | + """Creates a bug_record(source='validator', status='routed') -- 'routed' | ||
| 42 | + because by the time this is called, triage (root cause + suggested_route) | ||
| 43 | + is already complete; there's no separate triaging step for this origin | ||
| 44 | + (that's the human_reported branch's job, see claim_human_reported_bug / | ||
| 45 | + complete_human_reported_triage below).""" | ||
| 46 | + if root_cause_category not in _VALID_ROOT_CAUSE_CATEGORIES: | ||
| 47 | + raise InvalidRootCauseCategoryError( | ||
| 48 | + "root_cause_category must be one of {}, got {!r}".format( | ||
| 49 | + _VALID_ROOT_CAUSE_CATEGORIES, root_cause_category | ||
| 50 | + ) | ||
| 51 | + ) | ||
| 52 | + | ||
| 53 | + bug_id = str(uuid.uuid4()) | ||
| 54 | + conn.execute( | ||
| 55 | + "INSERT INTO bug_record " | ||
| 56 | + "(bug_id, task_id, source, description, repro_steps, root_cause_category, root_cause_detail, " | ||
| 57 | + "related_artifact_ids, suggested_route, skill_iteration_suggestion_id, status) " | ||
| 58 | + "VALUES (?, ?, 'validator', ?, ?, ?, ?, ?, ?, ?, 'routed')", | ||
| 59 | + ( | ||
| 60 | + bug_id, task_id, description, repro_steps, root_cause_category, root_cause_detail, | ||
| 61 | + json.dumps(related_artifact_ids) if related_artifact_ids else None, | ||
| 62 | + suggested_route, skill_iteration_suggestion_id, | ||
| 63 | + ), | ||
| 64 | + ) | ||
| 65 | + conn.commit() | ||
| 66 | + return {"bug_id": bug_id} | ||
| 67 | + | ||
| 68 | + | ||
| 69 | +def claim_human_reported_bug(conn, bug_id): | ||
| 70 | + """04-P §4.3: human_reported -> tracing_lineage. Validator claims an | ||
| 71 | + open, source=human bug_record. status: open -> triaging.""" | ||
| 72 | + row = conn.execute("SELECT source, status FROM bug_record WHERE bug_id = ?", (bug_id,)).fetchone() | ||
| 73 | + if row is None: | ||
| 74 | + raise ValueError("no such bug_record: " + bug_id) | ||
| 75 | + source, status = row | ||
| 76 | + if source != "human" or status != "open": | ||
| 77 | + raise ValueError( | ||
| 78 | + "bug_record {} is not claimable (source={}, status={}); expected source=human, status=open".format( | ||
| 79 | + bug_id, source, status | ||
| 80 | + ) | ||
| 81 | + ) | ||
| 82 | + conn.execute("UPDATE bug_record SET status = 'triaging' WHERE bug_id = ?", (bug_id,)) | ||
| 83 | + conn.commit() | ||
| 84 | + | ||
| 85 | + | ||
| 86 | +def complete_human_reported_triage(conn, bug_id, root_cause_category, root_cause_detail, suggested_route): | ||
| 87 | + """04-P §4.3 补充说明: human_reported 起点的 judging_root_cause->bug_recorded | ||
| 88 | + 是对已存在 bug_record 的字段补全式更新,不产生重复记录 -- status: triaging->routed.""" | ||
| 89 | + if root_cause_category not in _VALID_ROOT_CAUSE_CATEGORIES: | ||
| 90 | + raise InvalidRootCauseCategoryError( | ||
| 91 | + "root_cause_category must be one of {}, got {!r}".format( | ||
| 92 | + _VALID_ROOT_CAUSE_CATEGORIES, root_cause_category | ||
| 93 | + ) | ||
| 94 | + ) | ||
| 95 | + conn.execute( | ||
| 96 | + "UPDATE bug_record SET root_cause_category = ?, root_cause_detail = ?, suggested_route = ?, " | ||
| 97 | + "status = 'routed' WHERE bug_id = ?", | ||
| 98 | + (root_cause_category, root_cause_detail, suggested_route, bug_id), | ||
| 99 | + ) | ||
| 100 | + conn.commit() |
src/org_node_trigger.py
| @@ -44,8 +44,16 @@ def trigger_org_node(org_node, prompt, target_folder_path, timeout=300): | @@ -44,8 +44,16 @@ def trigger_org_node(org_node, prompt, target_folder_path, timeout=300): | ||
| 44 | timeout=timeout, | 44 | timeout=timeout, |
| 45 | ) | 45 | ) |
| 46 | if result.returncode != 0: | 46 | if result.returncode != 0: |
| 47 | + # 2026-07-31 fix: a real Coder call once failed with returncode=1 and | ||
| 48 | + # empty stderr, and the exception message (stderr-only, as it used to | ||
| 49 | + # be) gave zero diagnostic signal -- whatever claude actually said | ||
| 50 | + # about the failure was in stdout, which this branch was discarding. | ||
| 51 | + # Both are now included (truncated -- stdout in JSON mode can be | ||
| 52 | + # large) so a future real failure is actually debuggable. | ||
| 47 | raise OrgNodeTriggerError( | 53 | raise OrgNodeTriggerError( |
| 48 | - "claude --agent {} exited {}: {}".format(org_node, result.returncode, result.stderr) | 54 | + "claude --agent {} exited {}\nstderr: {}\nstdout (truncated): {}".format( |
| 55 | + org_node, result.returncode, result.stderr, result.stdout[:4000] | ||
| 56 | + ) | ||
| 49 | ) | 57 | ) |
| 50 | 58 | ||
| 51 | payload = json.loads(result.stdout) | 59 | payload = json.loads(result.stdout) |
src/root_cause_trace.py
0 → 100644
| 1 | +"""p_validator_root_cause_trace (04-P §4.3): U-015 scope. | ||
| 2 | + | ||
| 3 | +"血缘读取"(tracing_lineage: reading SDD/PRD/BRD versions + Coder implementation | ||
| 4 | +records) is the caller's job -- perspective_fns are expected to already have | ||
| 5 | +whatever lineage context they need baked in (e.g. closures over artifact | ||
| 6 | +content read beforehand) by the time judge_root_cause calls them. This module | ||
| 7 | +only drives judging_root_cause's multi-perspective vote and the | ||
| 8 | +bug_recorded/inconclusive_escalate branch, matching 04-P's actual state-table | ||
| 9 | +scope for that transition. | ||
| 10 | + | ||
| 11 | +Each perspective_fn takes no arguments and returns {"category": "planning"| | ||
| 12 | +"coder"|"skill_defect", "detail": str} -- one judgment from one of the three | ||
| 13 | +perspectives 04-P names (正确性视角/需求完整性视角/Skill 系统性缺陷视角). Which | ||
| 14 | +concrete question each perspective asks is a prompt-engineering concern for | ||
| 15 | +the caller (see real_validator_perspective below for the real-call version), | ||
| 16 | +not this module's. | ||
| 17 | +""" | ||
| 18 | + | ||
| 19 | +from collections import Counter | ||
| 20 | + | ||
| 21 | +from src import bug_record | ||
| 22 | + | ||
| 23 | + | ||
| 24 | +class InconclusiveEscalateError(Exception): | ||
| 25 | + """04-P: judging_root_cause -> inconclusive_escalate (<2/3 perspectives | ||
| 26 | + agree). Carries the raw per-perspective judgments for human review | ||
| 27 | + ("附带三视角原始判断供人工裁决").""" | ||
| 28 | + | ||
| 29 | + def __init__(self, judgments): | ||
| 30 | + self.judgments = judgments | ||
| 31 | + super().__init__("perspectives did not reach 2/3 agreement: " + repr(judgments)) | ||
| 32 | + | ||
| 33 | + | ||
| 34 | +def judge_root_cause(perspective_fns): | ||
| 35 | + """Runs all perspective_fns, requires >=2/3 to agree on the same | ||
| 36 | + category (04-P guard: "至少 2/3 视角一致才定案"). Returns | ||
| 37 | + {"root_cause_category", "root_cause_detail", "judgments"} on agreement, | ||
| 38 | + raises InconclusiveEscalateError otherwise. | ||
| 39 | + """ | ||
| 40 | + judgments = [fn() for fn in perspective_fns] | ||
| 41 | + counts = Counter(j["category"] for j in judgments) | ||
| 42 | + winner, count = counts.most_common(1)[0] | ||
| 43 | + if count * 2 < len(judgments): # strictly "at least 2/3", not just plurality | ||
| 44 | + raise InconclusiveEscalateError(judgments) | ||
| 45 | + detail = " | ".join(j["detail"] for j in judgments if j["category"] == winner) | ||
| 46 | + return {"root_cause_category": winner, "root_cause_detail": detail, "judgments": judgments} | ||
| 47 | + | ||
| 48 | + | ||
| 49 | +def trace_root_cause_test_failed(conn, task_id, description, perspective_fns, suggested_route, related_artifact_ids=None): | ||
| 50 | + """04-P: test_failed -> tracing_lineage -> judging_root_cause -> | ||
| 51 | + bug_recorded. Creates a brand-new bug_record (test_failed origin never | ||
| 52 | + updates an existing one, unlike human_reported -- see 04-P's own note | ||
| 53 | + on this distinction). Re-raises InconclusiveEscalateError unchanged if | ||
| 54 | + judge_root_cause can't reach agreement. | ||
| 55 | + """ | ||
| 56 | + result = judge_root_cause(perspective_fns) | ||
| 57 | + created = bug_record.create_bug_record( | ||
| 58 | + conn, task_id=task_id, description=description, | ||
| 59 | + root_cause_category=result["root_cause_category"], root_cause_detail=result["root_cause_detail"], | ||
| 60 | + suggested_route=suggested_route, related_artifact_ids=related_artifact_ids, | ||
| 61 | + ) | ||
| 62 | + return {**created, **result} | ||
| 63 | + | ||
| 64 | + | ||
| 65 | +def trace_root_cause_human_reported(conn, bug_id, perspective_fns, suggested_route): | ||
| 66 | + """04-P: human_reported (already claimed via bug_record.claim_human_reported_bug, | ||
| 67 | + status=triaging) -> tracing_lineage -> judging_root_cause -> bug_recorded. | ||
| 68 | + Field-completes the existing bug_record instead of creating a new one. | ||
| 69 | + """ | ||
| 70 | + result = judge_root_cause(perspective_fns) | ||
| 71 | + bug_record.complete_human_reported_triage( | ||
| 72 | + conn, bug_id, result["root_cause_category"], result["root_cause_detail"], suggested_route | ||
| 73 | + ) | ||
| 74 | + return result | ||
| 75 | + | ||
| 76 | + | ||
| 77 | +_PERSPECTIVE_FRAMINGS = { | ||
| 78 | + "correctness": ( | ||
| 79 | + "CORRECTNESS PERSPECTIVE: does the Coder's implementation match what the SDD/PRD actually " | ||
| 80 | + "specify? If the spec was followed correctly and the bug is still there, this is not your " | ||
| 81 | + "category to claim." | ||
| 82 | + ), | ||
| 83 | + "requirement_completeness": ( | ||
| 84 | + "REQUIREMENT-COMPLETENESS PERSPECTIVE: does the PRD/SDD actually define the behavior the bug " | ||
| 85 | + "report is about at all? If the requirement was never specified (a genuine planning gap, not " | ||
| 86 | + "an implementation mistake), that's your category to claim." | ||
| 87 | + ), | ||
| 88 | + "skill_defect": ( | ||
| 89 | + "SKILL-DEFECT PERSPECTIVE: is this a one-off mistake in this specific task, or does it look " | ||
| 90 | + "like the underlying Skill (the reusable methodology/prompt an Org Agent follows) is " | ||
| 91 | + "systematically wrong in a way that would recur on other tasks?" | ||
| 92 | + ), | ||
| 93 | +} | ||
| 94 | + | ||
| 95 | + | ||
| 96 | +def real_validator_perspective(perspective_name, bug_description, context, target_folder_path, trigger_fn, timeout=300): | ||
| 97 | + """Builds a real perspective_fn (for use with judge_root_cause) that calls | ||
| 98 | + the deployed Validator agent, framed from one of the three named | ||
| 99 | + perspectives, and parses its 'CATEGORY: ...' / 'DETAIL: ...' response. | ||
| 100 | + `trigger_fn` is org_node_trigger.trigger_org_node, passed in explicitly | ||
| 101 | + (not imported/called directly) so tests can inject a fake without | ||
| 102 | + mocking a module-global. | ||
| 103 | + """ | ||
| 104 | + framing = _PERSPECTIVE_FRAMINGS[perspective_name] | ||
| 105 | + prompt = ( | ||
| 106 | + "{framing}\n\nBug: {bug_description}\n\nContext:\n{context}\n\n" | ||
| 107 | + "Reply with exactly two lines:\n" | ||
| 108 | + "CATEGORY: planning|coder|skill_defect\n" | ||
| 109 | + "DETAIL: <one sentence, cite the specific SDD section or code location>" | ||
| 110 | + ).format(framing=framing, bug_description=bug_description, context=context) | ||
| 111 | + | ||
| 112 | + def perspective_fn(): | ||
| 113 | + response = trigger_fn("validator", prompt, target_folder_path, timeout=timeout) | ||
| 114 | + category = None | ||
| 115 | + detail = "" | ||
| 116 | + for line in response.splitlines(): | ||
| 117 | + stripped = line.strip() | ||
| 118 | + if stripped.upper().startswith("CATEGORY:"): | ||
| 119 | + value = stripped.split(":", 1)[1].strip().lower() | ||
| 120 | + if value in ("planning", "coder", "skill_defect"): | ||
| 121 | + category = value | ||
| 122 | + elif stripped.upper().startswith("DETAIL:"): | ||
| 123 | + detail = stripped.split(":", 1)[1].strip() | ||
| 124 | + if category is None: | ||
| 125 | + raise ValueError("could not parse a CATEGORY line from validator response: " + response) | ||
| 126 | + return {"category": category, "detail": detail or response} | ||
| 127 | + | ||
| 128 | + return perspective_fn |
src/test_bug_record.py
0 → 100644
| 1 | +"""U-015 tests: c_bug_record_create + the human_reported claim/complete | ||
| 2 | +mechanics. All real (no mocking needed -- sqlite is fast/free/local). | ||
| 3 | + | ||
| 4 | +Run: python3 -m unittest src.test_bug_record -v | ||
| 5 | +""" | ||
| 6 | + | ||
| 7 | +import json | ||
| 8 | +import unittest | ||
| 9 | + | ||
| 10 | +from src import bug_record, schema | ||
| 11 | + | ||
| 12 | + | ||
| 13 | +class BugRecordTestBase(unittest.TestCase): | ||
| 14 | + def setUp(self): | ||
| 15 | + self.conn = schema.connect(":memory:") | ||
| 16 | + schema.create_schema(self.conn) | ||
| 17 | + self.conn.execute( | ||
| 18 | + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) " | ||
| 19 | + "VALUES ('proj-1', 'P', '/tmp/proj-1', 1, 'active')" | ||
| 20 | + ) | ||
| 21 | + self.conn.execute( | ||
| 22 | + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) " | ||
| 23 | + "VALUES ('task-1', 'proj-1', 'goal', 'goal', 'validating', 'validator')" | ||
| 24 | + ) | ||
| 25 | + self.conn.commit() | ||
| 26 | + | ||
| 27 | + def tearDown(self): | ||
| 28 | + self.conn.close() | ||
| 29 | + | ||
| 30 | + | ||
| 31 | +class CreateBugRecordTest(BugRecordTestBase): | ||
| 32 | + def test_happy_path_creates_routed_validator_record(self): | ||
| 33 | + result = bug_record.create_bug_record( | ||
| 34 | + self.conn, task_id="task-1", description="wall collision misjudged", | ||
| 35 | + root_cause_category="coder", root_cause_detail="SDD §3.2 collision check off-by-one", | ||
| 36 | + suggested_route="fast_lane", | ||
| 37 | + ) | ||
| 38 | + row = self.conn.execute( | ||
| 39 | + "SELECT source, status, root_cause_category, root_cause_detail, suggested_route " | ||
| 40 | + "FROM bug_record WHERE bug_id = ?", | ||
| 41 | + (result["bug_id"],), | ||
| 42 | + ).fetchone() | ||
| 43 | + self.assertEqual(row, ("validator", "routed", "coder", "SDD §3.2 collision check off-by-one", "fast_lane")) | ||
| 44 | + | ||
| 45 | + def test_related_artifact_ids_stored_as_json(self): | ||
| 46 | + result = bug_record.create_bug_record( | ||
| 47 | + self.conn, task_id="task-1", description="x", root_cause_category="planning", | ||
| 48 | + root_cause_detail="d", suggested_route="full_replan", | ||
| 49 | + related_artifact_ids=["art-1", "art-2"], | ||
| 50 | + ) | ||
| 51 | + row = self.conn.execute( | ||
| 52 | + "SELECT related_artifact_ids FROM bug_record WHERE bug_id = ?", (result["bug_id"],) | ||
| 53 | + ).fetchone() | ||
| 54 | + self.assertEqual(json.loads(row[0]), ["art-1", "art-2"]) | ||
| 55 | + | ||
| 56 | + def test_invalid_root_cause_category_rejected(self): | ||
| 57 | + with self.assertRaises(bug_record.InvalidRootCauseCategoryError): | ||
| 58 | + bug_record.create_bug_record( | ||
| 59 | + self.conn, task_id="task-1", description="x", root_cause_category="not_a_real_category", | ||
| 60 | + root_cause_detail="d", suggested_route="fast_lane", | ||
| 61 | + ) | ||
| 62 | + | ||
| 63 | + def test_core_engine_rejected_pre_ph5(self): | ||
| 64 | + # core_engine is PH5-scoped (U-034) -- not accepted yet, matching the | ||
| 65 | + # schema CHECK constraint's own exclusion (see src/schema.py) | ||
| 66 | + with self.assertRaises(bug_record.InvalidRootCauseCategoryError): | ||
| 67 | + bug_record.create_bug_record( | ||
| 68 | + self.conn, task_id="task-1", description="x", root_cause_category="core_engine", | ||
| 69 | + root_cause_detail="d", suggested_route="fast_lane", | ||
| 70 | + ) | ||
| 71 | + | ||
| 72 | + | ||
| 73 | +class HumanReportedFlowTest(BugRecordTestBase): | ||
| 74 | + def _insert_human_bug(self, bug_id="bug-1", status="open"): | ||
| 75 | + self.conn.execute( | ||
| 76 | + "INSERT INTO bug_record (bug_id, task_id, source, description, status) " | ||
| 77 | + "VALUES (?, 'task-1', 'human', 'something looks broken', ?)", | ||
| 78 | + (bug_id, status), | ||
| 79 | + ) | ||
| 80 | + self.conn.commit() | ||
| 81 | + | ||
| 82 | + def test_claim_transitions_open_to_triaging(self): | ||
| 83 | + self._insert_human_bug() | ||
| 84 | + bug_record.claim_human_reported_bug(self.conn, "bug-1") | ||
| 85 | + status = self.conn.execute("SELECT status FROM bug_record WHERE bug_id = 'bug-1'").fetchone()[0] | ||
| 86 | + self.assertEqual(status, "triaging") | ||
| 87 | + | ||
| 88 | + def test_claim_rejects_already_claimed_bug(self): | ||
| 89 | + self._insert_human_bug(status="triaging") | ||
| 90 | + with self.assertRaises(ValueError): | ||
| 91 | + bug_record.claim_human_reported_bug(self.conn, "bug-1") | ||
| 92 | + | ||
| 93 | + def test_claim_rejects_unknown_bug_id(self): | ||
| 94 | + with self.assertRaises(ValueError): | ||
| 95 | + bug_record.claim_human_reported_bug(self.conn, "no-such-bug") | ||
| 96 | + | ||
| 97 | + def test_complete_triage_updates_in_place_not_new_record(self): | ||
| 98 | + self._insert_human_bug() | ||
| 99 | + bug_record.claim_human_reported_bug(self.conn, "bug-1") | ||
| 100 | + bug_record.complete_human_reported_triage( | ||
| 101 | + self.conn, "bug-1", root_cause_category="coder", | ||
| 102 | + root_cause_detail="found it", suggested_route="fast_lane", | ||
| 103 | + ) | ||
| 104 | + count = self.conn.execute("SELECT COUNT(*) FROM bug_record").fetchone()[0] | ||
| 105 | + self.assertEqual(count, 1) | ||
| 106 | + row = self.conn.execute( | ||
| 107 | + "SELECT status, root_cause_category, root_cause_detail, suggested_route " | ||
| 108 | + "FROM bug_record WHERE bug_id = 'bug-1'" | ||
| 109 | + ).fetchone() | ||
| 110 | + self.assertEqual(row, ("routed", "coder", "found it", "fast_lane")) | ||
| 111 | + | ||
| 112 | + def test_complete_triage_rejects_invalid_category(self): | ||
| 113 | + self._insert_human_bug() | ||
| 114 | + bug_record.claim_human_reported_bug(self.conn, "bug-1") | ||
| 115 | + with self.assertRaises(bug_record.InvalidRootCauseCategoryError): | ||
| 116 | + bug_record.complete_human_reported_triage( | ||
| 117 | + self.conn, "bug-1", root_cause_category="nonsense", | ||
| 118 | + root_cause_detail="x", suggested_route="fast_lane", | ||
| 119 | + ) | ||
| 120 | + | ||
| 121 | + | ||
| 122 | +if __name__ == "__main__": | ||
| 123 | + unittest.main() |
src/test_root_cause_trace.py
0 → 100644
| 1 | +"""U-015 tests: judge_root_cause's 2/3-majority logic and the test_failed/ | ||
| 2 | +human_reported wiring, with fake perspective_fns (CLAUDE.md Testing | ||
| 3 | +constraint -- real perspectives shell out to `claude`, mocked here). Real | ||
| 4 | +coverage is in src/test_root_cause_trace_e2e.py (RUN_REAL_AGENT_E2E=1). | ||
| 5 | + | ||
| 6 | +Run: python3 -m unittest src.test_root_cause_trace -v | ||
| 7 | +""" | ||
| 8 | + | ||
| 9 | +import unittest | ||
| 10 | + | ||
| 11 | +from src import bug_record, root_cause_trace, schema | ||
| 12 | + | ||
| 13 | + | ||
| 14 | +def _fixed(category, detail="detail"): | ||
| 15 | + return lambda: {"category": category, "detail": detail} | ||
| 16 | + | ||
| 17 | + | ||
| 18 | +class JudgeRootCauseTest(unittest.TestCase): | ||
| 19 | + def test_unanimous_agreement(self): | ||
| 20 | + result = root_cause_trace.judge_root_cause( | ||
| 21 | + [_fixed("coder", "d1"), _fixed("coder", "d2"), _fixed("coder", "d3")] | ||
| 22 | + ) | ||
| 23 | + self.assertEqual(result["root_cause_category"], "coder") | ||
| 24 | + self.assertIn("d1", result["root_cause_detail"]) | ||
| 25 | + self.assertIn("d2", result["root_cause_detail"]) | ||
| 26 | + | ||
| 27 | + def test_two_of_three_agreement_wins(self): | ||
| 28 | + result = root_cause_trace.judge_root_cause( | ||
| 29 | + [_fixed("planning", "d1"), _fixed("planning", "d2"), _fixed("coder", "d3")] | ||
| 30 | + ) | ||
| 31 | + self.assertEqual(result["root_cause_category"], "planning") | ||
| 32 | + self.assertNotIn("d3", result["root_cause_detail"]) | ||
| 33 | + | ||
| 34 | + def test_three_way_split_is_inconclusive(self): | ||
| 35 | + with self.assertRaises(root_cause_trace.InconclusiveEscalateError) as ctx: | ||
| 36 | + root_cause_trace.judge_root_cause( | ||
| 37 | + [_fixed("planning"), _fixed("coder"), _fixed("skill_defect")] | ||
| 38 | + ) | ||
| 39 | + self.assertEqual(len(ctx.exception.judgments), 3) | ||
| 40 | + | ||
| 41 | + def test_below_two_thirds_with_more_perspectives_is_inconclusive(self): | ||
| 42 | + # 2 out of 5 agreeing is not >=2/3 (04-P: "至少 2/3 视角一致") | ||
| 43 | + with self.assertRaises(root_cause_trace.InconclusiveEscalateError): | ||
| 44 | + root_cause_trace.judge_root_cause( | ||
| 45 | + [_fixed("coder"), _fixed("coder"), _fixed("planning"), _fixed("planning"), _fixed("skill_defect")] | ||
| 46 | + ) | ||
| 47 | + | ||
| 48 | + | ||
| 49 | +class TraceRootCauseTestFailedTest(unittest.TestCase): | ||
| 50 | + def setUp(self): | ||
| 51 | + self.conn = schema.connect(":memory:") | ||
| 52 | + schema.create_schema(self.conn) | ||
| 53 | + self.conn.execute( | ||
| 54 | + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) " | ||
| 55 | + "VALUES ('proj-1', 'P', '/tmp/proj-1', 1, 'active')" | ||
| 56 | + ) | ||
| 57 | + self.conn.execute( | ||
| 58 | + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) " | ||
| 59 | + "VALUES ('task-1', 'proj-1', 'goal', 'goal', 'validating', 'validator')" | ||
| 60 | + ) | ||
| 61 | + self.conn.commit() | ||
| 62 | + | ||
| 63 | + def tearDown(self): | ||
| 64 | + self.conn.close() | ||
| 65 | + | ||
| 66 | + def test_agreement_creates_bug_record(self): | ||
| 67 | + result = root_cause_trace.trace_root_cause_test_failed( | ||
| 68 | + self.conn, "task-1", "wall collision misjudged", | ||
| 69 | + [_fixed("coder", "SDD §3.2"), _fixed("coder", "code line 42"), _fixed("planning", "no")], | ||
| 70 | + suggested_route="fast_lane", | ||
| 71 | + ) | ||
| 72 | + self.assertEqual(result["root_cause_category"], "coder") | ||
| 73 | + row = self.conn.execute( | ||
| 74 | + "SELECT source, status, task_id FROM bug_record WHERE bug_id = ?", (result["bug_id"],) | ||
| 75 | + ).fetchone() | ||
| 76 | + self.assertEqual(row, ("validator", "routed", "task-1")) | ||
| 77 | + | ||
| 78 | + def test_disagreement_raises_and_creates_no_record(self): | ||
| 79 | + with self.assertRaises(root_cause_trace.InconclusiveEscalateError): | ||
| 80 | + root_cause_trace.trace_root_cause_test_failed( | ||
| 81 | + self.conn, "task-1", "x", | ||
| 82 | + [_fixed("planning"), _fixed("coder"), _fixed("skill_defect")], | ||
| 83 | + suggested_route="fast_lane", | ||
| 84 | + ) | ||
| 85 | + count = self.conn.execute("SELECT COUNT(*) FROM bug_record").fetchone()[0] | ||
| 86 | + self.assertEqual(count, 0) | ||
| 87 | + | ||
| 88 | + | ||
| 89 | +class TraceRootCauseHumanReportedTest(unittest.TestCase): | ||
| 90 | + def setUp(self): | ||
| 91 | + self.conn = schema.connect(":memory:") | ||
| 92 | + schema.create_schema(self.conn) | ||
| 93 | + self.conn.execute( | ||
| 94 | + "INSERT INTO bug_record (bug_id, source, description, status) " | ||
| 95 | + "VALUES ('bug-1', 'human', 'something looks broken', 'triaging')" | ||
| 96 | + ) | ||
| 97 | + self.conn.commit() | ||
| 98 | + | ||
| 99 | + def tearDown(self): | ||
| 100 | + self.conn.close() | ||
| 101 | + | ||
| 102 | + def test_completes_existing_record_not_new_one(self): | ||
| 103 | + root_cause_trace.trace_root_cause_human_reported( | ||
| 104 | + self.conn, "bug-1", [_fixed("planning", "gap"), _fixed("planning", "gap2"), _fixed("coder")], | ||
| 105 | + suggested_route="full_replan", | ||
| 106 | + ) | ||
| 107 | + count = self.conn.execute("SELECT COUNT(*) FROM bug_record").fetchone()[0] | ||
| 108 | + self.assertEqual(count, 1) | ||
| 109 | + row = self.conn.execute("SELECT status, root_cause_category FROM bug_record WHERE bug_id = 'bug-1'").fetchone() | ||
| 110 | + self.assertEqual(row, ("routed", "planning")) | ||
| 111 | + | ||
| 112 | + | ||
| 113 | +class RealValidatorPerspectiveParsingTest(unittest.TestCase): | ||
| 114 | + def test_parses_category_and_detail(self): | ||
| 115 | + fake_trigger = lambda org_node, prompt, target, timeout=300: "CATEGORY: coder\nDETAIL: off-by-one in bounds check" | ||
| 116 | + perspective_fn = root_cause_trace.real_validator_perspective( | ||
| 117 | + "correctness", "bug desc", "context", "/tmp/p", trigger_fn=fake_trigger | ||
| 118 | + ) | ||
| 119 | + result = perspective_fn() | ||
| 120 | + self.assertEqual(result, {"category": "coder", "detail": "off-by-one in bounds check"}) | ||
| 121 | + | ||
| 122 | + def test_missing_category_line_raises(self): | ||
| 123 | + fake_trigger = lambda org_node, prompt, target, timeout=300: "I have no idea what happened." | ||
| 124 | + perspective_fn = root_cause_trace.real_validator_perspective( | ||
| 125 | + "correctness", "bug desc", "context", "/tmp/p", trigger_fn=fake_trigger | ||
| 126 | + ) | ||
| 127 | + with self.assertRaises(ValueError): | ||
| 128 | + perspective_fn() | ||
| 129 | + | ||
| 130 | + | ||
| 131 | +if __name__ == "__main__": | ||
| 132 | + unittest.main() |
src/test_root_cause_trace_e2e.py
0 → 100644
| 1 | +"""U-015's real-call coverage: REQ-04's happy case, for real -- a deliberately | ||
| 2 | +injected "wall collision misjudged" bug, judged by 3 real Validator calls | ||
| 3 | +(one per perspective), expecting >=2/3 to land on root_cause_category=coder | ||
| 4 | +(the SDD clearly specifies wall-collision behavior; the code clearly | ||
| 5 | +deviates from it -- this should NOT look like a planning gap or a skill | ||
| 6 | +defect to a careful reader). Gated behind RUN_REAL_AGENT_E2E=1 (CLAUDE.md | ||
| 7 | +Testing constraint). | ||
| 8 | + | ||
| 9 | +Run: RUN_REAL_AGENT_E2E=1 python3 -m unittest src.test_root_cause_trace_e2e -v | ||
| 10 | +""" | ||
| 11 | + | ||
| 12 | +import subprocess | ||
| 13 | +import tempfile | ||
| 14 | +import unittest | ||
| 15 | + | ||
| 16 | +from src import agent_profile, org_node_trigger, root_cause_trace, schema | ||
| 17 | +from src.test_support import skip_unless_env | ||
| 18 | + | ||
| 19 | +_SDD_SNIPPET = ( | ||
| 20 | + "## Wall collision (SDD §3.2)\n" | ||
| 21 | + "The board is a grid with valid coordinates 0..N-1 in each axis. The snake's head " | ||
| 22 | + "position is checked against the board bounds every tick. A collision (game over) " | ||
| 23 | + "MUST be triggered the instant the head's x or y coordinate is < 0 or >= N (i.e. any " | ||
| 24 | + "coordinate outside the valid 0..N-1 range, inclusive of the boundary cells " | ||
| 25 | + "themselves being valid, exclusive of anything past them)." | ||
| 26 | +) | ||
| 27 | + | ||
| 28 | +_BUGGY_CODE_SNIPPET = ( | ||
| 29 | + "def check_wall_collision(head_x, head_y, board_size):\n" | ||
| 30 | + " # BUG: uses > / < instead of >= / <=, so the head is allowed to sit one cell\n" | ||
| 31 | + " # past the actual boundary before a collision is registered.\n" | ||
| 32 | + " return head_x < 0 or head_y < 0 or head_x > board_size or head_y > board_size\n" | ||
| 33 | +) | ||
| 34 | + | ||
| 35 | +_BUG_DESCRIPTION = ( | ||
| 36 | + "When the snake's head reaches the rightmost or bottommost column, the game does not " | ||
| 37 | + "end immediately -- the head is visually one cell outside the board before 'wall " | ||
| 38 | + "collision' triggers." | ||
| 39 | +) | ||
| 40 | + | ||
| 41 | + | ||
| 42 | +@skip_unless_env("RUN_REAL_AGENT_E2E") | ||
| 43 | +class RealRootCauseTraceTest(unittest.TestCase): | ||
| 44 | + def test_three_real_perspectives_agree_on_coder(self): | ||
| 45 | + with tempfile.TemporaryDirectory() as tmp: | ||
| 46 | + subprocess.run(["git", "init"], cwd=tmp, check=True, capture_output=True) | ||
| 47 | + agent_profile.deploy_agent_definitions(tmp) | ||
| 48 | + | ||
| 49 | + context = "SDD:\n{}\n\nImplementation:\n{}".format(_SDD_SNIPPET, _BUGGY_CODE_SNIPPET) | ||
| 50 | + perspective_fns = [ | ||
| 51 | + root_cause_trace.real_validator_perspective( | ||
| 52 | + name, _BUG_DESCRIPTION, context, tmp, trigger_fn=org_node_trigger.trigger_org_node | ||
| 53 | + ) | ||
| 54 | + for name in ("correctness", "requirement_completeness", "skill_defect") | ||
| 55 | + ] | ||
| 56 | + | ||
| 57 | + result = root_cause_trace.judge_root_cause(perspective_fns) | ||
| 58 | + | ||
| 59 | + self.assertEqual(result["root_cause_category"], "coder") | ||
| 60 | + self.assertTrue(result["root_cause_detail"]) | ||
| 61 | + | ||
| 62 | + | ||
| 63 | +if __name__ == "__main__": | ||
| 64 | + unittest.main() |
src/work_graph_execute.py
| @@ -103,14 +103,16 @@ def execute_work_graph(target_folder_path, name, args, output_schema, timeout=60 | @@ -103,14 +103,16 @@ def execute_work_graph(target_folder_path, name, args, output_schema, timeout=60 | ||
| 103 | timeout=timeout, | 103 | timeout=timeout, |
| 104 | ) | 104 | ) |
| 105 | if result.returncode != 0: | 105 | if result.returncode != 0: |
| 106 | - quota_type = _quota_limit_type(result.stderr) | 106 | + # 2026-07-31 fix (same bug as org_node_trigger.py): stdout can carry |
| 107 | + # the actual error detail even on nonzero exit, and messages here | ||
| 108 | + # were dropping it entirely -- check/include both streams. | ||
| 109 | + quota_type = _quota_limit_type(result.stderr) or _quota_limit_type(result.stdout) | ||
| 110 | + detail = "stderr: {}\nstdout (truncated): {}".format(result.stderr, result.stdout[:4000]) | ||
| 107 | if quota_type: | 111 | if quota_type: |
| 108 | raise UsageLimitReachedError( | 112 | raise UsageLimitReachedError( |
| 109 | - "workflow {} exited {}: {}".format(name, result.returncode, result.stderr), quota_type | 113 | + "workflow {} exited {}\n{}".format(name, result.returncode, detail), quota_type |
| 110 | ) | 114 | ) |
| 111 | - raise WorkGraphExecuteError( | ||
| 112 | - "workflow {} exited {}: {}".format(name, result.returncode, result.stderr) | ||
| 113 | - ) | 115 | + raise WorkGraphExecuteError("workflow {} exited {}\n{}".format(name, result.returncode, detail)) |
| 114 | 116 | ||
| 115 | payload = json.loads(result.stdout) | 117 | payload = json.loads(result.stdout) |
| 116 | if payload.get("is_error"): | 118 | if payload.get("is_error"): |
src/work_graph_plan.py
| @@ -121,7 +121,12 @@ def plan_work_graph(conn, org_node, task_id, goal_text, target_folder_path, time | @@ -121,7 +121,12 @@ def plan_work_graph(conn, org_node, task_id, goal_text, target_folder_path, time | ||
| 121 | timeout=timeout, | 121 | timeout=timeout, |
| 122 | ) | 122 | ) |
| 123 | if result.returncode != 0: | 123 | if result.returncode != 0: |
| 124 | - raise PlanningFailedError("plan failed for task {}: {}".format(task_id, result.stderr)) | 124 | + # 2026-07-31 fix (same bug as org_node_trigger.py): include stdout too. |
| 125 | + raise PlanningFailedError( | ||
| 126 | + "plan failed for task {}\nstderr: {}\nstdout (truncated): {}".format( | ||
| 127 | + task_id, result.stderr, result.stdout[:4000] | ||
| 128 | + ) | ||
| 129 | + ) | ||
| 125 | 130 | ||
| 126 | payload = json.loads(result.stdout) | 131 | payload = json.loads(result.stdout) |
| 127 | if payload.get("is_error"): | 132 | if payload.get("is_error"): |