Skip to content

Commit 30b84d6

Browse files
authored
feat(worker): withhold stale PR review output when the head moves mid-session (#8) (#42)
Re-fetch the PR refs after the session and before publication. If the head advanced while the review ran, complete the Check Run as neutral/Stale, surface 'Status: superseded' on the marker-backed status comment, and mark the task superseded in the Cave task list — the findings are never presented as if they covered the current head. The newer push's own event (or a maintainer retry) reviews the fresh head. This closes the last gap in issue #8: check runs already attach to resolved immutable SHAs (no 'HEAD' placeholders since the #10 target-resolution work), and queued reviews are already superseded at dequeue; this adds the mid-session window. Signed-off-by: Val Alexander <bunsthedev@gmail.com>
1 parent 3b98a12 commit 30b84d6

4 files changed

Lines changed: 313 additions & 2 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ duplicate comments.
156156
| PR lifecycle review trigger | Implemented | Policy-gated auto-review on opened/synchronize/reopened/ready_for_review plus label opt-in; familiar-authored PRs are never auto-reviewed. |
157157
| Push / commit review trigger | Partial | Events parsed and typed with fixtures; execution lane needs headless contract v3. |
158158
| GitHub App installation tokens | Implemented | Mints installation access tokens from the App private key. |
159-
| Check Run creation and completion | Partial | Creates and updates Check Runs against the resolved target head SHA; stale-ref revalidation before publish is still planned. |
159+
| Check Run creation and completion | Implemented | Check Runs attach to the resolved target head SHA; PR reviews re-fetch the head pre-publish and complete as neutral/Stale instead of publishing findings against a moved ref. |
160160
| Headless execution contract | Locked (v1) | Brief, result envelope, exit codes, and git-auth channel are pinned in [`docs/headless-contract.md`](docs/headless-contract.md) with JSON Schemas, golden fixtures, and a conformance test. |
161161
| `coven-code --headless` execution | Partial | Worker spawns headless sessions with a tokenless session brief and enforces task timeouts; result quality depends on the runtime. |
162162
| Pull request creation | Partial | Opens draft PRs from session results against the repository's resolved default/base branch. |

crates/github/src/tasks.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ pub enum TaskListStatus {
1212
Review,
1313
Done,
1414
Failed,
15+
/// The target moved while the task ran (e.g. a PR head advanced during a
16+
/// review); the output was withheld as stale (issue #8).
17+
Superseded,
1518
}
1619

1720
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -106,6 +109,16 @@ impl TaskStore {
106109
}
107110
}
108111

112+
/// Marks a task whose target moved mid-run; its output was withheld as
113+
/// stale rather than published against the wrong ref (issue #8).
114+
pub async fn mark_superseded(&self, task_id: &str) {
115+
let mut items = self.inner.write().await;
116+
if let Some(item) = items.get_mut(task_id) {
117+
item.status = TaskListStatus::Superseded;
118+
item.updated_at = now_rfc3339();
119+
}
120+
}
121+
109122
/// Records a task as failed, inserting it if it was never marked running.
110123
///
111124
/// Used for pre-flight failures (token, ref resolution, Check Run creation)

crates/webhook/src/routes.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,7 @@ fn status_label(status: &TaskListStatus) -> &'static str {
440440
TaskListStatus::Review => "awaiting review",
441441
TaskListStatus::Done => "done",
442442
TaskListStatus::Failed => "failed",
443+
TaskListStatus::Superseded => "superseded",
443444
}
444445
}
445446

crates/worker/src/lib.rs

