diff --git a/server/src/agents/pool/auto_assign/merge.rs b/server/src/agents/pool/auto_assign/merge.rs index 8051882f..47113028 100644 --- a/server/src/agents/pool/auto_assign/merge.rs +++ b/server/src/agents/pool/auto_assign/merge.rs @@ -40,6 +40,7 @@ impl AgentPool { commits_ahead: NonZeroU32::new(1).expect("1 is non-zero"), claim: None, retries: 0, + server_start_time: None, }; let merge_items = scan_stage_items(&merge_stage); for story_id in &merge_items { diff --git a/server/src/agents/pool/auto_assign/merge_failure_block_subscriber.rs b/server/src/agents/pool/auto_assign/merge_failure_block_subscriber.rs index fa13c8b8..623d366f 100644 --- a/server/src/agents/pool/auto_assign/merge_failure_block_subscriber.rs +++ b/server/src/agents/pool/auto_assign/merge_failure_block_subscriber.rs @@ -187,6 +187,7 @@ mod tests { commits_ahead: NonZeroU32::new(1).unwrap(), claim: None, retries: 0, + server_start_time: None, }, after: Stage::MergeFailure { kind: kind.clone(), diff --git a/server/src/agents/pool/auto_assign/merge_failure_subscriber.rs b/server/src/agents/pool/auto_assign/merge_failure_subscriber.rs index 55610e95..ccc2175e 100644 --- a/server/src/agents/pool/auto_assign/merge_failure_subscriber.rs +++ b/server/src/agents/pool/auto_assign/merge_failure_subscriber.rs @@ -162,6 +162,7 @@ mod tests { commits_ahead: NonZeroU32::new(1).unwrap(), claim: None, retries: 0, + server_start_time: None, }, after: crate::pipeline_state::Stage::MergeFailure { kind: kind.clone(), diff --git a/server/src/agents/pool/cost_rollup_subscriber.rs b/server/src/agents/pool/cost_rollup_subscriber.rs index ed19c6ab..7d496518 100644 --- a/server/src/agents/pool/cost_rollup_subscriber.rs +++ b/server/src/agents/pool/cost_rollup_subscriber.rs @@ -136,6 +136,7 @@ mod tests { commits_ahead: NonZeroU32::new(1).unwrap(), claim: None, retries: 0, + server_start_time: None, }, after: Stage::Done { merged_at: Utc::now(), diff --git a/server/src/chat/commands/status/tests.rs b/server/src/chat/commands/status/tests.rs index e7d24061..3c26730d 100644 --- a/server/src/chat/commands/status/tests.rs +++ b/server/src/chat/commands/status/tests.rs @@ -549,6 +549,7 @@ fn merge_stage() -> Stage { commits_ahead: std::num::NonZeroU32::new(1).unwrap(), claim: None, retries: 0, + server_start_time: None, } } diff --git a/server/src/crdt_state/read.rs b/server/src/crdt_state/read.rs index a4197d58..27c508d0 100644 --- a/server/src/crdt_state/read.rs +++ b/server/src/crdt_state/read.rs @@ -403,6 +403,11 @@ pub(super) fn extract_item_view(item: &PipelineItemCrdt) -> Option None, }; + let merge_server_start = match item.merge_server_start.view() { + JsonValue::Number(n) if n > 0.0 => Some(n), + _ => None, + }; + let stage = project_stage_for_view( &stage_str, &story_id, @@ -412,6 +417,7 @@ pub(super) fn extract_item_view(item: &PipelineItemCrdt) -> Option, plan_state_str: Option<&str>, retries: u32, + merge_server_start: Option, ) -> Option { use crate::pipeline_state::{ AgentClaim, AgentName, ArchiveReason, BranchName, GitSha, PlanState, Stage, @@ -518,6 +525,7 @@ fn project_stage_for_view( commits_ahead: NonZeroU32::new(1).expect("1 is non-zero"), claim, retries, + server_start_time: merge_server_start, }), "merge_failure" => { // Story 986: read the typed kind directly from ContentKey::MergeFailureKind diff --git a/server/src/crdt_state/types.rs b/server/src/crdt_state/types.rs index b9c24de9..960fb90b 100644 --- a/server/src/crdt_state/types.rs +++ b/server/src/crdt_state/types.rs @@ -100,6 +100,11 @@ pub struct PipelineItemCrdt { /// Wire values: `"missing"` (default/empty), `"drafted"`, `"confirmed"`. /// Updated by the filesystem watcher on PLAN.md create/modify/remove events. pub plan_state: LwwRegisterCrdt, + /// Story 1036: Unix timestamp (f64 seconds) of the server process that + /// started the currently active merge task for this item. Zero / absent + /// means no merge task is in flight. Projected into `Stage::Merge { + /// server_start_time }` so callers never read this register directly. + pub merge_server_start: LwwRegisterCrdt, } /// CRDT node that holds a single peer's presence entry. diff --git a/server/src/crdt_state/write/item.rs b/server/src/crdt_state/write/item.rs index be3e729e..81471de5 100644 --- a/server/src/crdt_state/write/item.rs +++ b/server/src/crdt_state/write/item.rs @@ -267,6 +267,14 @@ pub fn write_item( Stage::Merge { retries, .. } => *retries as f64, _ => 0.0, }; + // Extract merge_server_start from Stage::Merge; 0.0 clears the register. + let merge_server_start_val: f64 = match stage { + Stage::Merge { + server_start_time: Some(t), + .. + } => *t, + _ => 0.0, + }; let Some(state_mutex) = get_crdt() else { return; }; @@ -335,6 +343,11 @@ pub fn write_item( apply_and_persist(&mut state, |s| { s.crdt.doc.items[idx].claim_ts.set(claim_ts_val) }); + apply_and_persist(&mut state, |s| { + s.crdt.doc.items[idx] + .merge_server_start + .set(merge_server_start_val) + }); if let Some(ma) = merged_at { apply_and_persist(&mut state, |s| s.crdt.doc.items[idx].merged_at.set(ma)); } @@ -380,6 +393,7 @@ pub fn write_item( "epic": "", "resume_to": "", "plan_state": "", + "merge_server_start": merge_server_start_val, }) .into(); @@ -409,6 +423,7 @@ pub fn write_item( item.epic.advance_seq(floor); item.resume_to.advance_seq(floor); item.plan_state.advance_seq(floor); + item.merge_server_start.advance_seq(floor); } // Broadcast a CrdtEvent for the new item. @@ -484,11 +499,13 @@ pub fn set_retry_count(story_id: &str, count: i64) { commits_ahead, claim, retries: _, + server_start_time, } => Stage::Merge { feature_branch, commits_ahead, claim, retries: count.max(0) as u32, + server_start_time, }, _ => return, }; @@ -525,6 +542,7 @@ pub fn bump_retry_count(story_id: &str) -> i64 { commits_ahead, claim, retries, + server_start_time, } => { let n = retries + 1; ( @@ -533,6 +551,7 @@ pub fn bump_retry_count(story_id: &str) -> i64 { commits_ahead, claim, retries: n, + server_start_time, }, n, ) diff --git a/server/src/crdt_state/write/migrations.rs b/server/src/crdt_state/write/migrations.rs index f092f5b1..a96a6b38 100644 --- a/server/src/crdt_state/write/migrations.rs +++ b/server/src/crdt_state/write/migrations.rs @@ -391,6 +391,7 @@ mod stage_migration_tests { commits_ahead: NonZeroU32::new(1).unwrap(), claim: None, retries: 0, + server_start_time: None, }, ), ( diff --git a/server/src/db/content_store.rs b/server/src/db/content_store.rs index d79dce99..143f8fc6 100644 --- a/server/src/db/content_store.rs +++ b/server/src/db/content_store.rs @@ -56,6 +56,10 @@ pub enum ContentKey<'a> { /// "completed" so the mergemaster agent exit handler in `spawn.rs` can /// distinguish a clean success from a transient crash (bug 1008). MergeSuccess(&'a str), + /// JSON-serialised `MergeReport` written by the merge runner on successful + /// completion. Read by `get_merge_status` to surface gate output for the + /// "completed" state without a separate MergeJob CRDT register (story 1036). + MergeReport(&'a str), } impl<'a> ContentKey<'a> { @@ -80,6 +84,7 @@ impl<'a> ContentKey<'a> { ContentKey::MergeFixupPending(id) => format!("{id}:merge_fixup_pending"), ContentKey::MergeFailureKind(id) => format!("{id}:merge_failure_kind"), ContentKey::MergeSuccess(id) => format!("{id}:merge_success"), + ContentKey::MergeReport(id) => format!("{id}:merge_report"), } } } diff --git a/server/src/db/gc.rs b/server/src/db/gc.rs index 46f6adf2..e9b84eac 100644 --- a/server/src/db/gc.rs +++ b/server/src/db/gc.rs @@ -30,6 +30,7 @@ pub(crate) fn purge_content_keys_for_story(story_id: &str) { delete_content(ContentKey::CommitRecoveryPending(story_id)); delete_content(ContentKey::MergeFixupPending(story_id)); delete_content(ContentKey::MergeFailureKind(story_id)); + delete_content(ContentKey::MergeReport(story_id)); } /// Spawn a background task that purges content-store entries when a story reaches a terminal stage. diff --git a/server/src/pipeline_state/events.rs b/server/src/pipeline_state/events.rs index a1533fb2..9056d25a 100644 --- a/server/src/pipeline_state/events.rs +++ b/server/src/pipeline_state/events.rs @@ -174,6 +174,7 @@ mod tests { commits_ahead: NonZeroU32::new(3).unwrap(), claim: None, retries: 0, + server_start_time: None, }; // Stage::Merge has exactly two fields: feature_branch and commits_ahead. // There is no way to attach an agent name to it. The type system diff --git a/server/src/pipeline_state/projection.rs b/server/src/pipeline_state/projection.rs index 322b4fbd..ca07a47b 100644 --- a/server/src/pipeline_state/projection.rs +++ b/server/src/pipeline_state/projection.rs @@ -177,6 +177,7 @@ mod tests { commits_ahead: nz(1), claim: None, retries: 0, + server_start_time: None, }, Some("Test"), ); diff --git a/server/src/pipeline_state/tests.rs b/server/src/pipeline_state/tests.rs index 86750af3..b452372e 100644 --- a/server/src/pipeline_state/tests.rs +++ b/server/src/pipeline_state/tests.rs @@ -203,6 +203,7 @@ fn block_from_any_active_stage() { commits_ahead: nz(1), claim: None, retries: 0, + server_start_time: None, }; let result = transition( m, @@ -359,6 +360,7 @@ fn merge_failed_final() { commits_ahead: nz(1), claim: None, retries: 0, + server_start_time: None, }; let result = transition( s, @@ -462,6 +464,7 @@ fn bug_502_agent_not_in_stage() { commits_ahead: NonZeroU32::new(3).unwrap(), claim: None, retries: 0, + server_start_time: None, }; // Stage::Merge has exactly two fields: feature_branch and commits_ahead. // There is no way to attach an agent name to it. The type system @@ -552,6 +555,7 @@ fn reject_from_active_stages() { commits_ahead: nz(1), claim: None, retries: 0, + server_start_time: None, }; let result = transition( m, @@ -945,6 +949,7 @@ fn merge_aborted_returns_to_coding() { commits_ahead: nz(2), claim: None, retries: 0, + server_start_time: None, }; let result = transition(s, PipelineEvent::MergeAborted).unwrap(); assert!( @@ -1056,6 +1061,7 @@ fn hotfix_requested_rejected_from_non_done_stages() { commits_ahead: nz(1), claim: None, retries: 0, + server_start_time: None, }, ] { let result = transition(stage.clone(), PipelineEvent::HotfixRequested); @@ -1101,6 +1107,7 @@ fn audit_entry_is_single_line_with_all_fields() { commits_ahead: nz(3), claim: None, retries: 0, + server_start_time: None, }, event: PipelineEvent::GatesPassed { feature_branch: fb("feature/story-42"), @@ -1139,6 +1146,7 @@ fn audit_entry_merge_to_done() { commits_ahead: nz(1), claim: None, retries: 0, + server_start_time: None, }, after: Stage::Done { merged_at: chrono::Utc::now(), @@ -1232,6 +1240,7 @@ fn audit_entry_merge_to_merge_failure() { commits_ahead: nz(1), claim: None, retries: 0, + server_start_time: None, }, after: Stage::MergeFailure { kind: MergeFailureKind::Other("conflicts".into()), diff --git a/server/src/pipeline_state/transition.rs b/server/src/pipeline_state/transition.rs index 811f7ee3..c64812e7 100644 --- a/server/src/pipeline_state/transition.rs +++ b/server/src/pipeline_state/transition.rs @@ -166,6 +166,7 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result Result Ok(Coding { claim: None, @@ -394,6 +396,7 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result, /// Number of mergemaster restarts for this item. Zero on the first attempt. retries: u32, + /// Unix timestamp of the server process that started the active merge task. + /// `None` means no merge task is currently in flight. + server_start_time: Option, }, /// Mergemaster squashed to master. Always carries merge metadata. @@ -371,6 +380,7 @@ impl Stage { commits_ahead: NonZeroU32::new(1).expect("1 is non-zero"), claim: None, retries: 0, + server_start_time: None, }), "merge_failure" => Some(Stage::MergeFailure { kind: MergeFailureKind::Other(String::new()), @@ -455,7 +465,7 @@ pub enum ExecutionState { /// /// The retry count is no longer a top-level field — callers read it from the /// Stage variant (`Stage::Coding { retries }` / `Stage::Merge { retries }`). -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct PipelineItem { pub story_id: StoryId, pub name: String, diff --git a/server/src/worktree/sweep.rs b/server/src/worktree/sweep.rs index a2be93f2..8304ea73 100644 --- a/server/src/worktree/sweep.rs +++ b/server/src/worktree/sweep.rs @@ -196,6 +196,7 @@ mod tests { commits_ahead: NonZeroU32::new(1).unwrap(), claim: None, retries: 0, + server_start_time: None, }; assert!(!worktree_should_be_swept(Some(&stage))); } @@ -380,6 +381,7 @@ mod tests { commits_ahead: NonZeroU32::new(1).unwrap(), claim: None, retries: 0, + server_start_time: None, }) } else { None