huskies: merge 1009

This commit is contained in:
dave
2026-05-13 22:50:13 +00:00
parent a5cd3a2152
commit 4e007bb770
56 changed files with 453 additions and 384 deletions
+47 -25
View File
@@ -22,8 +22,10 @@ pub struct CrdtItemDump {
pub agent: Option<String>,
pub retry_count: Option<i64>,
pub depends_on: Option<Vec<u32>>,
pub claimed_by: Option<String>,
pub claimed_at: Option<f64>,
/// Agent name holding the claim, or `None` when unclaimed.
pub claim_agent: Option<String>,
/// Unix timestamp (seconds) when the claim was written.
pub claim_ts: Option<f64>,
/// Hex-encoded OpId of the list insert op — cross-reference with `crdt_ops`.
pub content_index: String,
pub is_deleted: bool,
@@ -139,11 +141,11 @@ pub fn dump_crdt_state(story_id_filter: Option<&str>) -> CrdtStateDump {
_ => None,
};
let claimed_by = match item_crdt.claimed_by.view() {
let claim_agent = match item_crdt.claim_agent.view() {
JsonValue::String(s) if !s.is_empty() => Some(s),
_ => None,
};
let claimed_at = match item_crdt.claimed_at.view() {
let claim_ts = match item_crdt.claim_ts.view() {
JsonValue::Number(n) if n > 0.0 => Some(n),
_ => None,
};
@@ -157,8 +159,8 @@ pub fn dump_crdt_state(story_id_filter: Option<&str>) -> CrdtStateDump {
agent,
retry_count,
depends_on,
claimed_by,
claimed_at,
claim_agent,
claim_ts,
content_index,
is_deleted: op.is_deleted,
});
@@ -326,7 +328,7 @@ pub fn evict_item(story_id: &str) -> Result<(), String> {
/// string, or with no name set, are filtered out (`None`) — a nameless item
/// is treated as malformed and never surfaces to callers.
pub(super) fn extract_item_view(item: &PipelineItemCrdt) -> Option<PipelineItemView> {
use super::types::{Claim, EpicId};
use super::types::EpicId;
use crate::io::story_metadata::{ItemType, QaMode};
let story_id = match item.story_id.view() {
@@ -357,18 +359,17 @@ pub(super) fn extract_item_view(item: &PipelineItemCrdt) -> Option<PipelineItemV
_ => Vec::new(),
};
let claimed_by = match item.claimed_by.view() {
// `claim_agent`/`claim_ts` are read only to embed in Stage::Coding /
// Stage::Merge via `project_stage_for_view`; they are not stored on
// `WorkItem` directly (story 1009: readers project from the Stage variant).
let claim_agent = match item.claim_agent.view() {
JsonValue::String(s) if !s.is_empty() => Some(s),
_ => None,
};
let claimed_at_secs = match item.claimed_at.view() {
let claim_ts_secs = match item.claim_ts.view() {
JsonValue::Number(n) if n > 0.0 => Some(n as u64),
_ => None,
};
let claim = match (claimed_by, claimed_at_secs) {
(Some(node), Some(at)) => Some(Claim { node, at }),
_ => None,
};
// `merged_at` is read only to project into `Stage::Done`; it is not
// stored on `WorkItem` (callers access it via `Stage::Done { merged_at }`).
@@ -397,8 +398,14 @@ pub(super) fn extract_item_view(item: &PipelineItemCrdt) -> Option<PipelineItemV
_ => None,
};
let stage =
project_stage_for_view(&stage_str, &story_id, merged_at_float, resume_to.as_deref())?;
let stage = project_stage_for_view(
&stage_str,
&story_id,
merged_at_float,
resume_to.as_deref(),
claim_agent.as_deref(),
claim_ts_secs,
)?;
Some(PipelineItemView {
story_id,
@@ -407,7 +414,6 @@ pub(super) fn extract_item_view(item: &PipelineItemCrdt) -> Option<PipelineItemV
agent,
retry_count,
depends_on,
claim,
qa_mode,
item_type,
epic,
@@ -432,9 +438,11 @@ fn project_stage_for_view(
story_id: &str,
merged_at: Option<f64>,
resume_to: Option<&str>,
claim_agent: Option<&str>,
claim_ts_secs: Option<u64>,
) -> Option<crate::pipeline_state::Stage> {
use crate::pipeline_state::{ArchiveReason, BranchName, GitSha, Stage};
use chrono::{DateTime, Utc};
use crate::pipeline_state::{AgentClaim, AgentName, ArchiveReason, BranchName, GitSha, Stage};
use chrono::{DateTime, TimeZone, Utc};
use std::num::NonZeroU32;
// Normalise legacy directory-style strings to their clean wire form so
@@ -458,13 +466,30 @@ fn project_stage_for_view(
// Story 945: resume target for `Frozen` / `ReviewHold` variants is stored
// in the sibling `resume_to` register. Fall back to `Coding` when the
// register is empty or holds an unrecognised value.
let resume_target =
|| -> Box<Stage> { Box::new(resume_to.and_then(Stage::from_dir).unwrap_or(Stage::Coding)) };
let resume_target = || -> Box<Stage> {
Box::new(
resume_to
.and_then(Stage::from_dir)
.unwrap_or(Stage::Coding { claim: None }),
)
};
// Story 1009: reconstruct AgentClaim from `claim_agent`/`claim_ts` registers.
let claim = match (claim_agent, claim_ts_secs) {
(Some(agent_str), Some(ts)) => Some(AgentClaim {
agent: AgentName(agent_str.to_string()),
claimed_at: Utc
.timestamp_opt(ts as i64, 0)
.single()
.unwrap_or(DateTime::<Utc>::UNIX_EPOCH),
}),
_ => None,
};
match clean {
"upcoming" => Some(Stage::Upcoming),
"backlog" => Some(Stage::Backlog),
"coding" => Some(Stage::Coding),
"coding" => Some(Stage::Coding { claim }),
"qa" => Some(Stage::Qa),
"blocked" => Some(Stage::Blocked {
reason: String::new(),
@@ -472,6 +497,7 @@ fn project_stage_for_view(
"merge" => Some(Stage::Merge {
feature_branch: BranchName(format!("feature/story-{story_id}")),
commits_ahead: NonZeroU32::new(1).expect("1 is non-zero"),
claim,
}),
"merge_failure" => {
// Story 986: read the typed kind directly from ContentKey::MergeFailureKind
@@ -709,8 +735,6 @@ mod tests {
None,
None,
None,
None,
None,
);
// The story is live on this node.
@@ -779,8 +803,6 @@ mod tests {
None,
None,
None,
None,
None,
);
assert!(
read_item(story_id).is_none(),