Lines changed: 298 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,58 @@ async fn execute_task_with_minter(
257257

258258
// The Check Run ALWAYS reaches a terminal conclusion; both arms complete it.
259259
match outcome {
260+
// Head moved mid-review (issue #8): the findings describe a commit the
261+
// PR no longer points at. Mark superseded everywhere — never publish
262+
// stale review output as if it covered the current head.
263+
Ok(published) if published.stale.is_some() => {
264+
let stale = published.stale.as_ref().expect("guarded by arm");
265+
task_store.mark_superseded(&task.id).await;
266+
if let Some(number) = surface_number(&task.kind) {
267+
let marker = status_comment::marker(
268+
&familiar.id,
269+
&task.repo_owner,
270+
&task.repo_name,
271+
number,
272+
);
273+
let body = format!(
274+
"Status: superseded\n\nThe PR head moved from `{}` to `{}` while the \
275+
review ran, so these findings no longer describe the current diff. \
276+
The newer push is reviewed by its own event, or re-run with a \
277+
`retry` command.",
278+
stale.reviewed_sha, stale.current_sha
279+
);
280+
if let Err(e) = status_comment::upsert(
281+
api_base_url,
282+
&orchestration,
283+
&task.repo_owner,
284+
&task.repo_name,
285+
number,
286+
&marker,
287+
&body,
288+
)
289+
.await
290+
{
291+
warn!(task_id = %task.id, "failed to upsert superseded status: {e:#}");
292+
}
293+
}
294+
if let Err(e) = check_run::complete_with_base_url(
295+
api_base_url,
296+
&orchestration,
297+
&task.repo_owner,
298+
&task.repo_name,
299+
check_id,
300+
check_run::CheckConclusion::Neutral,
301+
"Stale",
302+
&format!(
303+
"Reviewed {}, but the PR head is now {} — findings withheld as stale.",
304+
stale.reviewed_sha, stale.current_sha
305+
),
306+
)
307+
.await
308+
{
309+
error!(task_id = %task.id, "failed to finalize stale check run: {e:#}");
310+
}
311+
}
260312
Ok(published) => {
261313
let disp = disposition(&published.result);
262314
task_store
@@ -355,6 +407,17 @@ async fn execute_task_with_minter(
355407
struct Published {
356408
result: SessionResult,
357409
opened_pr: Option<u64>,
410+
/// Set when a PR review's head moved while the session ran (issue #8):
411+
/// the findings were computed against `reviewed_sha`, but the PR is now
412+
/// at `current_sha`. Publication must mark the task superseded instead of
413+
/// presenting stale findings as current.
414+
stale: Option<StaleRefs>,
415+
}
416+
417+
/// Evidence of a mid-session head move on a reviewed PR.
418+
struct StaleRefs {
419+
reviewed_sha: String,
420+
current_sha: String,
358421
}
359422

360423
/// Mints repo-scoped installation tokens for one task. Tests substitute
@@ -529,6 +592,38 @@ async fn run_and_publish(
529592
// PR body, Check Run output).
530593
redact::sanitize_result(&mut result, &[orchestration, &agent_git]);
531594

595+
// Stale-ref gate (issue #8): review findings are only valid for the head
596+
// SHA that was actually reviewed. Re-fetch the PR before publishing; if
597+
// the head moved mid-session, surface the task as superseded rather than
598+
// presenting stale findings as current. The newer push's own event (or a
599+
// maintainer `retry`) re-reviews the fresh head.
600+
if let TaskKind::ReviewPullRequest { pr_number, .. } = &task.kind {
601+
let refs = repo::get_pull_request_refs_with_base_url(
602+
api_base_url,
603+
orchestration,
604+
&task.repo_owner,
605+
&task.repo_name,
606+
*pr_number,
607+
)
608+
.await?;
609+
if refs.head_sha != targets.head_sha {
610+
info!(
611+
task_id = %task.id,
612+
reviewed = %targets.head_sha,
613+
current = %refs.head_sha,
614+
"PR head moved during review — publishing as superseded"
615+
);
616+
return Ok(Published {
617+
result,
618+
opened_pr: None,
619+
stale: Some(StaleRefs {
620+
reviewed_sha: targets.head_sha.clone(),
621+
current_sha: refs.head_sha,
622+
}),
623+
});
624+
}
625+
}
626+
532627
// Publish according to the terminal disposition of the result.
533628
let disp = disposition(&result);
534629
let mut opened_pr = None;
@@ -583,7 +678,11 @@ async fn run_and_publish(
583678
// Terminal state (done / needs input / failed) lands on the status surface
584679
// from execute_task's outcome handling.
585680

586-
Ok(Published { result, opened_pr })
681+
Ok(Published {
682+
result,
683+
opened_pr,
684+
stale: None,
685+
})
587686
}
588687

589688
/// Opens the draft PR and posts the PR-opened comment with post-validation
@@ -1958,6 +2057,204 @@ mod supersession_tests {
19582057
}
19592058
}
19602059

2060+
#[cfg(test)]
2061+
mod stale_ref_tests {
2062+
use super::*;
2063+
use coven_github_api::installation::TokenRole;
2064+
use coven_github_api::tasks::TaskListStatus;
2065+
use coven_github_config::{FamiliarConfig, GitHubAppConfig, ServerConfig, WorkerConfig};
2066+
use std::collections::HashMap;
2067+
use std::fs;
2068+
use std::os::unix::fs::PermissionsExt;
2069+
use std::path::PathBuf;
2070+
use wiremock::matchers::{method, path};
2071+
use wiremock::{Mock, MockServer, ResponseTemplate};
2072+
2073+
const ORCHESTRATION: &str = "ghs_orchestration0000000000000000000000";
2074+
const AGENT_GIT: &str = "******";
2075+
2076+
fn fixed_minter() -> Minter {
2077+
Minter::Fixed(HashMap::from([
2078+
(TokenRole::Orchestration, ORCHESTRATION.to_string()),
2079+
(TokenRole::AgentGit, AGENT_GIT.to_string()),
2080+
]))
2081+
}
2082+
2083+
fn pr_refs_body(head_sha: &str) -> serde_json::Value {
2084+
serde_json::json!({
2085+
"head": { "ref": "feat/change", "sha": head_sha },
2086+
"base": { "ref": "main", "sha": "base0000" }
2087+
})
2088+
}
2089+
2090+
/// A hosted review whose PR head advances mid-session must complete the
2091+
/// Check Run as neutral/Stale, surface `Status: superseded` on the status
2092+
/// comment, and mark the task superseded — never publishing the findings
2093+
/// as if they covered the current head (issue #8).
2094+
#[tokio::test]
2095+
async fn review_of_moved_head_is_published_as_superseded() {
2096+
let server = MockServer::start().await;
2097+
Mock::given(method("GET"))
2098+
.and(path("/repos/OpenCoven/demo"))
2099+
.respond_with(
2100+
ResponseTemplate::new(200)
2101+
.set_body_json(serde_json::json!({"default_branch": "main"})),
2102+
)
2103+
.mount(&server)
2104+
.await;
2105+
// First fetch (target resolution) sees the reviewed head; the
2106+
// pre-publish re-fetch sees that the head has moved on.
2107+
Mock::given(method("GET"))
2108+
.and(path("/repos/OpenCoven/demo/pulls/88"))
2109+
.respond_with(ResponseTemplate::new(200).set_body_json(pr_refs_body("sha-reviewed")))
2110+
.up_to_n_times(1)
2111+
.mount(&server)
2112+
.await;
2113+
Mock::given(method("GET"))
2114+
.and(path("/repos/OpenCoven/demo/pulls/88"))
2115+
.respond_with(ResponseTemplate::new(200).set_body_json(pr_refs_body("sha-moved")))
2116+
.mount(&server)
2117+
.await;
2118+
Mock::given(method("GET"))
2119+
.and(path("/repos/OpenCoven/demo/pulls/88/files"))
2120+
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
2121+
{ "filename": "src/lib.rs" }
2122+
])))
2123+
.mount(&server)
2124+
.await;
2125+
Mock::given(method("POST"))
2126+
.and(path("/repos/OpenCoven/demo/check-runs"))
2127+
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": 7})))
2128+
.mount(&server)
2129+
.await;
2130+
Mock::given(method("PATCH"))
2131+
.and(path("/repos/OpenCoven/demo/check-runs/7"))
2132+
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
2133+
.mount(&server)
2134+
.await;
2135+
Mock::given(method("GET"))
2136+
.and(path("/repos/OpenCoven/demo/issues/88/comments"))
2137+
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
2138+
.mount(&server)
2139+
.await;
2140+
Mock::given(method("POST"))
2141+
.and(path("/repos/OpenCoven/demo/issues/88/comments"))
2142+
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": 1})))
2143+
.mount(&server)
2144+
.await;
2145+
2146+
// Contract-conformant review result with a finding that must NOT be
2147+
// presented as current once the head has moved.
2148+
let script = r#"#!/usr/bin/env bash
2149+
cat > "$5" <<EOF
2150+
{"contract_version":"2","status":"success","branch":null,"commits":[],"files_changed":[],"summary":"Found one issue in src/lib.rs.","pr_body":"","review":{"mode":"pull_request","evidence_status":"complete","reviewed_files":["src/lib.rs"],"supporting_files":[],"findings":[{"severity":"medium","file":"src/lib.rs","line":10,"title":"Off-by-one","body":"Loop bound skips the last element.","recommendation":null}],"tests_run":[],"no_findings_reason":null,"limitations":[]},"exit_reason":null}
2151+
EOF
2152+
exit 0
2153+
"#;
2154+
let root =
2155+
std::env::temp_dir().join(format!("coven-github-stale-{}", uuid::Uuid::new_v4()));
2156+
fs::create_dir_all(&root).expect("test dir should be created");
2157+
let script_path = root.join("fake-coven-code.sh");
2158+
fs::write(&script_path, script).expect("script should be written");
2159+
fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755))
2160+
.expect("script should be executable");
2161+
2162+
let config = Config {
2163+
server: ServerConfig {
2164+
bind: "127.0.0.1:0".to_string(),
2165+
cave_base_url: None,
2166+
},
2167+
github: GitHubAppConfig {
2168+
app_id: 1,
2169+
private_key_path: PathBuf::from("/nonexistent/never-read.pem"),
2170+
webhook_secret: "secret".to_string(),
2171+
api_base_url: Some(server.uri()),
2172+
},
2173+
worker: WorkerConfig {
2174+
concurrency: 1,
2175+
coven_code_bin: script_path,
2176+
workspace_root: root.clone(),
2177+
timeout_secs: 30,
2178+
max_retries: 0,
2179+
},
2180+
familiars: vec![FamiliarConfig {
2181+
id: "cody".to_string(),
2182+
display_name: "Cody".to_string(),
2183+
bot_username: "coven-cody[bot]".to_string(),
2184+
model: None,
2185+
skills: vec![],
2186+
trigger_labels: vec![],
2187+
}],
2188+
review: coven_github_config::ReviewConfig::default(),
2189+
};
2190+
let task = Task {
2191+
id: "task-stale".to_string(),
2192+
installation_id: 1,
2193+
repo_owner: "OpenCoven".to_string(),
2194+
repo_name: "demo".to_string(),
2195+
familiar_id: "cody".to_string(),
2196+
commander: None,
2197+
kind: TaskKind::ReviewPullRequest {
2198+
pr_number: 88,
2199+
pr_title: "t".to_string(),
2200+
reason: "synchronize".to_string(),
2201+
},
2202+
};
2203+
let task_store = TaskStore::default();
2204+
2205+
execute_task_with_minter(&config, task_store.clone(), task, &fixed_minter())
2206+
.await
2207+
.expect("stale review must complete cleanly");
2208+
2209+
// Cave sees the honest terminal state.
2210+
let items = task_store.list().await;
2211+
assert_eq!(items.len(), 1);
2212+
assert_eq!(items[0].status, TaskListStatus::Superseded);
2213+
2214+
let requests = server.received_requests().await.expect("requests recorded");
2215+
2216+
// The Check Run reached neutral/Stale — not success/Done.
2217+
let check_patches: Vec<String> = requests
2218+
.iter()
2219+
.filter(|r| {
2220+
r.method.as_str() == "PATCH"
2221+
&& r.url.path() == "/repos/OpenCoven/demo/check-runs/7"
2222+
})
2223+
.map(|r| String::from_utf8_lossy(&r.body).to_string())
2224+
.collect();
2225+
let terminal = check_patches
2226+
.last()
2227+
.expect("the check run must reach a terminal state");
2228+
assert!(terminal.contains("\"neutral\""), "conclusion: {terminal}");
2229+
assert!(terminal.contains("Stale"), "title: {terminal}");
2230+
assert!(terminal.contains("sha-reviewed") && terminal.contains("sha-moved"));
2231+
assert!(
2232+
!check_patches.iter().any(|b| b.contains("\"success\"")),
2233+
"stale findings must never publish as a successful review"
2234+
);
2235+
2236+
// The status surface says superseded, and the finding text was withheld.
2237+
let comment_posts: Vec<String> = requests
2238+
.iter()
2239+
.filter(|r| {
2240+
r.method.as_str() == "POST"
2241+
&& r.url.path() == "/repos/OpenCoven/demo/issues/88/comments"
2242+
})
2243+
.map(|r| String::from_utf8_lossy(&r.body).to_string())
2244+
.collect();
2245+
assert!(
2246+
comment_posts.iter().any(|b| b.contains("Status: superseded")),
2247+
"status surface must say superseded: {comment_posts:?}"
2248+
);
2249+
assert!(
2250+
!comment_posts.iter().any(|b| b.contains("Off-by-one")),
2251+
"stale findings must not land on the status surface"
2252+
);
2253+
2254+
let _ = fs::remove_dir_all(root);
2255+
}
2256+
}
2257+
19612258
#[cfg(test)]
19622259
mod command_and_marker_tests {
19632260
use super::*;

0 commit comments

Comments
 (0)