Commit 86fc0bbcf7d197cea813929a56782198437cb30e

Authored by bboonnee
1 parent 528ec6e1

Phase 2a: node-level subagent/skill binding + dynamic_loop_back (Reflection-review + Fix-Skill)

Implements org-graph-unification-phase2-universal-self-planning-design.md
§4 + §5, the engine-extension sub-phase (Phase 2a) -- no org-flow callers
changed yet, this is the underlying capability.

- _run_one_node() now resolves subagent_type/skill_ref per-node (node.get
  with the run-level org_node/skill_id as fallback), threaded through
  _ensure_node_row/_make_run_fn/_resolve_node_verify_fn -- a node's own
  judgment.kind="llm" call runs under its own declared identity, not
  always the graph-level default. Found and fixed a real gap surfaced by
  writing this: run_graph() only ensures the run-level default skill_id
  exists as a placeholder skill (work_node_execution.skill_ref's FK
  target) -- a node declaring its own skill_ref needs the same guarantee
  or the first INSERT for that skill_id fails FOREIGN KEY constraint.

- New on_fail.action="dynamic_loop_back": Reflection-review
  (_judge_root_cause_node) runs three real parallel perspectives (content/
  connectivity/history) under the Validator identity, each picking a
  target_node_id from the failing node's real ancestors plus a defect_kind
  (content_error vs skill_defect), majority-voted by _tally_reflection
  (own small Counter-based implementation, not imported from
  root_cause_trace.py -- that module already imports this one). A runtime
  ancestor check in _run_one_node backs up _tally_reflection's own
  degradation guarantee.

- skill_defect triggers Fix-Skill (_fix_skill_for_node) before the loop
  back fires: reads the target node's declared skill_ref, builds a
  rewrite prompt from what was asked/produced/why it failed/prior loop
  history, runs it under the Validator identity (user-confirmed via
  AskUserQuestion), then calls the existing but -- until now -- never
  actually invoked by any production path bug_record.create_bug_record()
  + skill_update.update_skill(), against skill_query._REPO_ROOT (never
  target_folder_path). Logs a real 'skill_iterated' execution_event (a
  CHECK-enum value that has existed since the original U-001 schema but
  was never written before). Rate-limited to
  _MAX_SKILL_FIXES_PER_RUN_PER_SKILL rewrites of the same Skill per run.

- New total-attempts-per-source-node loop_limit guard alongside the
  existing per-(target,source) one -- dynamic_loop_back can pick a
  different target each round, which would otherwise dodge the per-pair
  cap indefinitely. New settings_config key shape
  "{org_node}:{source_node_id}:total", no interface change needed there.

- New skills/skill-defect-remediation/SKILL.md (Validator-only), wired
  into roles/validator/role.json.

python3 -m unittest discover -s src (741 tests) + discover -s supervisor
-p "test_*.py" (126 tests) both green, zero regressions.

