wip(929): stage 4 — migrate agents/pool/* + lifecycle.rs read sides off yaml_legacy

Read-side migrations:
- agents/pool/auto_assign/backlog.rs: depends_on check now reads from
  WorkItem.depends_on() instead of parse_front_matter.
- agents/pool/auto_assign/story_checks.rs: read_story_front_matter_agent
  drops its YAML fallback — post-891 the CRDT entry is reliable, and
  removing the fallback makes the contract honest. The now-unused
  read_story_contents helper goes too.
- agents/pool/start/validation.rs: same shape — YAML fallback removed,
  CRDT register is the only source for agent pinning.
- agents/pool/start/spawn.rs: epic-context injection wraps the
  parse_front_matter call in `yaml_residue(...)` since `meta.epic` has no
  CRDT analog (sub-story 933).
- agents/lifecycle.rs: item_type_from_id (numeric-only ID path) wraps its
  parse_front_matter in `yaml_residue(...)` for the same reason (933).
  The write-side `fields_to_clear_transform` calls in lifecycle.rs are
  left for stage 8, when FS-shadow writes are deleted wholesale.

Test fix:
- start_agent_returns_error_when_front_matter_agent_busy now seeds the
  CRDT entry (write_item with agent="coder-opus") instead of relying on
  parse_front_matter reading the YAML on disk.

Filed earlier:
- 932 (review_hold register) — note: this turns out to be a real class-1
  bug: write_review_hold_to_store still writes YAML but has_review_hold
  reads Stage::Frozen, so the write goes into a void. 932 is the correct
  fix.

All 2861 tests pass; fmt + clippy clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Timmy
2026-05-12 19:03:51 +01:00
parent 03a99b3cf1
commit f775f4cfb9
6 changed files with 42 additions and 39 deletions
+5 -1
View File
@@ -32,9 +32,13 @@ pub(crate) fn item_type_from_id(item_id: &str) -> &'static str {
return "refactor"; return "refactor";
} }
// Numeric-only ID: check content store front matter for explicit type. // Numeric-only ID: check content store front matter for explicit type.
// `item_type` has no CRDT register yet (story 933 — epic mechanism).
// Marked with yaml_residue so the gap is grep-findable.
if after_num.is_empty() if after_num.is_empty()
&& let Some(content) = crate::db::read_content(item_id) && let Some(content) = crate::db::read_content(item_id)
&& let Ok(meta) = crate::db::yaml_legacy::parse_front_matter(&content) && let Ok(meta) = crate::db::yaml_legacy::yaml_residue(
crate::db::yaml_legacy::parse_front_matter(&content),
)
&& let Some(t) = meta.item_type.as_deref() && let Some(t) = meta.item_type.as_deref()
{ {
return match t { return match t {
@@ -24,16 +24,12 @@ impl AgentPool {
/// logged so the user can see the promotion was triggered by an archived dep, not /// logged so the user can see the promotion was triggered by an archived dep, not
/// a clean completion. /// a clean completion.
pub(super) fn promote_ready_backlog_stories(&self, project_root: &Path) { pub(super) fn promote_ready_backlog_stories(&self, project_root: &Path) {
use crate::db::yaml_legacy::parse_front_matter;
let items = scan_stage_items(project_root, "1_backlog"); let items = scan_stage_items(project_root, "1_backlog");
for story_id in &items { for story_id in &items {
// Only promote stories that explicitly declare dependencies. // Only promote stories that explicitly declare dependencies
let contents = crate::db::read_content(story_id); // (story 929: read from the CRDT register, not YAML).
let has_deps = contents let has_deps = crate::crdt_state::read_item(story_id)
.and_then(|c| parse_front_matter(&c).ok()) .map(|w| !w.depends_on().is_empty())
.and_then(|m| m.depends_on)
.map(|d| !d.is_empty())
.unwrap_or(false); .unwrap_or(false);
if !has_deps { if !has_deps {
continue; continue;
@@ -2,11 +2,6 @@
use std::path::Path; use std::path::Path;
/// Read story contents from the DB content store (CRDT-backed).
fn read_story_contents(_project_root: &Path, story_id: &str) -> Option<String> {
crate::db::read_content(story_id)
}
/// Read the optional `agent:` pin for a story. /// Read the optional `agent:` pin for a story.
/// ///
/// After story 871 the agent assignment lives in the CRDT typed register /// After story 871 the agent assignment lives in the CRDT typed register
@@ -14,19 +9,16 @@ fn read_story_contents(_project_root: &Path, story_id: &str) -> Option<String> {
/// first; falling back to legacy YAML parsing keeps behaviour intact for any /// first; falling back to legacy YAML parsing keeps behaviour intact for any
/// stories whose CRDT entry doesn't yet have the field set. /// stories whose CRDT entry doesn't yet have the field set.
pub(super) fn read_story_front_matter_agent( pub(super) fn read_story_front_matter_agent(
project_root: &Path, _project_root: &Path,
_stage_dir: &str, _stage_dir: &str,
story_id: &str, story_id: &str,
) -> Option<String> { ) -> Option<String> {
if let Some(view) = crate::crdt_state::read_item(story_id) // Story 929: agent name comes from the CRDT register. The previous
&& let Some(agent) = view.agent() // YAML fallback is gone — post-891 every story has its CRDT entry,
&& !agent.is_empty() // and any story without one is treated as having no pinned agent.
{ crate::crdt_state::read_item(story_id)
return Some(agent.to_string()); .and_then(|w| w.agent().map(str::to_string))
} .filter(|s| !s.is_empty())
use crate::db::yaml_legacy::parse_front_matter;
let contents = read_story_contents(project_root, story_id)?;
parse_front_matter(&contents).ok()?.agent
} }
/// Return `true` if the story is in the `Frozen` pipeline stage. /// Return `true` if the story is in the `Frozen` pipeline stage.
+6 -1
View File
@@ -230,8 +230,13 @@ pub(super) async fn run_agent_spawn(
// Read the story's front matter to find the epic ID, then load the epic's // Read the story's front matter to find the epic ID, then load the epic's
// content and prepend it to the system prompt so the agent treats it as // content and prepend it to the system prompt so the agent treats it as
// authoritative context. // authoritative context.
//
// Epic linkage has no CRDT register yet (story 933) — wrap the parse in
// `yaml_residue` so the gap is grep-findable.
if let Some(story_content) = crate::db::read_content(&sid) if let Some(story_content) = crate::db::read_content(&sid)
&& let Ok(meta) = crate::db::yaml_legacy::parse_front_matter(&story_content) && let Ok(meta) = crate::db::yaml_legacy::yaml_residue(
crate::db::yaml_legacy::parse_front_matter(&story_content),
)
&& let Some(ref epic_id) = meta.epic && let Some(ref epic_id) = meta.epic
&& let Some(epic_content) = crate::db::read_content(epic_id) && let Some(epic_content) = crate::db::read_content(epic_id)
{ {
@@ -281,6 +281,20 @@ stage = "coder"
std::fs::write(current.join("368_story_test.md"), story_content).unwrap(); std::fs::write(current.join("368_story_test.md"), story_content).unwrap();
crate::db::ensure_content_store(); crate::db::ensure_content_store();
crate::db::write_content("368_story_test", story_content); crate::db::write_content("368_story_test", story_content);
// Story 929: agent pin comes from the CRDT register, not YAML. Seed it.
crate::crdt_state::init_for_test();
crate::crdt_state::write_item(
"368_story_test",
"2_current",
Some("Test Story"),
Some("coder-opus"),
None,
None,
None,
None,
None,
None,
);
let pool = AgentPool::new_test(3011); let pool = AgentPool::new_test(3011);
// Preferred agent is busy — should NOT fall back to coder-sonnet. // Preferred agent is busy — should NOT fall back to coder-sonnet.
+6 -14
View File
@@ -60,18 +60,10 @@ pub(super) fn read_front_matter_agent(story_id: &str, agent_name: Option<&str>)
if agent_name.is_some() { if agent_name.is_some() {
return None; return None;
} }
// After story 871 the pin lives in the CRDT typed register; fall back // Story 929: the agent pin lives in the CRDT typed register; the
// to legacy YAML parsing for stories whose CRDT entry doesn't yet have // legacy YAML fallback is gone — post-891 every story has its CRDT
// the field populated. // entry and any story without one has no pinned agent.
if let Some(view) = crate::crdt_state::read_item(story_id) crate::crdt_state::read_item(story_id)
&& let Some(agent) = view.agent() .and_then(|w| w.agent().map(str::to_string))
&& !agent.is_empty() .filter(|s| !s.is_empty())
{
return Some(agent.to_string());
}
crate::db::read_content(story_id).and_then(|contents| {
crate::db::yaml_legacy::parse_front_matter(&contents)
.ok()?
.agent
})
} }