From b6b495c8e51712ad0381408fd722aadb064ff7ef Mon Sep 17 00:00:00 2001 From: bboonnee Date: Sun, 2 Aug 2026 10:59:35 +0800 Subject: [PATCH] U-034: deployment/deploy_health_signal tables + core_engine enum (PH5 start) --- docs/development-plan.md | 1 + docs/sdd/unit_matrix.json | 2 +- src/schema.py | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------- src/test_bug_record.py | 12 +++++++++--- src/test_schema.py | 135 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------- 5 files changed, 194 insertions(+), 22 deletions(-) diff --git a/docs/development-plan.md b/docs/development-plan.md index 4e42857..d905b36 100644 --- a/docs/development-plan.md +++ b/docs/development-plan.md @@ -336,6 +336,7 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。 - **U-034 [Enabler]** 01-M 扩展:新增 `deployment`、`deploy_health_signal` 两个实体建表;`bug_record.root_cause_category` 新增 `core_engine` 枚举值 - 依赖:U-001 - Done:两张新表建表成功并通过约束校验;`bug_record` 现有记录/查询逻辑对新枚举值向后兼容(不破坏 U-015/U-018 已有测试) + - **实现补充(2026-08-02)**:按 01-M §1.15/1.16(比 `supervisor-auto-rollback-design.md` §3 的概要表更完整,以 01-M 为准)在 `src/schema.py` 追加两张表;`deployment.status` 用 01-M 原文的完整 15 值状态机枚举(不是 §3 表格里省略过的简化版)。`ux_deployment_active` 用表达式索引 `CREATE UNIQUE INDEX ... ON deployment((1)) WHERE status NOT IN (...)` 实现"同一时刻只允许一条非终态 deployment"的全局单例约束——和 `work_graph_run` 现有的按 `(task_id, org_node)` 分组的 partial unique index 是同一个思路,只是这里没有天然的分组键(graphlemon 自己就一份,不是"每个 X 一条"),用常量表达式当分组键达到"全局只有一条"的效果,本地 sqlite3 验证过真实生效。`bug_record.root_cause_category` CHECK 约束加了 `core_engine`,但 `bug_record.py` 自己的应用层 `_VALID_ROOT_CAUSE_CATEGORIES` 白名单**刻意没有跟着放开**——"什么时候一个 bug 该归为 core_engine"是 05 §5.8 定义的按文件路径判断的规则(`.claude/agents/*.md`/Graph Engine 核心逻辑/01-M 自身 schema 变更),不是 Validator 现有三视角 `judge_root_cause` 能判断的东西(那三个视角硬编码只产出 planning/coder/skill_defect),这条分类逻辑连同 `c_org_loop_route` 的路由扩展一起留给 U-035,不是这个 Unit 该做的。`previous_commit_hash`/`previous_instance_dir` 在 01-M 里标了 not null,但第一次真正跑 Deploy Supervisor 时没有"上一条 finalized deployment"可取——这个引导期边界情况留给 U-037 的 `c_deploy_supervisor_stage` 处理(第一次调用时把当前正在跑的 commit/checkout 目录当成"previous"传进去),不是 schema 层该解决的,已在 `schema.py` docstring 里记录。 - 覆盖:`supervisor-auto-rollback-design.md` §3;`self-development-feasibility.md` 缺口 3 - **U-035 [Risk Gate]** self-target 场景 worktree 无条件强制规则(缺口 1)+ `root_cause_category=core_engine` 命中时路由到蓝绿部署流程而非 `fast_lane`(`c_org_loop_route` 扩展) diff --git a/docs/sdd/unit_matrix.json b/docs/sdd/unit_matrix.json index 743644a..4c4c8e6 100644 --- a/docs/sdd/unit_matrix.json +++ b/docs/sdd/unit_matrix.json @@ -51,7 +51,7 @@ {"id": "U-032", "phase": "PH4", "sub_phase": "PH4-S2", "name": "多 Goal 并发 + 长时间运行稳定性压测(见 development-plan.md 2026-08-02 实现补充)", "classification": "Risk Gate", "depends_on": ["U-031"], "coverage": {"model": ["org_concurrency_config"], "view": [], "controller": [], "process": [], "requirement": ["REQ-09"]}, "done": "并发数调高后多个独立 Goal 可同时稳定运行;发现并补上一个此前从未存在的结构性缺口——04-P/01-M/REQ-09 boundary 反复假设存在的自动调度器实际从未被建过,新增 src/scheduler.py(ThreadPoolExecutor + 每线程独立 sqlite 连接,避免复刻 U-027 的共享连接锁陷阱)真正实现,并顺带补上 e2e_pipeline.run_end_to_end() 自己文档过的缺口(PASS/FAIL 后从不调用 close_task_done/run_fix_loop,卡在 validating 不动)"}, {"id": "U-033", "phase": "PH4", "sub_phase": "PH4-S3", "name": "A-2 假设收口(见 development-plan.md 2026-08-02 实现补充)", "classification": "Risk Gate", "depends_on": ["U-004"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": [], "assumption": ["A-2"]}, "done": "A-2 状态确认为 confirmed 或记录实际偏差;最终结论:维持 U-004 记录的\"部分成立\",PH1~PH4 积累的真实运行数据未暴露新偏差,偏差范围到 U-004 为止已完全摸清(Coder SOUL.md 三处实质性重写),不改判 confirmed,正式收口"}, - {"id": "U-034", "phase": "PH5", "sub_phase": "PH5-S1", "name": "01-M 扩展:deployment/deploy_health_signal + core_engine 枚举", "classification": "Enabler", "depends_on": ["U-001"], "coverage": {"model": ["deployment", "deploy_health_signal", "bug_record"], "view": [], "controller": [], "process": [], "requirement": []}, "done": "两张新表建表成功;bug_record 新枚举值向后兼容 U-015/U-018 已有测试"}, + {"id": "U-034", "phase": "PH5", "sub_phase": "PH5-S1", "name": "01-M 扩展:deployment/deploy_health_signal + core_engine 枚举(见 development-plan.md 2026-08-02 实现补充)", "classification": "Enabler", "depends_on": ["U-001"], "coverage": {"model": ["deployment", "deploy_health_signal", "bug_record"], "view": [], "controller": [], "process": [], "requirement": []}, "done": "两张新表建表成功(共 17 张);bug_record 新枚举值向后兼容 U-015/U-018 已有测试;deployment 状态机用 01-M 1.15 完整 15 值枚举;ux_deployment_active 表达式索引强制全局单例非终态约束;bug_record.py 应用层白名单刻意未跟着放开 core_engine,classification+路由逻辑留给 U-035"}, {"id": "U-035", "phase": "PH5", "sub_phase": "PH5-S1", "name": "self-target worktree 无条件强制 + core_engine 路由到蓝绿部署", "classification": "Risk Gate", "depends_on": ["U-013", "U-016", "U-034"], "coverage": {"model": [], "view": [], "controller": ["c_org_loop_route"], "process": [], "requirement": ["REQ-03"]}, "done": "self-target 时无条件走 worktree;core_engine 分类的 bug 被路由到 PH5-S2/S3 而非 fast_lane;且改动落地后必须重跑 U-013/REQ-03(worktree 隔离)现有回归用例全部通过(2026-07-30 计划评审追加,确认未破坏 PH4 已建立的隔离保证)"}, {"id": "U-036", "phase": "PH5", "sub_phase": "PH5-S2", "name": "supervisor/isolation 补齐单测 + Deploy Supervisor 进程骨架", "classification": "Enabler", "depends_on": [], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": []}, "done": "四模块各有 test_*.py;Deploy Supervisor 骨架独立进程可启停,不受 Live/Candidate 崩溃影响"}, {"id": "U-037", "phase": "PH5", "sub_phase": "PH5-S2", "name": "c_deploy_supervisor_stage", "classification": "Risk Gate", "depends_on": ["U-034", "U-036"], "coverage": {"model": ["deployment"], "view": [], "controller": ["c_deploy_supervisor_stage"], "process": [], "requirement": []}, "done": "能生成 deployment 记录并拉起监听临时端口、连一次性 DB 副本的候选进程"}, diff --git a/src/schema.py b/src/schema.py index 2d7104b..e8898ef 100644 --- a/src/schema.py +++ b/src/schema.py @@ -1,9 +1,8 @@ """SQLite schema for graphlemon's own structured storage. U-001 scope: the 15 platform entities from docs/sdd/01-M-模型设计.md §1.0-1.14 -(PH0-PH4). `deployment`/`deploy_health_signal` (§1.15/§1.16) are PH5-only and are -deliberately not created here -- see CLAUDE.md "PH5 is not in scope yet" and -development-plan.md U-034. +(PH0-PH4). `deployment`/`deploy_health_signal` (§1.15/§1.16, added here in U-034, +2026-08-02) are PH5-only -- 17 tables total as of PH5. Interpretive conventions used below, where 01-M leaves the physical type implicit: - Booleans are stored as INTEGER CHECK(x IN (0, 1)) -- sqlite3 has no native bool type. @@ -18,12 +17,18 @@ Interpretive conventions used below, where 01-M leaves the physical type implici org-node lookup table to reference (planner/coder/validator only exist as entries inside org_graph_definition.nodes, a JSON column) -- implemented as a CHECK enum instead, matching work_graph_run.org_node / skill.owner_org_node / agent_profile.org_node. -- bug_record.root_cause_category intentionally excludes 'core_engine' for now: that - value is PH5-scoped (05 §5.8) and development-plan.md assigns adding it to U-034, - not U-001 (confirmed with user, 2026-07-30). +- bug_record.root_cause_category gained 'core_engine' in U-034 (2026-08-02, PH5) -- + 05 §5.8: self-target scenarios (`.claude/agents/*.md` identity file changes, Graph + Engine scheduler/executor core logic changes, this schema's own changes), routed by + `c_org_loop_route` to `p_deploy_supervisor_blue_green` instead of fast_lane/full_replan. - bug_record and skill have no created_at in 01-M (skill is Git-versioned, so git log covers "when"; bug_record has no such backing and this looks like a possible spec gap, but adding an undocumented column is out of scope for U-001 -- flagged, not fixed). +- deployment.previous_commit_hash/previous_instance_dir are "not null" per 01-M 1.15, + but the very first deployment ever run has no prior `finalized` deployment to point + at -- that bootstrap case is c_deploy_supervisor_stage's problem (U-037), not this + schema's: it must supply the currently-running commit/checkout dir as the "previous" + values on that first call, not leave the schema-level NOT NULL as aspirational. """ import sqlite3 @@ -44,6 +49,8 @@ TABLE_NAMES = ( "execution_event", "execution_log_file", "log_retention_config", + "deployment", + "deploy_health_signal", ) SCHEMA_SQL = """ @@ -177,7 +184,7 @@ CREATE TABLE bug_record ( reported_by TEXT, description TEXT NOT NULL, repro_steps TEXT, - root_cause_category TEXT CHECK (root_cause_category IN ('planning', 'coder', 'skill_defect')), + root_cause_category TEXT CHECK (root_cause_category IN ('planning', 'coder', 'skill_defect', 'core_engine')), root_cause_detail TEXT, related_artifact_ids TEXT, suggested_route TEXT CHECK (suggested_route IN ('fast_lane', 'full_replan')), @@ -268,6 +275,48 @@ CREATE TABLE log_retention_config ( updated_by TEXT NOT NULL, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); + +-- 1.15 deployment (PH5, U-034, 2026-08-02 -- see docs/supervisor-auto-rollback-design.md) +CREATE TABLE deployment ( + deployment_id TEXT PRIMARY KEY, + git_commit_hash TEXT NOT NULL, + previous_commit_hash TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ( + 'staged', 'candidate_starting', 'health_check_running', 'health_check_passed', + 'health_check_failed', 'cutover_in_progress', 'live', 'monitoring', + 'anomaly_detected', 'rollback_in_progress', 'rolled_back', 'rollback_failed', + 'blocked_pending_human', 'monitoring_window_clean', 'finalized' + )), + candidate_port INTEGER, + candidate_db_path TEXT, + started_at TEXT, + cutover_at TEXT, + monitoring_closed_at TEXT, + rollback_at TEXT, + rollback_reason TEXT, + previous_instance_dir TEXT NOT NULL +); + +-- only one non-terminal deployment at a time (01-M 1.15's own constraint) -- +-- expression index on a constant, same "global singleton while active" idiom +-- as a partial unique index scoped by a real column, just with no column to +-- scope by here (there's exactly one graphlemon self-target, not one per key) +CREATE UNIQUE INDEX ux_deployment_active ON deployment((1)) + WHERE status NOT IN ('finalized', 'rolled_back', 'blocked_pending_human'); + +-- 1.16 deploy_health_signal (PH5, U-034, 2026-08-02) +CREATE TABLE deploy_health_signal ( + signal_id TEXT PRIMARY KEY, + deployment_id TEXT NOT NULL REFERENCES deployment(deployment_id), + sampled_at TEXT NOT NULL, + signal_type TEXT NOT NULL CHECK (signal_type IN ( + 'process_alive', 'http_health', 'canary_task', 'validator_approval_rate' + )), + status TEXT NOT NULL CHECK (status IN ('ok', 'degraded', 'fail')), + detail TEXT +); + +CREATE INDEX idx_deploy_health_signal_deployment_sampled ON deploy_health_signal(deployment_id, sampled_at); """ @@ -289,6 +338,7 @@ def connect(db_path=":memory:", check_same_thread=True): def create_schema(conn): - """Create all 15 U-001 tables (+ indexes) on an open connection.""" + """Create all 17 tables (15 from U-001 + deployment/deploy_health_signal + from U-034, PH5) + indexes on an open connection.""" conn.executescript(SCHEMA_SQL) conn.commit() diff --git a/src/test_bug_record.py b/src/test_bug_record.py index 92a97a6..4b3cd5b 100644 --- a/src/test_bug_record.py +++ b/src/test_bug_record.py @@ -60,9 +60,15 @@ class CreateBugRecordTest(BugRecordTestBase): root_cause_detail="d", suggested_route="fast_lane", ) - def test_core_engine_rejected_pre_ph5(self): - # core_engine is PH5-scoped (U-034) -- not accepted yet, matching the - # schema CHECK constraint's own exclusion (see src/schema.py) + def test_core_engine_rejected_pending_u035(self): + # U-034 (2026-08-02) widened the SCHEMA's own CHECK constraint to + # accept core_engine (see src/schema.py), but bug_record.py's own + # application-layer _VALID_ROOT_CAUSE_CATEGORIES deliberately still + # excludes it -- nothing yet determines WHEN a bug is core_engine + # (05 §5.8's file-path-based rule, not the Validator's 3-perspective + # judge_root_cause, which only ever produces planning/coder/ + # skill_defect); that classification logic + c_org_loop_route's + # routing to the blue-green flow is U-035's job, not this one's. with self.assertRaises(bug_record.InvalidRootCauseCategoryError): bug_record.create_bug_record( self.conn, task_id="task-1", description="x", root_cause_category="core_engine", diff --git a/src/test_schema.py b/src/test_schema.py index 8588171..4d5475c 100644 --- a/src/test_schema.py +++ b/src/test_schema.py @@ -1,5 +1,7 @@ -"""U-001 tests: all 15 tables build, and the constraints named in the Unit's +"""U-001 tests: all tables build, and the constraints named in the Unit's Done criterion (unique / foreign key / enum) are actually enforced. +U-034 (PH5, 2026-08-02) added `deployment`/`deploy_health_signal` (17 tables +total) and widened `bug_record.root_cause_category` to accept `core_engine`. Run: python3 -m unittest src.test_schema -v """ @@ -18,13 +20,13 @@ class SchemaCreationTest(unittest.TestCase): def tearDown(self): self.conn.close() - def test_all_15_tables_created(self): + def test_all_17_tables_created(self): rows = self.conn.execute( "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'" ).fetchall() actual = {row[0] for row in rows} self.assertEqual(actual, set(schema.TABLE_NAMES)) - self.assertEqual(len(schema.TABLE_NAMES), 15) + self.assertEqual(len(schema.TABLE_NAMES), 17) def test_schema_is_idempotent_to_reopen_on_disk(self): # not required by the Done criterion, but a schema that only works once @@ -234,9 +236,10 @@ class ExecutionLogFileUniqueConstraintTest(ConstraintTestBase): class BugRecordRootCauseCategoryTest(ConstraintTestBase): - """core_engine is PH5-scoped (U-034); U-001 deliberately does not accept it yet - (confirmed with user, 2026-07-30) -- this test locks that decision in so a - future edit can't silently widen the enum back out ahead of U-034.""" + """core_engine was PH5-scoped and deliberately excluded pre-U-034 + (confirmed with user, 2026-07-30); U-034 (2026-08-02) widened the enum + to accept it -- 05 §5.8's self-target routing needs it as a real, + storable category, not just a documented intention.""" def _insert_bug(self, root_cause_category): self.conn.execute( @@ -245,8 +248,8 @@ class BugRecordRootCauseCategoryTest(ConstraintTestBase): (root_cause_category,), ) - def test_pre_ph5_categories_accepted(self): - for category in ("planning", "coder", "skill_defect"): + def test_all_four_categories_accepted(self): + for category in ("planning", "coder", "skill_defect", "core_engine"): with self.subTest(category=category): conn = schema.connect(":memory:") schema.create_schema(conn) @@ -258,9 +261,121 @@ class BugRecordRootCauseCategoryTest(ConstraintTestBase): conn.commit() conn.close() - def test_core_engine_rejected_before_u034(self): + def test_unknown_category_still_rejected(self): with self.assertRaises(sqlite3.IntegrityError): - self._insert_bug("core_engine") + self._insert_bug("not_a_real_category") + + +class DeploymentEnumConstraintTest(ConstraintTestBase): + def _insert_deployment(self, deployment_id, status): + self.conn.execute( + "INSERT INTO deployment (deployment_id, git_commit_hash, previous_commit_hash, status, " + "previous_instance_dir) VALUES (?, 'sha-new', 'sha-old', ?, '/tmp/prev-instance')", + (deployment_id, status), + ) + + def test_every_state_machine_status_accepted(self): + statuses = ( + "staged", "candidate_starting", "health_check_running", "health_check_passed", + "health_check_failed", "cutover_in_progress", "live", "monitoring", + "anomaly_detected", "rollback_in_progress", "rolled_back", "rollback_failed", + "blocked_pending_human", "monitoring_window_clean", "finalized", + ) + for status in statuses: + with self.subTest(status=status): + conn = schema.connect(":memory:") + schema.create_schema(conn) + conn.execute( + "INSERT INTO deployment (deployment_id, git_commit_hash, previous_commit_hash, status, " + "previous_instance_dir) VALUES ('d1', 'sha-new', 'sha-old', ?, '/tmp/prev-instance')", + (status,), + ) + conn.commit() + conn.close() + + def test_invalid_status_rejected(self): + with self.assertRaises(sqlite3.IntegrityError): + self._insert_deployment("d1", "not_a_real_status") + + +class DeploymentActiveUniqueIndexTest(ConstraintTestBase): + """01-M 1.15: only one non-terminal deployment (not finalized/rolled_back/ + blocked_pending_human) at a time -- concurrent self-modifying deploys + would corrupt the single Live/previous_instance_dir bookkeeping.""" + + def _insert_deployment(self, deployment_id, status): + self.conn.execute( + "INSERT INTO deployment (deployment_id, git_commit_hash, previous_commit_hash, status, " + "previous_instance_dir) VALUES (?, 'sha-new', 'sha-old', ?, '/tmp/prev-instance')", + (deployment_id, status), + ) + + def test_second_active_deployment_rejected(self): + self._insert_deployment("d1", "staged") + self.conn.commit() + with self.assertRaises(sqlite3.IntegrityError): + self._insert_deployment("d2", "candidate_starting") + + def test_second_deployment_allowed_once_first_is_terminal(self): + for terminal_status in ("finalized", "rolled_back", "blocked_pending_human"): + with self.subTest(terminal_status=terminal_status): + conn = schema.connect(":memory:") + schema.create_schema(conn) + conn.execute( + "INSERT INTO deployment (deployment_id, git_commit_hash, previous_commit_hash, status, " + "previous_instance_dir) VALUES ('d1', 'sha-new', 'sha-old', ?, '/tmp/prev-instance')", + (terminal_status,), + ) + conn.commit() + conn.execute( + "INSERT INTO deployment (deployment_id, git_commit_hash, previous_commit_hash, status, " + "previous_instance_dir) VALUES ('d2', 'sha-newer', 'sha-new', 'staged', '/tmp/prev-instance-2')" + ) + conn.commit() + count = conn.execute("SELECT COUNT(*) FROM deployment").fetchone()[0] + conn.close() + self.assertEqual(count, 2) + + +class DeployHealthSignalTest(ConstraintTestBase): + def setUp(self): + super().setUp() + self.conn.execute( + "INSERT INTO deployment (deployment_id, git_commit_hash, previous_commit_hash, status, " + "previous_instance_dir) VALUES ('d1', 'sha-new', 'sha-old', 'health_check_running', " + "'/tmp/prev-instance')" + ) + self.conn.commit() + + def test_valid_signal_accepted(self): + self.conn.execute( + "INSERT INTO deploy_health_signal (signal_id, deployment_id, sampled_at, signal_type, status, detail) " + "VALUES ('sig-1', 'd1', '2026-08-02T00:00:00Z', 'process_alive', 'ok', 'pid 1234 alive')" + ) + self.conn.commit() + row = self.conn.execute("SELECT status FROM deploy_health_signal WHERE signal_id = 'sig-1'").fetchone() + self.assertEqual(row[0], "ok") + + def test_invalid_signal_type_rejected(self): + with self.assertRaises(sqlite3.IntegrityError): + self.conn.execute( + "INSERT INTO deploy_health_signal (signal_id, deployment_id, sampled_at, signal_type, status) " + "VALUES ('sig-1', 'd1', '2026-08-02T00:00:00Z', 'not_a_real_signal', 'ok')" + ) + + def test_invalid_status_rejected(self): + with self.assertRaises(sqlite3.IntegrityError): + self.conn.execute( + "INSERT INTO deploy_health_signal (signal_id, deployment_id, sampled_at, signal_type, status) " + "VALUES ('sig-1', 'd1', '2026-08-02T00:00:00Z', 'process_alive', 'not_a_real_status')" + ) + + def test_rejects_unknown_deployment_id(self): + with self.assertRaises(sqlite3.IntegrityError): + self.conn.execute( + "INSERT INTO deploy_health_signal (signal_id, deployment_id, sampled_at, signal_type, status) " + "VALUES ('sig-1', 'no-such-deployment', '2026-08-02T00:00:00Z', 'process_alive', 'ok')" + ) if __name__ == "__main__": -- libgit2 0.21.4