Deferred to Phase 2b (per design doc's own sequencing note): §2.1/§2.2
(work_graph_run.org_node removal + ux_work_graph_run_active index
narrowing) -- narrowing that index now, before the append-to-existing-run
mechanism lands, would break the current production flow, since Planner's
and Coder's work_graph_run rows both stay non-terminal indefinitely on
the happy path today (only Validator's own graph ever reaches 'done').

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
roles/validator/role.json
... ... @@ -14,7 +14,8 @@
14 14 "qa-task-validation",
15 15 "unattended-agent-operations",
16 16 "design-dynamic-work-graph",
17   - "unknown-scope-defect-sweep"
  17 + "unknown-scope-defect-sweep",
  18 + "skill-defect-remediation"
18 19 ],
19 20 "applications": {
20 21 "mcp_servers": []
... ...
skills/skill-defect-remediation/SKILL.md 0 → 100644
  1 +---
  2 +name: skill-defect-remediation
  3 +description: |
  4 + 当 Reflection-review 判定一次节点失败的根因是系统性 Skill 缺陷(不是这一次内容的偶然错误)时,
  5 + 怎么安全地修订这份 Skill 文件本身——精确定位并只修正导致这次真实失败的那一小部分,不破坏该
  6 + Skill 对其他场景已经正确的既有指导。产出必须是完整的、可直接替换生效的 Skill 内容,不是
  7 + diff 或建议清单。
  8 +version: 1.0.0
  9 +tags:
  10 + - skill-iteration
  11 + - remediation
  12 + - self-evolution
  13 +---
  14 +
  15 +# Skill 缺陷修复
  16 +
  17 +## 我在做什么、不做什么
  18 +
  19 +我在改一份 Skill 文件的内容,不是在改这一次任务的输出。触发条件是 Reflection-review(`_judge_root_cause_node`)已经判定:这不是"这次恰好没写好"(`content_error`),而是"这份 Skill 本身的方法论/提示词有系统性偏差,同类任务会反复踩同一个坑"(`skill_defect`)。
  20 +
  21 +我拿到的输入是:这份 Skill 当前的完整内容、使用它的节点这次被要求做什么、它实际产出了什么、为什么被判定失败、这个节点在本次 run 内此前被退回的历史(如果有)。我的输出会被 `skill_update.update_skill()` 直接、原样写入 Skill 文件并提交 Git——不是草案,不是给人看的建议,是最终生效内容。
  22 +
  23 +## 核心原则:手术刀式修改,不是重写
  24 +
  25 +- **只改导致这次失败的那一部分**。这份 Skill 对其他场景的既有指导,大概率是对的——它能走到今天,说明大部分情况下有效。逐字重写整份文件、"顺手优化"看起来不够好的其他段落,是最常见的过度修改陷阱,会引入新的、这次任务范围之外的回归风险。
  26 +- **先定位到具体是哪一句/哪一段导致了偏差,再改**。不要停留在"这份 Skill 整体上不够好"这种笼统判断——不可操作,也无法验证改对了没有。好的定位是这个形状:"第 X 节要求 Y,但没有说明 Z 场景下应该怎么做,导致模型默认走了错误的 W 路径"。
  27 +- **改动要能解释"为什么这次会防住同一类失败"**,不是"看起来更完善了"。如果说不清楚新内容具体怎么拦住了这次复现的失败模式,说明还没找到真正的病灶。
  28 +
  29 +## 一个真实的参考案例:叙述文字冒充结构化产出
  30 +
  31 +这个代码库真实反复复现过的一类系统性 Skill 缺陷(`docs/todo/validator-test-script-narrative-output-issue.md` §6 完整根因记录):要求"先用工具探索、再产出最终内容"的节点(比如需要先读真实代码才能写 SDD/Benchmark 的场景),模型探索完之后的收尾话术天然倾向"汇报我刚才做了/启动了什么",而不是切换成"直接吐出最终内容"模式——即使 Skill 提示词已经写了"不要写叙述文字",这句话附加在提示词末尾的格式规则,不足以覆盖"多轮工具调用之后的收尾习惯"这个更底层的行为模式。
  32 +
  33 +如果这次要修的 Skill 也有类似"先探索、再产出"的结构,而失败症状也是"产出的是进度汇报式散文,不是结构化内容",可以直接参考这个案例的诊断方式,但**不要机械照抄"加一句不要写叙述文字"这种已经被证明不够的修法**——真正管用的方向是把"探索阶段"和"最终作答阶段"在提示词结构上明确分开(比如要求模型先完成探索、再有一个清晰的"现在开始,你的下一段输出必须且只能是最终内容本身"的转折),而不是在末尾追加一句格式提醒。
  34 +
  35 +## 输出
  36 +
  37 +`new_content`:完整的、修订后的 Skill 文件内容(不是 diff、不是片段)。`change_summary`:一句话说清楚改了什么、为什么——这句话会被记录进 `skill_iteration.change_summary`,是这次 Skill 迭代唯一的人类可读说明,写清楚比写简短更重要。
... ...
src/test_work_graph_orchestrate.py
... ... @@ -9,13 +9,17 @@ Run: python3 -m unittest src.test_work_graph_orchestrate -v
9 9  
10 10 import json
11 11 import os
  12 +import subprocess
12 13 import tempfile
13 14 import threading
14 15 import time
15 16 import unittest
16 17 from unittest.mock import patch
17 18  
18   -from src import execution_log, paths, schema, work_graph_execute, work_graph_orchestrate as wgo, work_node_run
  19 +from src import (
  20 + execution_log, paths, schema, skill_query, skill_update,
  21 + work_graph_execute, work_graph_orchestrate as wgo, work_node_run,
  22 +)
19 23  
20 24  
21 25 class TopologicalOrderTest(unittest.TestCase):
... ... @@ -1257,5 +1261,313 @@ class RunGraphLoopBackTest(RunGraphTestBase):
1257 1261 self.assertEqual(mock_execute.call_count, 2)
1258 1262  
1259 1263  
  1264 +class NodeLevelSubagentSkillTest(RunGraphTestBase):
  1265 + """Phase 2 §4: a node can declare its own subagent_type/skill_ref,
  1266 + overriding the run-level org_node/skill_id defaults -- SDD §10 Phase 2a
  1267 + acceptance criterion 1."""
  1268 +
  1269 + def test_node_level_fields_override_run_level_defaults(self):
  1270 + manifest = {
  1271 + "nodes": [
  1272 + {"node_id": "a", "depends_on": [], "subagent_type": "coder", "skill_ref": "code-task-development"},
  1273 + {"node_id": "b", "depends_on": []}, # no override -- uses run-level org_node/skill_id
  1274 + ],
  1275 + "edges": [],
  1276 + }
  1277 + with patch.object(work_graph_execute, "write_workflow_script"), \
  1278 + patch.object(work_graph_execute, "execute_work_graph", return_value={"content": "x"}):
  1279 + wgo.run_graph(
  1280 + self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest,
  1281 + lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA,
  1282 + )
  1283 + rows = {
  1284 + r[0]: (r[1], r[2]) for r in self.conn.execute(
  1285 + "SELECT node_id, subagent_type, skill_ref FROM work_node_execution WHERE run_id = 'run-1'"
  1286 + ).fetchall()
  1287 + }
  1288 + self.assertEqual(rows["a"], ("coder", "code-task-development"))
  1289 + self.assertEqual(rows["b"], ("planner", "planner-doc-writing"))
  1290 +
  1291 + def test_node_level_org_node_used_for_llm_judgment_agent_identity(self):
  1292 + # A node's own judgment.kind='llm' call must run under ITS OWN
  1293 + # subagent_type, not the run-level default -- otherwise a node
  1294 + # declaring subagent_type='validator' for judgment purposes would
  1295 + # silently get judged under the wrong persona.
  1296 + manifest = {
  1297 + "nodes": [{
  1298 + "node_id": "a", "depends_on": [], "subagent_type": "coder",
  1299 + "judgment": {"kind": "llm", "judge_prompt": "check {content}"},
  1300 + "on_fail": {"action": "hard_fail"},
  1301 + }],
  1302 + "edges": [],
  1303 + }
  1304 + seen_agent_types = []
  1305 +
  1306 + def fake_execute(cwd, name, args, output_schema, timeout=600):
  1307 + if "ok" in output_schema.get("required", []):
  1308 + return {"ok": True, "issues": []}
  1309 + return {"content": "x"}
  1310 +
  1311 + def fake_write(cwd, name, script_content):
  1312 + if "graphlemon Work Node" in script_content or "graphlemon node judgment" in script_content:
  1313 + seen_agent_types.append(script_content)
  1314 +
  1315 + with patch.object(work_graph_execute, "write_workflow_script", side_effect=fake_write), \
  1316 + patch.object(work_graph_execute, "execute_work_graph", side_effect=fake_execute):
  1317 + wgo.run_graph(
  1318 + self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest,
  1319 + lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA,
  1320 + )
  1321 + judgment_script = next(s for s in seen_agent_types if "graphlemon node judgment" in s)
  1322 + # _llm_judgment_verify_fn renders agentType as an unquoted JS
  1323 + # identifier key (agentType: "coder"), not a JSON key -- only the
  1324 + # VALUE is json.dumps'd.
  1325 + self.assertIn('agentType:"coder"', judgment_script.replace(" ", ""))
  1326 +
  1327 +
  1328 +class DynamicLoopBackContentErrorTest(RunGraphTestBase):
  1329 + """Phase 2 §5: on_fail.action='dynamic_loop_back' -- Reflection-review
  1330 + picks a target node from the failing node's real ancestors and judges
  1331 + defect_kind. content_error skips Fix-Skill and loops back directly --
  1332 + SDD §10 Phase 2a acceptance criterion 2."""
  1333 +
  1334 + def test_unanimous_content_error_loops_back_to_chosen_ancestor(self):
  1335 + manifest = {
  1336 + "nodes": [
  1337 + {"node_id": "sdd", "depends_on": []},
  1338 + {
  1339 + "node_id": "final_review", "depends_on": ["sdd"],
  1340 + "judgment": {"kind": "mechanical", "check_ref": "is_good"},
  1341 + "on_fail": {"action": "dynamic_loop_back", "max_attempts": 3},
  1342 + },
  1343 + ],
  1344 + "edges": [],
  1345 + }
  1346 + sdd_contents = ["sdd v1", "sdd v2 (fixed)"]
  1347 + node_call_count = [0]
  1348 + lock = threading.Lock()
  1349 +
  1350 + def side_effect(cwd, name, args, output_schema, timeout=600):
  1351 + if name.startswith("graphlemon-reflection-"):
  1352 + return {"target_node_id": "sdd", "defect_kind": "content_error", "detail": "sdd is stale"}
  1353 + if name.startswith("graphlemon-node-"):
  1354 + with lock:
  1355 + node_call_count[0] += 1
  1356 + idx = node_call_count[0]
  1357 + if idx == 1:
  1358 + return {"content": sdd_contents[0]}
  1359 + if idx == 2:
  1360 + return {"content": "bad final review"}
  1361 + if idx == 3:
  1362 + return {"content": sdd_contents[1]}
  1363 + return {"content": "good final review"}
  1364 + raise AssertionError("unexpected script name: " + name)
  1365 +
  1366 + registry = {"is_good": lambda content: (content == "good final review", None if content == "good final review" else "rejected: " + content)}
  1367 +
  1368 + with patch.object(work_graph_execute, "write_workflow_script"), \
  1369 + patch.object(work_graph_execute, "execute_work_graph", side_effect=side_effect):
  1370 + outputs = wgo.run_graph(
  1371 + self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest,
  1372 + lambda node, outputs: "prompt for " + node["node_id"], _IN_SCHEMA, _OUT_SCHEMA,
  1373 + mechanical_check_registry=registry,
  1374 + )
  1375 +
  1376 + self.assertEqual(outputs["sdd"], {"content": sdd_contents[1]})
  1377 + self.assertEqual(outputs["final_review"], {"content": "good final review"})
  1378 +
  1379 + rows = [
  1380 + r[0] for r in self.conn.execute(
  1381 + "SELECT node_id FROM work_node_execution WHERE run_id = 'run-1' ORDER BY node_id"
  1382 + ).fetchall()
  1383 + ]
  1384 + self.assertEqual(rows, ["final_review", "final_review-attempt2", "sdd", "sdd-attempt2"])
  1385 +
  1386 + events = self.conn.execute(
  1387 + "SELECT payload FROM execution_event WHERE event_type = 'loop_back_triggered'"
  1388 + ).fetchall()
  1389 + self.assertEqual(len(events), 1)
  1390 + payload = json.loads(events[0][0])
  1391 + self.assertEqual(payload["source_node_id"], "final_review")
  1392 + self.assertEqual(payload["target_node_id"], "sdd")
  1393 +
  1394 + # no skill_iterated event -- content_error must not touch any Skill.
  1395 + skill_events = self.conn.execute(
  1396 + "SELECT 1 FROM execution_event WHERE event_type = 'skill_iterated'"
  1397 + ).fetchall()
  1398 + self.assertEqual(skill_events, [])
  1399 +
  1400 +class TallyReflectionTest(unittest.TestCase):
  1401 + """_tally_reflection in isolation -- the majority-vote + degradation
  1402 + logic _judge_root_cause_node relies on. An invalid winner (not among
  1403 + candidate_node_ids) is corrected HERE, before _run_one_node's own
  1404 + 'target not in ancestors' runtime check (§5.7 point 1) would ever see
  1405 + it -- that check is defense-in-depth for if this guarantee is ever
  1406 + weakened, not the primary enforcement, so it's tested at this level."""
  1407 +
  1408 + def test_unanimous_valid_winner(self):
  1409 + judgments = [
  1410 + {"target_node_id": "sdd", "defect_kind": "content_error", "detail": "a"},
  1411 + {"target_node_id": "sdd", "defect_kind": "content_error", "detail": "b"},
  1412 + {"target_node_id": "sdd", "defect_kind": "content_error", "detail": "c"},
  1413 + ]
  1414 + result = wgo._tally_reflection(judgments, ["sdd", "prd"])
  1415 + self.assertEqual(result["target_node_id"], "sdd")
  1416 + self.assertEqual(result["defect_kind"], "content_error")
  1417 +
  1418 + def test_two_thirds_skill_defect_majority(self):
  1419 + judgments = [
  1420 + {"target_node_id": "sdd", "defect_kind": "skill_defect", "detail": "a"},
  1421 + {"target_node_id": "sdd", "defect_kind": "skill_defect", "detail": "b"},
  1422 + {"target_node_id": "sdd", "defect_kind": "content_error", "detail": "c"},
  1423 + ]
  1424 + result = wgo._tally_reflection(judgments, ["sdd", "prd"])
  1425 + self.assertEqual(result["target_node_id"], "sdd")
  1426 + self.assertEqual(result["defect_kind"], "skill_defect")
  1427 +
  1428 + def test_winner_not_in_candidates_degrades_to_first_candidate_content_error(self):
  1429 + judgments = [
  1430 + {"target_node_id": "not_a_candidate", "defect_kind": "skill_defect", "detail": "a"},
  1431 + {"target_node_id": "not_a_candidate", "defect_kind": "skill_defect", "detail": "b"},
  1432 + {"target_node_id": "not_a_candidate", "defect_kind": "skill_defect", "detail": "c"},
  1433 + ]
  1434 + result = wgo._tally_reflection(judgments, ["sdd", "prd"])
  1435 + self.assertEqual(result["target_node_id"], "sdd")
  1436 + self.assertEqual(result["defect_kind"], "content_error")
  1437 +
  1438 + def test_no_two_thirds_agreement_degrades(self):
  1439 + judgments = [
  1440 + {"target_node_id": "sdd", "defect_kind": "skill_defect", "detail": "a"},
  1441 + {"target_node_id": "prd", "defect_kind": "content_error", "detail": "b"},
  1442 + {"target_node_id": "benchmark", "defect_kind": "content_error", "detail": "c"},
  1443 + ]
  1444 + result = wgo._tally_reflection(judgments, ["sdd", "prd", "benchmark"])
  1445 + self.assertEqual(result["defect_kind"], "content_error")
  1446 + self.assertIn(result["target_node_id"], ["sdd", "prd", "benchmark"])
  1447 +
  1448 +
  1449 +def _init_git_repo(path):
  1450 + subprocess.run(["git", "init"], cwd=path, check=True, capture_output=True)
  1451 +
  1452 +
  1453 +class DynamicLoopBackSkillDefectTest(RunGraphTestBase):
  1454 + """Phase 2 §5.5: defect_kind='skill_defect' triggers Fix-Skill -- a
  1455 + real skill_update.update_skill() call against a real (throwaway) git
  1456 + repo, never graphlemon's own checkout -- before the loop_back fires.
  1457 + SDD §10 Phase 2a acceptance criterion 3."""
  1458 +
  1459 + def setUp(self):
  1460 + super().setUp()
  1461 + self.skill_repo = tempfile.TemporaryDirectory()
  1462 + self.addCleanup(self.skill_repo.cleanup)
  1463 + _init_git_repo(self.skill_repo.name)
  1464 + skill_update._write_and_commit(self.skill_repo.name, "skills/my-skill/SKILL.md", "v1 content", "seed skill")
  1465 + self.conn.execute(
  1466 + "INSERT INTO skill (skill_id, owner_org_node, file_path, current_git_commit_sha, source, status) "
  1467 + "VALUES ('my-skill', 'coder', 'skills/my-skill', 'unknown', 'migrated_from_hermes', 'active')"
  1468 + )
  1469 + self.conn.commit()
  1470 + self.repo_root_patch = patch.object(skill_query, "_REPO_ROOT", self.skill_repo.name)
  1471 + self.repo_root_patch.start()
  1472 + self.addCleanup(self.repo_root_patch.stop)
  1473 +
  1474 + def test_skill_defect_calls_real_update_skill_before_loop_back(self):
  1475 + manifest = {
  1476 + "nodes": [
  1477 + {"node_id": "sdd", "depends_on": [], "skill_ref": "my-skill"},
  1478 + {
  1479 + "node_id": "final_review", "depends_on": ["sdd"],
  1480 + "judgment": {"kind": "mechanical", "check_ref": "is_good"},
  1481 + "on_fail": {"action": "dynamic_loop_back", "max_attempts": 3},
  1482 + },
  1483 + ],
  1484 + "edges": [],
  1485 + }
  1486 + node_call_count = [0]
  1487 + lock = threading.Lock()
  1488 +
  1489 + def side_effect(cwd, name, args, output_schema, timeout=600):
  1490 + if name.startswith("graphlemon-reflection-"):
  1491 + return {"target_node_id": "sdd", "defect_kind": "skill_defect", "detail": "systemic issue"}
  1492 + if name.startswith("graphlemon-fixskill-"):
  1493 + return {"new_content": "v2 content, fixed", "change_summary": "fixed the systemic issue"}
  1494 + if name.startswith("graphlemon-node-"):
  1495 + with lock:
  1496 + node_call_count[0] += 1
  1497 + idx = node_call_count[0]
  1498 + if idx in (1, 3):
  1499 + return {"content": "sdd content"}
  1500 + if idx == 2:
  1501 + return {"content": "bad final review"}
  1502 + return {"content": "good final review"}
  1503 + raise AssertionError("unexpected script name: " + name)
  1504 +
  1505 + registry = {"is_good": lambda content: (content == "good final review", None if content == "good final review" else "rejected: " + content)}
  1506 +
  1507 + with patch.object(work_graph_execute, "write_workflow_script"), \
  1508 + patch.object(work_graph_execute, "execute_work_graph", side_effect=side_effect):
  1509 + outputs = wgo.run_graph(
  1510 + self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest,
  1511 + lambda node, outputs: "prompt for " + node["node_id"], _IN_SCHEMA, _OUT_SCHEMA,
  1512 + mechanical_check_registry=registry,
  1513 + )
  1514 +
  1515 + self.assertEqual(outputs["final_review"], {"content": "good final review"})
  1516 +
  1517 + # a real skill_iteration row was produced, with a real (different)
  1518 + # after_git_commit_sha -- not a stub, an actual git commit.
  1519 + iteration = self.conn.execute(
  1520 + "SELECT skill_id, before_git_commit_sha, after_git_commit_sha, change_summary "
  1521 + "FROM skill_iteration"
  1522 + ).fetchone()
  1523 + self.assertEqual(iteration[0], "my-skill")
  1524 + self.assertNotEqual(iteration[1], iteration[2])
  1525 + self.assertEqual(iteration[3], "fixed the systemic issue")
  1526 +
  1527 + with open(os.path.join(self.skill_repo.name, "skills/my-skill/SKILL.md")) as f:
  1528 + self.assertEqual(f.read(), "v2 content, fixed")
  1529 +
  1530 + # a real bug_record(root_cause_category='skill_defect') was created
  1531 + # and linked back to the iteration (skill_update.update_skill()'s
  1532 + # own contract).
  1533 + bug_row = self.conn.execute(
  1534 + "SELECT root_cause_category, skill_iteration_suggestion_id FROM bug_record"
  1535 + ).fetchone()
  1536 + self.assertEqual(bug_row[0], "skill_defect")
  1537 + self.assertIsNotNone(bug_row[1])
  1538 +
  1539 + # skill_iterated execution_event recorded (existing but previously
  1540 + # never-written CHECK enum value).
  1541 + skill_events = self.conn.execute(
  1542 + "SELECT payload FROM execution_event WHERE event_type = 'skill_iterated'"
  1543 + ).fetchall()
  1544 + self.assertEqual(len(skill_events), 1)
  1545 + payload = json.loads(skill_events[0][0])
  1546 + self.assertEqual(payload["node_id"], "sdd")
  1547 + self.assertEqual(payload["skill_id"], "my-skill")
  1548 +
  1549 + # graphlemon's own real checkout was never touched -- the Skill
  1550 + # write landed only in the throwaway skill_repo tempdir.
  1551 + self.assertFalse(
  1552 + os.path.exists(os.path.join(paths.repo_root(), "skills", "my-skill", "SKILL.md.orig"))
  1553 + )
  1554 +
  1555 + def test_rate_limit_stops_repeated_rewrites_of_same_skill(self):
  1556 + # §5.7 point 5: after _MAX_SKILL_FIXES_PER_RUN_PER_SKILL rewrites of
  1557 + # the same (run_id, skill_id) this run, Fix-Skill silently skips --
  1558 + # dynamic_loop_back still proceeds as a plain loop_back.
  1559 + for i in range(wgo._MAX_SKILL_FIXES_PER_RUN_PER_SKILL):
  1560 + wgo._log_skill_iterated_event(self.conn, "run-1", "sdd", "my-skill", "iteration-{}".format(i))
  1561 +
  1562 + manifest = {"nodes": [{"node_id": "sdd", "depends_on": [], "skill_ref": "my-skill"}], "edges": []}
  1563 + result = wgo._fix_skill_for_node(
  1564 + self.conn, "run-1", "sdd", manifest, lambda node, outputs: "p", {}, ["feedback"], "/tmp/proj-1",
  1565 + )
  1566 + self.assertIsNone(result)
  1567 + # no new skill_iteration row was created beyond what we seeded above.
  1568 + count = self.conn.execute("SELECT COUNT(*) FROM skill_iteration").fetchone()[0]
  1569 + self.assertEqual(count, 0) # the two seeded events above didn't insert real skill_iteration rows
  1570 +
  1571 +
1260 1572 if __name__ == "__main__":
1261 1573 unittest.main()
... ...
src/work_graph_orchestrate.py
... ... @@ -66,11 +66,13 @@ actually happened (N attempts), not a bug.
66 66  
67 67 import concurrent.futures
68 68 import json
  69 +import os
69 70 import threading
70 71 import time
71 72 import uuid
  73 +from collections import Counter
72 74  
73   -from src import execution_log, paths, settings_config, work_graph_execute, work_node_run
  75 +from src import bug_record, execution_log, paths, settings_config, skill_query, skill_update, work_graph_execute, work_node_run
74 76  
75 77  
76 78 def _log_event(conn, node_exec_id, event_type, **payload):
... ... @@ -506,6 +508,323 @@ def _validate_mechanical_check_refs(graph_manifest, mechanical_check_registry):
506 508 )
507 509  
508 510  
  511 +_REFLECTION_OUTPUT_SCHEMA = {
  512 + "type": "object", "required": ["target_node_id", "defect_kind", "detail"],
  513 + "properties": {
  514 + "target_node_id": {"type": "string"},
  515 + "defect_kind": {"type": "string", "enum": ["content_error", "skill_defect"]},
  516 + "detail": {"type": "string"},
  517 + },
  518 +}
  519 +
  520 +_REFLECTION_FRAMINGS = {
  521 + "content": (
  522 + "CONTENT PERSPECTIVE: for each candidate node below, look at what it was asked to produce "
  523 + "and what it actually produced. Which ONE candidate's output itself is genuinely defective? "
  524 + "Then decide: is this a one-off mistake for this specific run ('content_error' -- the "
  525 + "underlying methodology is fine, this particular attempt just didn't execute it well), or "
  526 + "does it look like a systematic pattern that would recur on other tasks using the same "
  527 + "approach/prompt style ('skill_defect')? A concrete red flag for skill_defect: the node's "
  528 + "content reads as a narrative description of an action taken (e.g. 'I launched/produced/"
  529 + "completed X') instead of the structured content itself -- this is a known recurring failure "
  530 + "mode in nodes that explore with tools before producing a final answer, not a one-off typo."
  531 + ),
  532 + "connectivity": (
  533 + "CONNECTIVITY PERSPECTIVE: if no single candidate's content is obviously wrong on its own, "
  534 + "is the real problem how the candidates connect -- e.g. a node that should have depended on "
  535 + "another candidate's output but didn't, so it worked from stale or missing context? This "
  536 + "kind of problem is almost always 'content_error' (a one-off planning slip for this run), not "
  537 + "a systemic Skill defect -- pick whichever candidate's isolation from its true dependency is "
  538 + "the root issue."
  539 + ),
  540 + "history": (
  541 + "HISTORY PERSPECTIVE: you are given this run's actual loop_back/skill-iteration event history "
  542 + "for each candidate. If a candidate has already been sent back for revision or had its Skill "
  543 + "rewritten before in this same run for a SIMILAR reason, that recurrence itself is strong "
  544 + "evidence of 'skill_defect' -- a Skill that keeps producing the same class of failure even "
  545 + "after a plain retry has methodology problems, not bad luck. If a candidate has no such prior "
  546 + "history, default to 'content_error' unless the content itself (see the CONTENT framing's red "
  547 + "flag) makes skill_defect obvious even on a first occurrence."
  548 + ),
  549 +}
  550 +
  551 +
  552 +def _candidate_context_block(graph_manifest, build_prompt, outputs, candidate_node_ids):
  553 + nodes_by_id = {n["node_id"]: n for n in graph_manifest["nodes"]}
  554 + parts = []
  555 + for node_id in candidate_node_ids:
  556 + node = nodes_by_id[node_id]
  557 + asked = build_prompt(node, outputs)
  558 + produced = (outputs.get(node_id) or {}).get("content", "")
  559 + parts.append(
  560 + "--- candidate node_id={} ---\nWhat it was asked:\n{}\n\nWhat it produced:\n{}".format(
  561 + node_id, asked, produced
  562 + )
  563 + )
  564 + return "\n\n".join(parts)
  565 +
  566 +
  567 +def _candidate_history_block(conn, run_id, candidate_node_ids):
  568 + events = _loop_back_events(conn, run_id)
  569 + skill_events = _skill_iterated_events(conn, run_id)
  570 + parts = []
  571 + for node_id in candidate_node_ids:
  572 + prior_loop_backs = [e for e in events if e["target_node_id"] == node_id]
  573 + prior_skill_fixes = [e for e in skill_events if e["node_id"] == node_id]
  574 + parts.append(
  575 + "--- candidate node_id={} --- prior loop_back count: {}; prior skill_iterated count: {}".format(
  576 + node_id, len(prior_loop_backs), len(prior_skill_fixes)
  577 + )
  578 + )
  579 + return "\n".join(parts)
  580 +
  581 +
  582 +def _reflection_prompt(framing_name, failed_node_id, feedback, candidate_node_ids, graph_manifest, build_prompt, outputs, conn, run_id):
  583 + framing = _REFLECTION_FRAMINGS[framing_name]
  584 + context = (
  585 + _candidate_history_block(conn, run_id, candidate_node_ids) if framing_name == "history"
  586 + else _candidate_context_block(graph_manifest, build_prompt, outputs, candidate_node_ids)
  587 + )
  588 + return (
  589 + "{framing}\n\nThe node {failed_node_id} failed judgment with this feedback: {feedback}\n\n"
  590 + "Candidates (pick target_node_id from EXACTLY these node_ids, nothing else):\n{candidates}\n\n"
  591 + "Context:\n{context}\n\n"
  592 + "Reply with target_node_id (one of the candidate node_ids above), defect_kind "
  593 + "('content_error' or 'skill_defect'), and detail (one sentence justifying both)."
  594 + ).format(
  595 + framing=framing, failed_node_id=failed_node_id, feedback="; ".join(feedback or []),
  596 + candidates=", ".join(candidate_node_ids), context=context,
  597 + )
  598 +
  599 +
  600 +def _run_reflection_perspective(framing_name, prompt, target_folder_path, timeout):
  601 + """One perspective's real call, under the Validator identity (Reflection-
  602 + review is diagnostic, not opinion-laden about the failed content itself
  603 + -- same 'independent second opinion' reasoning Fix-Skill's own 'who
  604 + rewrites the Skill' decision uses, see _fix_skill_for_node). Same
  605 + self-target worktree isolation as _llm_judgment_verify_fn (a real
  606 + agent() call with memory:project writes an uncommitted change wherever
  607 + it runs)."""
  608 + is_self = paths.is_self_target(target_folder_path)
  609 + node_exec_id = "reflection-" + framing_name + "-" + uuid.uuid4().hex[:12]
  610 + worktree_path = work_node_run.create_worktree(target_folder_path, node_exec_id) if is_self else None
  611 + cwd = worktree_path or target_folder_path
  612 + try:
  613 + script_name = "graphlemon-reflection-" + uuid.uuid4().hex
  614 + script_content = (
  615 + "export const meta = {{ name: {name}, description: 'graphlemon Reflection-review ({framing})' }}\n\n"
  616 + "const result = await agent({prompt}, {{ agentType: 'validator', schema: {schema} }})\n\n"
  617 + "return result\n"
  618 + ).format(
  619 + name=json.dumps(script_name), framing=framing_name, prompt=json.dumps(prompt),
  620 + schema=json.dumps(_REFLECTION_OUTPUT_SCHEMA),
  621 + )
  622 + work_graph_execute.write_workflow_script(cwd, script_name, script_content)
  623 + result = work_graph_execute.execute_work_graph(cwd, script_name, {}, _REFLECTION_OUTPUT_SCHEMA, timeout=timeout)
  624 + if worktree_path:
  625 + work_node_run.ensure_worktree_committed(worktree_path, node_exec_id)
  626 + work_node_run.ensure_worktree_committed(target_folder_path, node_exec_id + "-main-checkout-drift")
  627 + work_node_run.merge_worktree(target_folder_path, node_exec_id)
  628 + return {
  629 + "target_node_id": result["target_node_id"],
  630 + "defect_kind": result["defect_kind"],
  631 + "detail": result.get("detail", ""),
  632 + }
  633 + finally:
  634 + if worktree_path:
  635 + work_node_run.remove_worktree(target_folder_path, worktree_path)
  636 +
  637 +
  638 +def _tally_reflection(judgments, candidate_node_ids):
  639 + """>=2/3 agreement on target_node_id, same threshold root_cause_trace.py's
  640 + _tally_perspectives uses -- not imported from there (would create a
  641 + circular import, root_cause_trace.py already imports this module) and
  642 + small enough to duplicate, same reasoning work_graph_plan.py's own
  643 + _transitive_ancestors duplication already documents. defect_kind is
  644 + taken from whichever judgment(s) picked the winning target_node_id
  645 + (majority among just those, defaulting to 'content_error' on a tie --
  646 + the conservative choice per §5.7 point 2). <2/3 agreement on
  647 + target_node_id degrades to the nearest direct ancestor + 'content_error'
  648 + rather than escalating to a human -- this is one retry inside a single
  649 + Work Graph run, not the terminal cross-Org-Agent judgment
  650 + root_cause_trace.py's InconclusiveEscalateError guards."""
  651 + target_counts = Counter(j["target_node_id"] for j in judgments)
  652 + winner, count = target_counts.most_common(1)[0]
  653 + if count * 2 < len(judgments) or winner not in candidate_node_ids:
  654 + return {"target_node_id": candidate_node_ids[0], "defect_kind": "content_error", "detail": "no >=2/3 agreement"}
  655 + kind_counts = Counter(j["defect_kind"] for j in judgments if j["target_node_id"] == winner)
  656 + defect_kind = "skill_defect" if kind_counts.get("skill_defect", 0) > kind_counts.get("content_error", 0) else "content_error"
  657 + detail = " | ".join(j["detail"] for j in judgments if j["target_node_id"] == winner)
  658 + return {"target_node_id": winner, "defect_kind": defect_kind, "detail": detail}
  659 +
  660 +
  661 +def _judge_root_cause_node(
  662 + conn, run_id, failed_node_id, feedback, candidate_node_ids, graph_manifest, build_prompt, outputs,
  663 + target_folder_path, timeout=180,
  664 +):
  665 + """Reflection-review (org-graph-unification-and-universal-self-planning-
  666 + requirements.md §3.1 step 2): three independent real perspectives --
  667 + content, connectivity, history -- each picks a target_node_id from
  668 + candidate_node_ids and a defect_kind, run concurrently (own real claude
  669 + call, own worktree if self-target). Majority vote via _tally_reflection.
  670 + candidate_node_ids must be non-empty (the caller, _run_one_node's
  671 + dynamic_loop_back branch, only calls this when the failing node has at
  672 + least one real ancestor -- a terminal judgment node with zero ancestors
  673 + has nothing to loop back to and shouldn't declare dynamic_loop_back)."""
  674 + with concurrent.futures.ThreadPoolExecutor(max_workers=3) as pool:
  675 + futures = [
  676 + pool.submit(
  677 + _run_reflection_perspective, name,
  678 + _reflection_prompt(name, failed_node_id, feedback, candidate_node_ids, graph_manifest, build_prompt, outputs, conn, run_id),
  679 + target_folder_path, timeout,
  680 + )
  681 + for name in _REFLECTION_FRAMINGS
  682 + ]
  683 + judgments = [f.result() for f in futures]
  684 + return _tally_reflection(judgments, candidate_node_ids)
  685 +
  686 +
  687 +_SKILL_REWRITE_SCHEMA = {
  688 + "type": "object", "required": ["new_content", "change_summary"],
  689 + "properties": {"new_content": {"type": "string"}, "change_summary": {"type": "string"}},
  690 +}
  691 +
  692 +_SKILL_REWRITE_PROMPT = (
  693 + "You are revising a Skill file (the reusable methodology/prompt guidance a graphlemon Org Agent "
  694 + "follows) because it produced a real, repeated failure. Your job is surgical: fix the SPECIFIC "
  695 + "gap that caused this failure, without breaking the Skill's existing correct guidance for other "
  696 + "cases it already handles well.\n\n"
  697 + "Goal this task is working on: {goal_text}\n\n"
  698 + "Skill being revised (skill_id={skill_id}):\n{current_content}\n\n"
  699 + "What the node using this Skill was asked to do:\n{what_was_asked}\n\n"
  700 + "What it actually produced (the defective output):\n{what_it_produced}\n\n"
  701 + "Why this was judged a failure: {failure_feedback}\n\n"
  702 + "This node's prior rejection history in this same run (if any): {loop_history}\n\n"
  703 + "Reply with new_content (the COMPLETE revised Skill file content -- not a diff, not a partial "
  704 + "excerpt) and change_summary (one sentence describing what changed and why)."
  705 +)
  706 +
  707 +
  708 +def _read_skill_content(skill_id):
  709 + """Skill library content lives in graphlemon's own repo checkout
  710 + (skill_query._REPO_ROOT), never in target_folder_path -- same
  711 + established boundary c_skill_diff/c_skill_rollback's HTTP routes
  712 + already rely on (05 §5.4.1.4), reused here rather than re-derived."""
  713 + path = os.path.join(skill_query._REPO_ROOT, "skills", skill_id, "SKILL.md")
  714 + with open(path, encoding="utf-8") as f:
  715 + return f.read()
  716 +
  717 +
  718 +def _log_skill_iterated_event(conn, run_id, node_id, skill_id, iteration_id):
  719 + """Persists a 'skill_iterated' execution_event -- this CHECK-enum value
  720 + has existed since the original U-001 schema but was never actually
  721 + written by any code path before Fix-Skill (skill_update.update_skill()
  722 + itself doesn't log one; it's this caller's job, same division of
  723 + responsibility loop_back_triggered already has between run_graph() and
  724 + _record_loop_back_event). No schema migration needed."""
  725 + row = conn.execute(
  726 + "SELECT wgr.task_id, t.project_id FROM work_graph_run wgr "
  727 + "JOIN task t ON wgr.task_id = t.task_id WHERE wgr.run_id = ?", (run_id,)
  728 + ).fetchone()
  729 + task_id, project_id = row
  730 + conn.execute(
  731 + "INSERT INTO execution_event (event_id, project_id, task_id, run_id, event_type, payload) "
  732 + "VALUES (?, ?, ?, ?, 'skill_iterated', ?)",
  733 + (str(uuid.uuid4()), project_id, task_id, run_id, json.dumps({
  734 + "node_id": node_id, "skill_id": skill_id, "iteration_id": iteration_id,
  735 + })),
  736 + )
  737 + conn.commit()
  738 +
  739 +
  740 +def _skill_iterated_events(conn, run_id):
  741 + rows = conn.execute(
  742 + "SELECT payload FROM execution_event WHERE run_id = ? AND event_type = 'skill_iterated' "
  743 + "ORDER BY created_at", (run_id,)
  744 + ).fetchall()
  745 + return [json.loads(p) for (p,) in rows]
  746 +
  747 +
  748 +_MAX_SKILL_FIXES_PER_RUN_PER_SKILL = 2 # §5.7 point 5: cap real Git rewrites of the same Skill within one run
  749 +
  750 +
  751 +def _fix_skill_for_node(conn, run_id, target_node_id, graph_manifest, build_prompt, outputs, feedback, target_folder_path, timeout=300):
  752 + """Fix-Skill (org-graph-unification-and-universal-self-planning-
  753 + requirements.md §3.1 step 3): a real, Git-versioned Skill rewrite, not a
  754 + per-run prompt patch -- calls the existing but (until now) never
  755 + actually invoked by production code skill_update.py::update_skill()
  756 + (see design doc §5's 'existing fact B': org_level_loop.py's
  757 + _ROUTE_FOR_CATEGORY has no 'skill_defect' key, so this capability has
  758 + sat unused since it was built). Always runs under the Validator
  759 + identity (user-confirmed 2026-08-12 via AskUserQuestion): letting a
  760 + node's own org_node rewrite its own Skill risks reproducing the exact
  761 + blind spot that caused the failure.
  762 +
  763 + §5.7 point 5 rate limit: if this (run_id, skill_id) pair has already
  764 + been rewritten _MAX_SKILL_FIXES_PER_RUN_PER_SKILL times this run, skip
  765 + the rewrite silently (the caller's dynamic_loop_back still proceeds as
  766 + a plain re-run of target_node_id -- not touching the Skill again is a
  767 + safe degradation, not a failure)."""
  768 + target_node = next(n for n in graph_manifest["nodes"] if n["node_id"] == target_node_id)
  769 + skill_id = target_node.get("skill_ref")
  770 + if not skill_id:
  771 + return None # nothing declared for this node to attribute the fix to -- see caller's docstring
  772 +
  773 + prior_fixes = sum(1 for e in _skill_iterated_events(conn, run_id) if e["skill_id"] == skill_id)
  774 + if prior_fixes >= _MAX_SKILL_FIXES_PER_RUN_PER_SKILL:
  775 + return None
  776 +
  777 + row = conn.execute("SELECT task_id FROM work_graph_run WHERE run_id = ?", (run_id,)).fetchone()
  778 + task_id = row[0]
  779 + goal_text = conn.execute("SELECT goal_text FROM task WHERE task_id = ?", (task_id,)).fetchone()[0]
  780 + current_content = _read_skill_content(skill_id)
  781 +
  782 + prompt = _SKILL_REWRITE_PROMPT.format(
  783 + goal_text=goal_text, skill_id=skill_id, current_content=current_content,
  784 + what_was_asked=build_prompt(target_node, outputs),
  785 + what_it_produced=(outputs.get(target_node_id) or {}).get("content", ""),
  786 + failure_feedback="; ".join(feedback or []),
  787 + loop_history=_loop_history_prompt_suffix(conn, run_id, target_node_id) or "(none)",
  788 + )
  789 +
  790 + is_self = paths.is_self_target(target_folder_path)
  791 + node_exec_id = "fixskill-" + uuid.uuid4().hex[:12]
  792 + worktree_path = work_node_run.create_worktree(target_folder_path, node_exec_id) if is_self else None
  793 + cwd = worktree_path or target_folder_path
  794 + try:
  795 + script_name = "graphlemon-fixskill-" + uuid.uuid4().hex
  796 + script_content = (
  797 + "export const meta = {{ name: {name}, description: 'graphlemon Fix-Skill' }}\n\n"
  798 + "const result = await agent({prompt}, {{ agentType: 'validator', schema: {schema} }})\n\n"
  799 + "return result\n"
  800 + ).format(name=json.dumps(script_name), prompt=json.dumps(prompt), schema=json.dumps(_SKILL_REWRITE_SCHEMA))
  801 + work_graph_execute.write_workflow_script(cwd, script_name, script_content)
  802 + rewrite_result = work_graph_execute.execute_work_graph(cwd, script_name, {}, _SKILL_REWRITE_SCHEMA, timeout=timeout)
  803 + if worktree_path:
  804 + work_node_run.ensure_worktree_committed(worktree_path, node_exec_id)
  805 + work_node_run.ensure_worktree_committed(target_folder_path, node_exec_id + "-main-checkout-drift")
  806 + work_node_run.merge_worktree(target_folder_path, node_exec_id)
  807 + finally:
  808 + if worktree_path:
  809 + work_node_run.remove_worktree(target_folder_path, worktree_path)
  810 +
  811 + # skill_query._REPO_ROOT (graphlemon's own checkout), NOT target_folder_path
  812 + # -- the Skill library is graphlemon's own platform asset (05 §5.4.1.4
  813 + # precedent), independent of which target project this task is working on.
  814 + bug_result = bug_record.create_bug_record(
  815 + conn, task_id=task_id,
  816 + description="Skill {} produced a repeated defective output at node {}".format(skill_id, target_node_id),
  817 + root_cause_category="skill_defect", root_cause_detail=rewrite_result["change_summary"],
  818 + suggested_route="full_replan",
  819 + )
  820 + iteration = skill_update.update_skill(
  821 + conn, skill_id, bug_result["bug_id"], rewrite_result["new_content"],
  822 + rewrite_result["change_summary"], repo_path=skill_query._REPO_ROOT,
  823 + )
  824 + _log_skill_iterated_event(conn, run_id, target_node_id, skill_id, iteration["iteration_id"])
  825 + return iteration
  826 +
  827 +
509 828 _DEFAULT_LOOP_LIMIT = 3 # only reached if even the "default" config_id row is unseeded
510 829  
511 830  
... ... @@ -528,6 +847,23 @@ def _resolve_loop_limit(conn, org_node, graph_manifest, source_node_id):
528 847 return _DEFAULT_LOOP_LIMIT
529 848  
530 849  
  850 +def _resolve_total_loop_limit(conn, org_node, source_node_id):
  851 + """Phase 2 §5.7 point 3: total dynamic_loop_back attempts for
  852 + source_node_id, regardless of which target each attempt picked --
  853 + guards against dynamic_loop_back dodging _resolve_loop_limit's
  854 + per-(target,source) cap by picking a different target each round.
  855 + Lookup: a settings_config.py row keyed "{org_node}:{source_node_id}:total"
  856 + (config_id has no format constraint, this key shape needs no schema
  857 + change), then "default" (shared with _resolve_loop_limit's own default
  858 + row -- deliberately the SAME fallback chain/value, so a source_node_id
  859 + that never uses dynamic_loop_back sees an unrestrictive default here)."""
  860 + for config_id in ("{}:{}:total".format(org_node, source_node_id), "default"):
  861 + config = settings_config.get_loop_limit_config(conn, config_id)
  862 + if config:
  863 + return config["max_loop_attempts"]
  864 + return _DEFAULT_LOOP_LIMIT
  865 +
  866 +
531 867 def _record_loop_back_event(conn, run_id, sig, attempt_number):
532 868 """Persists one 'loop_back_triggered' execution_event row -- this IS
533 869 the loop history (2026-08-08 design §7): no separate table, so a
... ... @@ -567,6 +903,13 @@ def _loop_attempt_count_from_events(conn, run_id, target_node_id, source_node_id
567 903 )
568 904  
569 905  
  906 +def _loop_attempt_count_from_events_total(conn, run_id, source_node_id):
  907 + """Real count of every prior loop_back triggered by source_node_id,
  908 + regardless of target -- see _resolve_total_loop_limit's own docstring
  909 + for why this differs from _loop_attempt_count_from_events."""
  910 + return sum(1 for e in _loop_back_events(conn, run_id) if e["source_node_id"] == source_node_id)
  911 +
  912 +
570 913 def _loop_history_prompt_suffix(conn, run_id, node_id):
571 914 """When node_id is about to be (re-)executed, append the full history
572 915 of every prior time it was sent back for revision (not just the most
... ... @@ -814,12 +1157,24 @@ def _run_layers(
814 1157 conn, run_id, sig.target_node_id, sig.source_node_id
815 1158 ) + 1
816 1159 limit = _resolve_loop_limit(conn, org_node, graph_manifest, sig.source_node_id)
817   - if attempt_number > limit:
  1160 + # Phase 2 §5.7 point 3: attempt_number/limit above count only
  1161 + # THIS (target, source) pair -- dynamic_loop_back can pick a
  1162 + # DIFFERENT target each round, which would dodge that per-pair
  1163 + # limit indefinitely. total_attempts/total_limit count every
  1164 + # loop_back triggered by this source_node_id regardless of
  1165 + # target. For retry_self/static loop_back (always the same
  1166 + # target every time), total_attempts == attempt_number by
  1167 + # construction, so this is a no-op there -- only dynamic_loop_back
  1168 + # can actually make them diverge.
  1169 + total_attempts = _loop_attempt_count_from_events_total(conn, run_id, sig.source_node_id) + 1
  1170 + total_limit = _resolve_total_loop_limit(conn, org_node, sig.source_node_id)
  1171 + if attempt_number > limit or total_attempts > total_limit:
818 1172 conn.execute("UPDATE work_graph_run SET status = 'blocked' WHERE run_id = ?", (run_id,))
819 1173 conn.commit()
820 1174 raise ConsistencyCheckFailedError(
821   - "loop_back from {} to {} exceeded max_attempts={}".format(
822   - sig.source_node_id, sig.target_node_id, limit
  1175 + "loop_back from {} to {} exceeded max_attempts={} (per-target={}/{}, total={}/{})".format(
  1176 + sig.source_node_id, sig.target_node_id, max(limit, total_limit),
  1177 + attempt_number, limit, total_attempts, total_limit,
823 1178 ),
824 1179 node_id=sig.source_node_id,
825 1180 )
... ... @@ -849,17 +1204,34 @@ def _run_one_node(
849 1204 node_id -- see that function's own docstring for why (a loop_back
850 1205 target must never collide with, and short-circuit against, its own
851 1206 stale pre-loop_back 'done' row; every other node keeps resuming
852   - exactly like before)."""
  1207 + exactly like before).
  1208 +
  1209 + 2026-08-12 (Phase 2 §4, node-level subagent/skill binding): a node can
  1210 + declare its own `subagent_type`/`skill_ref`, overriding the run-level
  1211 + `org_node`/`skill_id` defaults this whole function used to use
  1212 + unconditionally for every node. A node with neither field (every
  1213 + existing graph_manifest -- Planner's fixed chain, Coder's synthetic
  1214 + single-node manifest) behaves identically to before: `node_org_node`/
  1215 + `node_skill_id` just equal the run-level defaults."""
853 1216 node_id = node["node_id"]
  1217 + node_org_node = node.get("subagent_type", org_node)
  1218 + node_skill_id = node.get("skill_ref", skill_id)
  1219 + # run_graph() only ensures the RUN-LEVEL default skill_id exists as a
  1220 + # placeholder row (work_node_execution.skill_ref's FK target) -- a
  1221 + # node declaring its own skill_ref needs the same guarantee, or the
  1222 + # INSERT below fails FOREIGN KEY constraint the first time that
  1223 + # specific skill_id is ever seen. Idempotent, cheap to call per node.
  1224 + if node_skill_id != skill_id:
  1225 + ensure_placeholder_skill(conn, node_skill_id, node_org_node)
854 1226 exec_node_id_str = _next_exec_node_id(conn, run_id, node_id, graph_manifest)
855 1227  
856 1228 node_exec_id = _ensure_node_row(
857   - conn, run_id, exec_node_id_str, org_node, skill_id,
  1229 + conn, run_id, exec_node_id_str, node_org_node, node_skill_id,
858 1230 "work_graph_node_input.schema.json", "work_graph_node_output.schema.json",
859 1231 {"node_id": node_id},
860 1232 )
861 1233 prompt = build_prompt(node, outputs) + _loop_history_prompt_suffix(conn, run_id, node_id)
862   - run_fn = _make_run_fn(conn, target_folder_path, node_exec_id, org_node, prompt, output_schema, timeout)
  1234 + run_fn = _make_run_fn(conn, target_folder_path, node_exec_id, node_org_node, prompt, output_schema, timeout)
863 1235 output = work_node_run.run_work_node(
864 1236 conn, node_exec_id, {"node_id": node_id}, input_schema, output_schema, run_fn,
865 1237 needs_worktree=needs_worktree, repo_path=target_folder_path, sleep=sleep, now=now,
... ... @@ -868,7 +1240,7 @@ def _run_one_node(
868 1240 judgment = node.get("judgment")
869 1241 has_own_judgment = bool(judgment) and judgment.get("kind", "none") != "none"
870 1242 resolved_verify_fn = _resolve_node_verify_fn(
871   - node, verify_fn, org_node, target_folder_path, mechanical_check_registry
  1243 + node, verify_fn, node_org_node, target_folder_path, mechanical_check_registry
872 1244 )
873 1245 if resolved_verify_fn is None:
874 1246 return output
... ... @@ -881,7 +1253,7 @@ def _run_one_node(
881 1253 # every node, once redundantly), regardless of any on_fail field
882 1254 # (legacy nodes don't have one).
883 1255 return _verify_and_maybe_regenerate(
884   - conn, run_id, org_node, skill_id, target_folder_path, node, node_id, prompt,
  1256 + conn, run_id, node_org_node, node_skill_id, target_folder_path, node, node_id, prompt,
885 1257 output, input_schema, output_schema, timeout, needs_worktree, resolved_verify_fn, sleep, now,
886 1258 )
887 1259  
... ... @@ -899,6 +1271,26 @@ def _run_one_node(
899 1271 ),
900 1272 node_id=node_id,
901 1273 )
  1274 + if action == "dynamic_loop_back":
  1275 + # Phase 2 §5: Reflection-review -> [Fix-Skill] -> Re-loop. See
  1276 + # _judge_root_cause_node/_fix_skill_for_node docstrings for the
  1277 + # full four-step methodology (org-graph-unification-and-universal-
  1278 + # self-planning-requirements.md §3.1).
  1279 + ancestors = sorted(_transitive_ancestors(graph_manifest, node_id) - {node_id})
  1280 + diagnosis = _judge_root_cause_node(
  1281 + conn, run_id, node_id, feedback, ancestors, graph_manifest, build_prompt, outputs,
  1282 + target_folder_path,
  1283 + )
  1284 + target = diagnosis["target_node_id"]
  1285 + if target not in ancestors:
  1286 + raise ValueError(
  1287 + "dynamic_loop_back judge picked {!r}, not a real ancestor of {}".format(target, node_id)
  1288 + )
  1289 + if diagnosis["defect_kind"] == "skill_defect":
  1290 + _fix_skill_for_node(
  1291 + conn, run_id, target, graph_manifest, build_prompt, outputs, feedback, target_folder_path,
  1292 + )
  1293 + raise _LoopBackRequested(source_node_id=node_id, target_node_id=target, feedback=feedback)
902 1294 target = node_id if action == "retry_self" else _on_failure_target(graph_manifest, node_id)
903 1295 raise _LoopBackRequested(source_node_id=node_id, target_node_id=target, feedback=feedback)
904 1296  
... ...