Commit f8c59e368998535003fac3f00ae9eb8bd3278958

Authored by bboonnee
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>
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"):