diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7d2eab1ee5..fe320a062c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1044,6 +1044,8 @@ dependencies = [ "tokio-util", "toml 0.8.2", "tower-http", + "tracing", + "tracing-subscriber", "urlencoding", "uuid", "walkdir", @@ -3663,6 +3665,15 @@ dependencies = [ "zbus", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -7491,6 +7502,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", ] [[package]] @@ -7500,12 +7523,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" dependencies = [ "matchers", + "nu-ansi-term", "once_cell", "regex-automata", "sharded-slab", + "smallvec", "thread_local", "tracing", "tracing-core", + "tracing-log", ] [[package]] @@ -7769,6 +7795,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "vcpkg" version = "0.2.15" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d456296dd2..ee291e5bb0 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -108,6 +108,8 @@ argon2 = "0.5" tempfile = "3" minisign-verify = "0.2" semver = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-window-state = { version = "2", optional = true } diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index d74c7f08b4..671b6a7124 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -109,6 +109,15 @@ pub enum ConnectionCommand { /// by the outer `.map_err(...)` in `run_connection`. const INIT_TIMEOUT_SENTINEL: &str = "__codeg_init_timeout__"; +/// Sentinel string embedded in a `sacp::Error` when the session/new (or the +/// session/load → new fallback) handshake times out. Converted back to +/// `AcpError::SessionStartTimeout` by the outer `.map_err(...)`. Like +/// `initialize`, the session handshake is bounded so a stuck agent fails the +/// connection (and the loop reconcile then settles the iteration) rather than +/// parking it `Running` forever with no output. This bounds connection +/// *establishment*, not the agent's working turn — no cap on real work. +const SESSION_TIMEOUT_SENTINEL: &str = "__codeg_session_timeout__"; + /// RAII guard that removes the `AgentConnection` entry from the manager /// map when dropped. Runs on both normal task exit AND task panic, so a /// panic inside `run_connection` can't leak a stale map entry. @@ -466,6 +475,9 @@ pub async fn spawn_agent_connection( preferred_mode_id: Option, preferred_config_values: BTreeMap, delegation_injection: Option, + // Per-iteration loop capability token, threaded straight to `run_connection` + // → `inject_codeg_mcp`. `Some` only for loop-engine dispatch. + loop_capability_token: Option, ) -> Result, AcpError> { // Create the authoritative session state up front. Subsequent emit_with_state // calls write through this state and increment its seq counter so the first @@ -571,6 +583,7 @@ pub async fn spawn_agent_connection( preferred_mode_id, preferred_config_values, delegation_injection, + loop_capability_token, ) .await; @@ -1072,8 +1085,9 @@ fn companion_features_arg( delegation_enabled: bool, feedback_enabled: bool, ask_enabled: bool, + loop_enabled: bool, ) -> Option { - if !delegation_enabled && !feedback_enabled && !ask_enabled { + if !delegation_enabled && !feedback_enabled && !ask_enabled && !loop_enabled { return None; } let mut features: Vec<&str> = Vec::new(); @@ -1086,6 +1100,9 @@ fn companion_features_arg( if ask_enabled { features.push("ask"); } + if loop_enabled { + features.push("loop"); + } Some(features.join(",")) } @@ -1102,17 +1119,29 @@ async fn inject_codeg_mcp( injection: &DelegationInjection, parent_connection_id: &str, working_dir: &Path, + loop_capability_token: Option<&str>, ) -> Option { - // codeg-mcp carries BOTH the delegation tools and the live-feedback tool. - // Inject it when EITHER feature is enabled; the `--features` arg tells the + // codeg-mcp carries the delegation tools, the live-feedback tool, the + // ask-user-question tool, AND (for loop iterations) the loop-submit tools. + // Inject it when ANY feature is enabled; the `--features` arg tells the // companion which tool groups to expose so a disabled feature's tools never // surface to the LLM. (Historically this was gated on delegation alone.) + // + // The loop feature is per-spawn, not a hot-swappable setting: it is on iff a + // per-iteration `loop_capability_token` was threaded in by the dispatch + // path. That token is NOT registered in the `tokens` registry (unlike the + // delegation `--token`) — the host reverse-looks it up in the database. let delegation_enabled = injection.broker.config_snapshot().await.enabled; let feedback_enabled = injection.feedback.is_enabled().await; - let ask_enabled = injection.ask.is_enabled().await; + let loop_enabled = loop_capability_token.is_some(); + // Loop iterations always expose `ask_user_question`: the "question" inbox + // category is a first-class part of the loop human-in-the-loop design, so a + // loop agent must be able to ask the operator regardless of the global ask + // toggle that governs ordinary / delegation sessions. + let ask_enabled = injection.ask.is_enabled().await || loop_enabled; // `None` (no feature enabled) short-circuits the whole injection. let features_arg = - companion_features_arg(delegation_enabled, feedback_enabled, ask_enabled)?; + companion_features_arg(delegation_enabled, feedback_enabled, ask_enabled, loop_enabled)?; let Some(binary_path) = locate_codeg_mcp_binary() else { eprintln!( "[delegation][WARN] codeg-mcp companion binary not found (checked CODEG_MCP_BIN, \ @@ -1134,7 +1163,7 @@ async fn inject_codeg_mcp( ) .await; let mut server = McpServerStdio::new("codeg-mcp", binary_path); - server = server.args(vec![ + let mut args = vec![ "--parent-connection-id".to_string(), parent_connection_id.to_string(), "--socket-path".to_string(), @@ -1147,10 +1176,17 @@ async fn inject_codeg_mcp( // (any platform). "--parent-pid".to_string(), std::process::id().to_string(), - // Tool groups to expose this launch (delegation and/or feedback). + // Tool groups to expose this launch (delegation / feedback / ask / loop). "--features".to_string(), features_arg, - ]); + ]; + // Loop iterations also carry their per-iteration capability token so the + // `loop_submit_*` tools can authenticate to the host's `ingest`. + if let Some(cap) = loop_capability_token { + args.push("--capability-token".to_string()); + args.push(cap.to_string()); + } + server = server.args(args); servers.push(McpServer::Stdio(server)); Some(CompanionInjection { token, @@ -1269,6 +1305,9 @@ async fn run_connection( preferred_mode_id: Option, preferred_config_values: BTreeMap, delegation_injection: Option, + // Per-iteration loop capability token (see `loop_engine::dispatch`). `Some` + // turns on the codeg-mcp companion's loop tools for this connection only. + loop_capability_token: Option, ) -> Result<(), AcpError> { let pending_perms: PendingPermissions = Arc::new(tokio::sync::Mutex::new(HashMap::new())); // `terminal_base_env` already filtered to just the credential helper @@ -1508,7 +1547,17 @@ async fn run_connection( // filter needed. The returned token is stashed on the session // state so connection teardown can revoke it. let delegate_injection = if let Some(inj) = delegation_injection.as_ref() { - inject_codeg_mcp(&mut mcp_servers, inj, &conn_id, &cwd).await + // For loop iterations the dispatch path threads a per-iteration + // capability token here, which turns on the companion's loop + // tools; ordinary sessions pass `None` and never see them. + inject_codeg_mcp( + &mut mcp_servers, + inj, + &conn_id, + &cwd, + loop_capability_token.as_deref(), + ) + .await } else { None }; @@ -1551,7 +1600,21 @@ async fn run_connection( &cwd, mcp_servers.clone(), ); - let load_result = cx.send_request_to(Agent, load_req).block_task().await; + let load_result = match tokio::time::timeout( + std::time::Duration::from_secs(60), + cx.send_request_to(Agent, load_req).block_task(), + ) + .await + { + Ok(r) => r, + Err(_) => { + eprintln!( + "[ACP] session/load TIMED OUT after 60s — treating as a load \ + failure and falling back to a new session." + ); + Err(sacp::util::internal_error("session/load timed out")) + } + }; match load_result { Ok(load_resp) => { @@ -1740,8 +1803,9 @@ async fn run_connection( ) .await; } - let new_resp = cx - .send_request_to( + let new_resp = match tokio::time::timeout( + std::time::Duration::from_secs(60), + cx.send_request_to( Agent, build_new_session_request( agent_type, @@ -1749,8 +1813,21 @@ async fn run_connection( mcp_servers.clone(), ), ) - .block_task() - .await?; + .block_task(), + ) + .await + { + Ok(r) => r?, + Err(_) => { + eprintln!( + "[ACP] session/new (resume fallback) TIMED OUT after \ + 60s — the agent never answered the session handshake." + ); + return Err(sacp::util::internal_error( + SESSION_TIMEOUT_SENTINEL, + )); + } + }; let fallback_sid = new_resp.session_id.0.to_string(); let initial_config_options = new_resp.config_options.clone(); let mut session = cx.attach_session(new_resp, Default::default())?; @@ -1818,13 +1895,25 @@ async fn run_connection( } } else { // Create new session - let new_resp = cx - .send_request_to( + let new_resp = match tokio::time::timeout( + std::time::Duration::from_secs(60), + cx.send_request_to( Agent, build_new_session_request(agent_type, &cwd, mcp_servers.clone()), ) - .block_task() - .await?; + .block_task(), + ) + .await + { + Ok(r) => r?, + Err(_) => { + eprintln!( + "[ACP] session/new TIMED OUT after 60s — the agent never \ + answered the session handshake." + ); + return Err(sacp::util::internal_error(SESSION_TIMEOUT_SENTINEL)); + } + }; let sid = new_resp.session_id.0.to_string(); let initial_config_options = new_resp.config_options.clone(); let mut session = cx.attach_session(new_resp, Default::default())?; @@ -1893,6 +1982,8 @@ async fn run_connection( let raw = e.to_string(); if raw.contains(INIT_TIMEOUT_SENTINEL) { AcpError::InitializeTimeout + } else if raw.contains(SESSION_TIMEOUT_SENTINEL) { + AcpError::SessionStartTimeout } else { AcpError::protocol(raw) } @@ -4591,6 +4682,7 @@ mod tests { &injection, "parent-conn", std::path::Path::new("/tmp"), + None, ) .await; @@ -4617,27 +4709,33 @@ mod tests { #[test] fn companion_features_arg_inject_skip_decision() { // All off → no companion at all. - assert_eq!(companion_features_arg(false, false, false), None); + assert_eq!(companion_features_arg(false, false, false, false), None); // Delegation only. assert_eq!( - companion_features_arg(true, false, false), + companion_features_arg(true, false, false, false), Some("delegation".to_string()) ); // Feedback only — the decoupling: companion injected for feedback even // when delegation is off. assert_eq!( - companion_features_arg(false, true, false), + companion_features_arg(false, true, false, false), Some("feedback".to_string()) ); // Ask only — likewise injects the companion on its own. assert_eq!( - companion_features_arg(false, false, true), + companion_features_arg(false, false, true, false), Some("ask".to_string()) ); + // Loop only — a loop iteration spawn injects the companion for the + // loop-submit tools even with every persisted feature off. + assert_eq!( + companion_features_arg(false, false, false, true), + Some("loop".to_string()) + ); // All on → comma-joined, in declaration order. assert_eq!( - companion_features_arg(true, true, true), - Some("delegation,feedback,ask".to_string()) + companion_features_arg(true, true, true, true), + Some("delegation,feedback,ask,loop".to_string()) ); } } diff --git a/src-tauri/src/acp/delegation/companion.rs b/src-tauri/src/acp/delegation/companion.rs index df11e09b95..84ed39dd36 100644 --- a/src-tauri/src/acp/delegation/companion.rs +++ b/src-tauri/src/acp/delegation/companion.rs @@ -41,9 +41,10 @@ use tokio::sync::{oneshot, Mutex}; use crate::acp::delegation::transport::{ client_ask_round_trip, client_cancel, client_cancel_task_round_trip, client_commit_feedback, - client_feedback_round_trip, client_round_trip, client_status_round_trip, BrokerAskRequest, - BrokerCancelRequest, BrokerCancelTaskRequest, BrokerCommitFeedbackRequest, BrokerFeedbackRequest, - BrokerRequest, BrokerResponse, BrokerStatusRequest, + client_feedback_round_trip, client_loop_submit_round_trip, client_round_trip, + client_status_round_trip, BrokerAskRequest, BrokerCancelRequest, BrokerCancelTaskRequest, + BrokerCommitFeedbackRequest, BrokerFeedbackRequest, BrokerLoopSubmitRequest, BrokerRequest, + BrokerResponse, BrokerStatusRequest, }; use crate::acp::question::parse_questions; @@ -133,32 +134,39 @@ pub struct CompanionFeatures { pub delegation: bool, pub feedback: bool, pub ask: bool, + /// Loop-engineering submission tools (`loop_submit_*` etc.). Enabled ONLY for + /// a loop-iteration companion launch, which also carries a per-iteration + /// `--capability-token`. Field is `loop_tools` because `loop` is a keyword. + pub loop_tools: bool, } impl CompanionFeatures { - /// Parse the comma-joined `--features` value (e.g. `delegation,feedback,ask`). - /// Unknown tokens are ignored. An absent value (`None`) defaults to - /// delegation-only — backward compatible with a parent that predates - /// feature gating (companion + listener ship together, so post-upgrade the - /// parent always passes an explicit `--features`). + /// Parse the comma-joined `--features` value (e.g. + /// `delegation,feedback,ask,loop`). Unknown tokens are ignored. An absent + /// value (`None`) defaults to delegation-only — backward compatible with a + /// parent that predates feature gating (companion + listener ship together, + /// so post-upgrade the parent always passes an explicit `--features`). pub fn parse(raw: Option<&str>) -> Self { let Some(s) = raw else { return Self { delegation: true, feedback: false, ask: false, + loop_tools: false, }; }; let mut f = Self { delegation: false, feedback: false, ask: false, + loop_tools: false, }; for tok in s.split(',').map(str::trim).filter(|t| !t.is_empty()) { match tok { "delegation" => f.delegation = true, "feedback" => f.feedback = true, "ask" => f.ask = true, + "loop" => f.loop_tools = true, _ => {} } } @@ -171,6 +179,9 @@ impl CompanionFeatures { "check_user_feedback" => self.feedback, "ask_user_question" => self.ask, "delegate_to_agent" | "get_delegation_status" | "cancel_delegation" => self.delegation, + "loop_submit_route" | "loop_submit_artifacts" | "loop_submit_review" + | "loop_report_blocked" | "loop_task_complete" | "loop_record_memory" + | "loop_read_memory" | "loop_submit_reflection" => self.loop_tools, _ => false, } } @@ -185,6 +196,10 @@ pub struct CompanionContext { pub token: String, /// Tool groups this launch exposes (see [`CompanionFeatures`]). pub features: CompanionFeatures, + /// Per-iteration loop capability token (`--capability-token`), present ONLY + /// for a loop-iteration launch. The `loop_submit_*` tools send THIS token + /// (not [`Self::token`]) so the host can reverse-look-up the iteration. + pub capability_token: Option, } /// Per-in-flight-call state. The companion stashes one of these per @@ -500,6 +515,33 @@ async fn build_tools_call_spawn( let round_trip = Box::pin(async move { client_ask_round_trip(&socket, &req).await }); register_and_spawn(inflight, id, None, round_trip, render_ask_result).await } + "loop_submit_route" | "loop_submit_artifacts" | "loop_submit_review" + | "loop_report_blocked" | "loop_task_complete" | "loop_record_memory" + | "loop_read_memory" | "loop_submit_reflection" => { + // The loop tools authenticate with the per-iteration capability + // token, NOT the delegation launch token. Its absence means this + // companion wasn't launched for a loop iteration — a configuration + // bug, surfaced as an internal error rather than a tool the LLM can + // retry. The host (`ingest`) owns all further validation. + let Some(capability_token) = ctx.capability_token.clone() else { + return LineAction::Respond(err( + id, + -32603, + "loop tools require a capability token, which this launch lacks", + )); + }; + let req = BrokerLoopSubmitRequest { + token: capability_token, + tool: name.clone(), + payload: arguments, + }; + // No external_handle: canceling a loop submission only suppresses the + // response. The submission is idempotent host-side, so a re-issue + // after a lost response is safe. + let round_trip = + Box::pin(async move { client_loop_submit_round_trip(&socket, &req).await }); + register_and_spawn(inflight, id, None, round_trip, render_loop_result).await + } other => LineAction::Respond(err(id, -32602, format!("unknown tool: {other}"))), } } @@ -959,6 +1001,34 @@ pub fn render_task_report(report: &Value) -> Value { }) } +/// Map a loop-submission round-trip outcome (the host's `{ "ok": bool, .. }` +/// envelope) into an MCP `tools/call` result. `ok=true` carries the persisted +/// result under `result` (rendered as compact JSON the agent can read back, with +/// the structured payload preserved); `ok=false` carries an agent-actionable +/// message under `error` and flags `isError` so the LLM treats it as a failure +/// to correct (e.g. wrong stage, empty batch) rather than a success. +pub fn render_loop_result(outcome: &Value) -> Value { + let ok = outcome.get("ok").and_then(|v| v.as_bool()).unwrap_or(false); + if ok { + let result = outcome.get("result").cloned().unwrap_or(Value::Null); + let text = serde_json::to_string(&result).unwrap_or_else(|_| "submitted".into()); + json!({ + "content": [{ "type": "text", "text": text }], + "isError": false, + "structuredContent": result, + }) + } else { + let msg = outcome + .get("error") + .and_then(|v| v.as_str()) + .unwrap_or("loop submission failed"); + json!({ + "content": [{ "type": "text", "text": msg }], + "isError": true, + }) + } +} + #[cfg(test)] mod tests { use super::*; @@ -970,6 +1040,7 @@ mod tests { delegation: true, feedback: false, ask: false, + loop_tools: false, }) } @@ -979,6 +1050,24 @@ mod tests { socket_path: "/tmp/codeg-mcp-companion-test-nope.sock".into(), token: "tok".into(), features, + capability_token: None, + } + } + + /// A loop-iteration companion context: loop tools enabled + a capability + /// token present, so the `loop_submit_*` dispatch path is exercisable. + fn ctx_loop() -> CompanionContext { + CompanionContext { + parent_connection_id: "p1".into(), + socket_path: "/tmp/codeg-mcp-companion-test-nope.sock".into(), + token: "tok".into(), + features: CompanionFeatures { + delegation: false, + feedback: false, + ask: false, + loop_tools: true, + }, + capability_token: Some("cap-tok".into()), } } @@ -1436,16 +1525,25 @@ mod tests { delegation: false, feedback: true, ask: false, + loop_tools: false, }; const BOTH: CompanionFeatures = CompanionFeatures { delegation: true, feedback: true, ask: false, + loop_tools: false, }; const ASK_ONLY: CompanionFeatures = CompanionFeatures { delegation: false, feedback: false, ask: true, + loop_tools: false, + }; + const LOOP_ONLY: CompanionFeatures = CompanionFeatures { + delegation: false, + feedback: false, + ask: false, + loop_tools: true, }; fn list_tool_names(action: LineAction) -> Vec { @@ -1792,6 +1890,7 @@ mod tests { socket_path: sock, token: "tok".into(), features: FEEDBACK_ONLY, + capability_token: None, }; let inflight = Arc::new(InflightCalls::new()); // tools/call → Spawn (registers the inflight entry). @@ -1826,4 +1925,111 @@ mod tests { // Crucially: no commit was sent for a cancelled (undelivered) check. assert!(!*saw_commit.lock().await, "a cancelled check must not commit"); } + + // -- loop-engineering tool gating + dispatch + rendering ---------------- + + #[test] + fn features_parse_recognizes_loop() { + let f = CompanionFeatures::parse(Some("delegation,loop")); + assert!(f.delegation && f.loop_tools); + assert!(!f.feedback && !f.ask); + // Absent default leaves loop off. + assert!(!CompanionFeatures::parse(None).loop_tools); + } + + #[tokio::test] + async fn tools_list_includes_loop_only_when_enabled() { + let off = list_tool_names( + dispatch_for_test(r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#).await, + ); + assert!(!off.iter().any(|n| n.starts_with("loop_"))); + let on = list_tool_names( + dispatch_with_features(LOOP_ONLY, r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#) + .await, + ); + let mut loop_names: Vec<&str> = on.iter().map(String::as_str).collect(); + loop_names.sort_unstable(); + assert_eq!( + loop_names, + vec![ + "loop_read_memory", + "loop_record_memory", + "loop_report_blocked", + "loop_submit_artifacts", + "loop_submit_reflection", + "loop_submit_review", + "loop_submit_route", + "loop_task_complete", + ] + ); + } + + #[tokio::test] + async fn loop_submit_spawns_when_enabled_with_token() { + let line = json!({ + "jsonrpc": "2.0", "id": 50, "method": "tools/call", + "params": { "name": "loop_submit_route", "arguments": { "route": "full" } } + }) + .to_string(); + let action = dispatch_line(&ctx_loop(), Arc::new(InflightCalls::new()), &line).await; + assert!(matches!(action, LineAction::Spawn(_))); + } + + #[tokio::test] + async fn loop_submit_rejected_as_unknown_when_feature_off() { + // Default ctx (delegation-only): loop tools are hidden + rejected + // uniformly as unknown, no leak that the feature exists but is off. + let line = json!({ + "jsonrpc": "2.0", "id": 51, "method": "tools/call", + "params": { "name": "loop_submit_route", "arguments": { "route": "full" } } + }) + .to_string(); + let resp = unwrap_respond(dispatch_for_test(&line).await); + let e = resp.error.unwrap(); + assert_eq!(e.code, -32602); + assert!(e.message.contains("unknown tool")); + } + + #[tokio::test] + async fn loop_submit_without_capability_token_is_internal_error() { + // Loop feature enabled but no capability token (a misconfigured launch): + // the tool is allowed but cannot authenticate → internal error (-32603), + // distinct from the unknown-tool rejection above. + let ctx = CompanionContext { + parent_connection_id: "p1".into(), + socket_path: "/tmp/nope.sock".into(), + token: "tok".into(), + features: LOOP_ONLY, + capability_token: None, + }; + let line = json!({ + "jsonrpc": "2.0", "id": 52, "method": "tools/call", + "params": { "name": "loop_submit_route", "arguments": { "route": "full" } } + }) + .to_string(); + let action = dispatch_line(&ctx, Arc::new(InflightCalls::new()), &line).await; + let resp = unwrap_respond(action); + let e = resp.error.unwrap(); + assert_eq!(e.code, -32603); + assert!(e.message.contains("capability token")); + } + + #[test] + fn render_loop_result_ok_surfaces_result() { + let outcome = json!({ "ok": true, "result": { "ok": true, "ids": [1, 2] } }); + let rendered = render_loop_result(&outcome); + assert_eq!(rendered["isError"], false); + assert_eq!(rendered["structuredContent"]["ids"][0], 1); + let text = rendered["content"][0]["text"].as_str().unwrap(); + assert!(text.contains("ids")); + } + + #[test] + fn render_loop_result_error_is_flagged() { + let outcome = json!({ "ok": false, "error": "loop_submit_route is only valid during triage" }); + let rendered = render_loop_result(&outcome); + assert_eq!(rendered["isError"], true); + let text = rendered["content"][0]["text"].as_str().unwrap(); + assert!(text.contains("only valid during triage")); + } } diff --git a/src-tauri/src/acp/delegation/listener.rs b/src-tauri/src/acp/delegation/listener.rs index 650eff410d..5a16ec9333 100644 --- a/src-tauri/src/acp/delegation/listener.rs +++ b/src-tauri/src/acp/delegation/listener.rs @@ -45,6 +45,28 @@ pub trait ParentSessionLookup: Send + Sync { async fn current_conversation_id(&self, parent_connection_id: &str) -> Option; } +/// Host-side sink for loop-engineering submissions (`loop_submit_*` tools). The +/// production impl wraps the loop engine's `ingest` over the shared database; +/// tests use an in-memory stub. Kept as a trait (mirroring [`ParentSessionLookup`] +/// / [`crate::acp::feedback::SessionFeedbackAccess`]) so the listener stays +/// decoupled from the `loop_engine` module and is unit-testable without a DB. +/// +/// Unlike the delegation arms, authentication is NOT via [`TokenRegistry`]: the +/// `token` is a per-iteration capability token the impl reverse-looks-up in the +/// database. The return is a flat `Result` — `Ok` is the +/// persisted outcome, `Err` is an agent-actionable message (the listener wraps +/// either into the wire `{ ok, .. }` envelope), so a `LoopError` never crosses +/// this boundary. +#[async_trait] +pub trait LoopIngestAccess: Send + Sync { + async fn loop_ingest( + &self, + token: &str, + tool: &str, + payload: &Value, + ) -> Result; +} + /// Per-launch token entry. Bound at MCP injection time and revoked on parent /// connection teardown. #[derive(Debug, Clone)] @@ -90,6 +112,9 @@ pub struct DelegationListener { /// Registers / cancels the blocking `ask_user_question` tool's pending /// questions. Same `tokens` registry and parent-connection scoping. pub questions: Arc, + /// Persists loop-engineering submissions (`loop_submit_*` tools), authed by + /// per-iteration capability token (NOT the `tokens` registry). + pub loop_ingest: Arc, } impl DelegationListener { @@ -99,6 +124,7 @@ impl DelegationListener { parent_lookup: Arc, feedback: Arc, questions: Arc, + loop_ingest: Arc, ) -> Arc { Arc::new(Self { broker, @@ -106,6 +132,7 @@ impl DelegationListener { parent_lookup, feedback, questions, + loop_ingest, }) } @@ -296,6 +323,17 @@ impl DelegationListener { write_frame(conn, &resp).await?; return Ok(()); } + BrokerMessage::LoopSubmit(req) => { + // No TokenRegistry check: the `ingest` impl reverse-looks-up the + // capability token in the DB and owns all validation. Both an + // accepted submission and an agent-actionable rejection come back + // as the same `{ ok, .. }` envelope so the LLM can correct itself. + let outcome = self + .loop_ingest + .loop_ingest(&req.token, &req.tool, &req.payload) + .await; + loop_submit_response(outcome) + } BrokerMessage::Cancel(cancel) => { self.process_cancel(cancel).await; // Empty ack — the companion only uses this to detect the @@ -534,6 +572,19 @@ fn ask_declined_response() -> std::io::Result { }) } +/// Wrap a loop `ingest` outcome into the wire `{ ok, .. }` envelope. `Ok` carries +/// the persisted result under `result`; `Err` carries the agent-actionable +/// message under `error`. The companion's `render_loop_result` maps this to a +/// `tools/call` result (flagging `isError` on the error path). Never fails — +/// both branches are pure JSON construction. +fn loop_submit_response(outcome: Result) -> BrokerResponse { + let envelope = match outcome { + Ok(result) => serde_json::json!({ "ok": true, "result": result }), + Err(error) => serde_json::json!({ "ok": false, "error": error }), + }; + BrokerResponse { outcome: envelope } +} + /// A `Canceled` report for a setup-side rejection the LLM can't react to (bad /// token, parent gone). Mirrors the old `cancel(..)` DelegationOutcome. fn report_canceled(message: &str) -> DelegationTaskReport { @@ -615,6 +666,7 @@ pub fn default_socket_path(_temp_dir: &Path) -> PathBuf { mod tests { use super::*; use crate::acp::delegation::broker::{ConversationDepthLookup, DelegationConfig}; + use crate::acp::delegation::transport::BrokerLoopSubmitRequest; use crate::acp::delegation::spawner::{mock::MockSpawner, ConnectionSpawner, SpawnerError}; use crate::acp::delegation::types::{DelegationError, DelegationOutcome, DelegationSuccess}; use serde_json::json; @@ -715,6 +767,34 @@ mod tests { } } + /// In-memory loop-ingest stub. Records each `(token, tool, payload)` it sees; + /// by default echoes a success outcome, or returns the seeded error when + /// `fail_with` is set (to exercise the listener's error envelope). Default is + /// success (the delegation/feedback/ask tests don't exercise loop submits). + #[derive(Default)] + struct StubLoopIngest { + calls: tokio::sync::Mutex>, + fail_with: Option, + } + #[async_trait] + impl LoopIngestAccess for StubLoopIngest { + async fn loop_ingest( + &self, + token: &str, + tool: &str, + payload: &Value, + ) -> Result { + self.calls + .lock() + .await + .push((token.to_string(), tool.to_string(), payload.clone())); + match &self.fail_with { + Some(e) => Err(e.clone()), + None => Ok(json!({ "ok": true, "echo_tool": tool })), + } + } + } + use tokio::sync::oneshot; async fn make_broker(mock: Arc) -> Arc { @@ -746,6 +826,7 @@ mod tests { Arc::new(StaticParentLookup(parent_conversation)), Arc::new(StubFeedback::default()), Arc::new(StubQuestion::default()), + Arc::new(StubLoopIngest::default()), ) } @@ -765,6 +846,7 @@ mod tests { Arc::new(StaticParentLookup(Some(1))), feedback, Arc::new(StubQuestion::default()), + Arc::new(StubLoopIngest::default()), ) } @@ -785,6 +867,7 @@ mod tests { Arc::new(StaticParentLookup(Some(1))), Arc::new(StubFeedback::default()), questions, + Arc::new(StubLoopIngest::default()), ) } @@ -1404,6 +1487,82 @@ mod tests { assert_eq!(report.error_code.as_deref(), Some("spawn_failed")); } + // --- loop_submit_* over the listener ----------------------------------- + + fn make_loop_listener(loop_ingest: Arc) -> Arc { + let broker = Arc::new(DelegationBroker::new( + Arc::new(MockSpawner::new()) as Arc, + Arc::new(AlwaysRootLookup) as Arc, + )); + DelegationListener::new( + broker, + Arc::new(TokenRegistry::default()), + Arc::new(StaticParentLookup(Some(1))), + Arc::new(StubFeedback::default()), + Arc::new(StubQuestion::default()), + loop_ingest, + ) + } + + /// A loop submission forwards the capability token + tool + payload to the + /// ingest sink verbatim (no TokenRegistry gate stands between the agent and + /// `ingest`) and wraps the success outcome in the `{ ok:true, result }` + /// envelope. + #[tokio::test] + async fn loop_submit_forwards_to_ingest_and_wraps_ok() { + let ingest = Arc::new(StubLoopIngest::default()); + let listener = make_loop_listener(ingest.clone()); + let (mut client, mut server) = duplex(8 * 1024); + let server_task = tokio::spawn(async move { + listener.serve_one(&mut server).await.unwrap(); + }); + let msg = BrokerMessage::LoopSubmit(BrokerLoopSubmitRequest { + token: "cap-xyz".into(), + tool: "loop_submit_route".into(), + payload: json!({ "route": "full" }), + }); + write_frame(&mut client, &msg).await.unwrap(); + let resp: BrokerResponse = read_frame(&mut client).await.unwrap(); + server_task.await.unwrap(); + assert_eq!(resp.outcome["ok"], true); + assert_eq!(resp.outcome["result"]["echo_tool"], "loop_submit_route"); + // The exact triple reached the sink, capability token included. + let calls = ingest.calls.lock().await; + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].0, "cap-xyz"); + assert_eq!(calls[0].1, "loop_submit_route"); + assert_eq!(calls[0].2["route"], "full"); + } + + /// An ingest rejection (wrong stage / unknown token / etc.) comes back as the + /// `{ ok:false, error }` envelope so the companion can flag it `isError` and + /// the LLM can correct itself — never a transport error. + #[tokio::test] + async fn loop_submit_error_is_wrapped_not_failed() { + let ingest = Arc::new(StubLoopIngest { + fail_with: Some("loop_submit_route is only valid during triage".into()), + ..Default::default() + }); + let listener = make_loop_listener(ingest); + let (mut client, mut server) = duplex(8 * 1024); + let server_task = tokio::spawn(async move { + listener.serve_one(&mut server).await.unwrap(); + }); + let msg = BrokerMessage::LoopSubmit(BrokerLoopSubmitRequest { + token: "cap-xyz".into(), + tool: "loop_submit_route".into(), + payload: json!({ "route": "full" }), + }); + write_frame(&mut client, &msg).await.unwrap(); + let resp: BrokerResponse = read_frame(&mut client).await.unwrap(); + server_task.await.unwrap(); + assert_eq!(resp.outcome["ok"], false); + assert!(resp.outcome["error"] + .as_str() + .unwrap() + .contains("only valid during triage")); + } + // --- check_user_feedback over the listener ----------------------------- use crate::acp::feedback::PendingFeedback; diff --git a/src-tauri/src/acp/delegation/tool_schema.json b/src-tauri/src/acp/delegation/tool_schema.json index 85b2d66e8c..0bf4616744 100644 --- a/src-tauri/src/acp/delegation/tool_schema.json +++ b/src-tauri/src/acp/delegation/tool_schema.json @@ -127,5 +127,200 @@ } } } + }, + { + "name": "loop_submit_route", + "description": "Loop engineering — TRIAGE stage only. Decide how this issue should flow through the loop and record it. Call exactly once after analyzing the issue. The route determines which downstream stages run.", + "inputSchema": { + "type": "object", + "required": ["route"], + "properties": { + "route": { + "type": "string", + "enum": ["full", "skip_design", "direct"], + "description": "full = requirements → design → tasks (use for non-trivial or design-bearing work); skip_design = requirements → tasks (clear scope, no design needed); direct = straight to a single task (a small, obvious change)." + }, + "priority": { + "type": "string", + "enum": ["high", "medium", "low"], + "description": "Optional re-prioritization of the issue based on what you learned during triage." + } + } + } + }, + { + "name": "loop_submit_artifacts", + "description": "Loop engineering — produce the artifacts for the current stage (REFINE → requirements, DESIGN → designs, PLAN → tasks). Submit the complete batch in ONE call; the kind is inferred from your stage. Each artifact is linked to the node you were briefed on. Idempotent: re-calling returns the same ids.", + "inputSchema": { + "type": "object", + "required": ["artifacts"], + "properties": { + "artifacts": { + "type": "array", + "minItems": 1, + "description": "One or more artifacts produced this stage.", + "items": { + "type": "object", + "required": ["title", "content"], + "properties": { + "title": { + "type": "string", + "description": "Short, specific title (e.g. a requirement statement or a task name)." + }, + "content": { + "type": "string", + "description": "The full artifact body in markdown — the requirement detail, the design, or the task's implementation brief." + }, + "criteria": { + "type": "array", + "items": { "type": "string" }, + "description": "Optional acceptance criteria — each a single verifiable statement." + } + } + } + } + } + } + }, + { + "name": "loop_submit_review", + "description": "Loop engineering — REVIEW stage only. Submit ONE structured check per acceptance-criterion handle from your briefing's checklist (each with a pass/fail verdict and concrete evidence), plus overall findings. Submit exactly once. The engine derives the gate decision from your per-criterion checks; a failing check feeds its evidence back to the implementer. Submit exactly one check per listed handle — no more, no fewer — and do NOT submit checks for the design obligations (they are context only).", + "inputSchema": { + "type": "object", + "required": ["checks"], + "properties": { + "checks": { + "type": "array", + "minItems": 1, + "description": "One entry per acceptance-criterion handle from the briefing checklist (e.g. R1.AC1, T1).", + "items": { + "type": "object", + "required": ["criterion", "verdict"], + "properties": { + "criterion": { + "type": "string", + "description": "The EXACT criterion handle from the briefing checklist, e.g. \"R1.AC1\" or \"T1\"." + }, + "verdict": { + "type": "string", + "enum": ["pass", "fail"], + "description": "pass = the implementation satisfies this criterion; fail = it does not." + }, + "evidence": { + "type": "string", + "description": "The specific code, behavior, or test that proves your verdict. REQUIRED for a fail (name the concrete defect)." + } + } + } + }, + "findings": { + "type": "string", + "description": "Overall markdown findings for the implementer. Be specific and actionable for any failing criterion so it can be fixed on the next attempt." + } + } + } + }, + { + "name": "loop_report_blocked", + "description": "Loop engineering — signal that you cannot make progress and need human intervention. Raises a blocked item in the issue's inbox. Use this instead of guessing when a hard external dependency, ambiguity you cannot resolve, or a missing prerequisite stops you.", + "inputSchema": { + "type": "object", + "required": ["reason"], + "properties": { + "reason": { + "type": "string", + "description": "What is blocking you and what a human would need to do to unblock it." + } + } + } + }, + { + "name": "loop_task_complete", + "description": "Loop engineering — during IMPLEMENT only: declare this task already complete with NO code change needed (its acceptance criteria are already satisfied — e.g. a dependency task already delivered the work). Call this INSTEAD of ending your turn with no edits, so the engine routes the task to review rather than treating the empty result as a stuck no-progress failure. Provide a concrete reason.", + "inputSchema": { + "type": "object", + "required": ["reason"], + "properties": { + "reason": { + "type": "string", + "description": "Why no change is needed (e.g. 'app/page.tsx already renders from task #12')." + } + } + } + }, + { + "name": "loop_record_memory", + "description": "Loop engineering — record a durable lesson for this space that future iterations should know. Use sparingly for genuinely reusable knowledge (a constraint discovered, a decision made, a pitfall to avoid), not per-task notes.", + "inputSchema": { + "type": "object", + "required": ["content"], + "properties": { + "kind": { + "type": "string", + "enum": ["constraint", "decision", "preference", "pitfall"], + "description": "constraint = a hard rule the code must obey; decision = a settled choice and its rationale; preference = a soft styling/approach preference; pitfall = a trap to avoid. Defaults to pitfall." + }, + "title": { + "type": "string", + "description": "Short title for the memory." + }, + "summary": { + "type": "string", + "description": "Optional one-line summary shown in future briefings' Memory index (the full content is read on demand)." + }, + "content": { + "type": "string", + "description": "The lesson, stated so a future agent can apply it without this context." + } + } + } + }, + { + "name": "loop_read_memory", + "description": "Loop engineering — batch-read the full content of memories from your briefing's Memory index by their [M{n}] handles. Pass as MANY handles as you judge relevant in a SINGLE call — you decide how many. Returns each memory's full text and metadata; handles not in your index are returned under not_found. Read-only: to record a new memory use loop_record_memory.", + "inputSchema": { + "type": "object", + "required": ["handles"], + "properties": { + "handles": { + "type": "array", + "items": { "type": "string" }, + "description": "One or more memory handles exactly as shown in the Memory index — batch as many as you want, e.g. [\"M1\",\"M4\",\"M7\"]." + } + } + } + }, + { + "name": "loop_submit_reflection", + "description": "Loop engineering — after an issue is complete, record your retrospective and distill durable memories for this space. Read-only otherwise: do not modify files. Call exactly once. Pass a `reflection` {title, content} and a `memories` array; each memory is {kind, title, summary, content, supersedes?}. kind is one of episodic, procedural, constraint, decision, preference, pitfall (NOT constitution). summary is the one-line shown in future Memory index entries. supersedes is an optional array of [M{n}] handles (from your Memory index) this memory makes obsolete; each handle may appear once across the whole submission. The memories array may be empty.", + "inputSchema": { + "type": "object", + "required": ["reflection"], + "properties": { + "reflection": { + "type": "object", + "required": ["title", "content"], + "properties": { + "title": { "type": "string" }, + "content": { "type": "string", "description": "Your retrospective on how this issue went." } + } + }, + "memories": { + "type": "array", + "description": "Durable memories to record (may be empty).", + "items": { + "type": "object", + "required": ["kind", "title", "content"], + "properties": { + "kind": { "type": "string", "enum": ["episodic", "procedural", "constraint", "decision", "preference", "pitfall"] }, + "title": { "type": "string" }, + "summary": { "type": "string", "description": "One line shown in future Memory index entries." }, + "content": { "type": "string" }, + "supersedes": { "type": "array", "items": { "type": "string" }, "description": "Optional [M{n}] handles from your Memory index that this memory obsoletes." } + } + } + } + } + } } ] diff --git a/src-tauri/src/acp/delegation/transport.rs b/src-tauri/src/acp/delegation/transport.rs index 7cdddfd625..ffd02bb832 100644 --- a/src-tauri/src/acp/delegation/transport.rs +++ b/src-tauri/src/acp/delegation/transport.rs @@ -165,6 +165,27 @@ pub struct BrokerAskRequest { pub questions: Vec, } +/// One loop-engineering submission forwarded from a loop-iteration companion to +/// the main process. Backs the five `loop_submit_*` / `loop_report_*` / +/// `loop_record_*` MCP tools. Unlike the delegation arms, this is NOT +/// authenticated through the [`super::listener::TokenRegistry`]: the `token` is a +/// per-iteration **capability token** the host reverse-looks-up in the database +/// (`loop_iteration.capability_token`) — the host trusts no ids the agent sends, +/// only the iteration the token resolves to. The listener hands the triple +/// straight to `loop_engine::ingest`, which owns all validation (unknown / non +/// running token, stage→tool allow-table, target ownership, idempotency). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BrokerLoopSubmitRequest { + /// The iteration's capability token (from `--capability-token`), NOT the + /// delegation launch token. + pub token: String, + /// The MCP tool name (`loop_submit_route`, `loop_submit_artifacts`, + /// `loop_submit_review`, `loop_report_blocked`, `loop_record_memory`). + pub tool: String, + /// Raw `arguments` JSON from the `tools/call`; `ingest` parses per tool. + pub payload: Value, +} + /// Tagged top-level message dispatched by the listener. Adding new variants /// is the wire-stable way to grow the broker protocol without touching the /// frame layer. @@ -178,6 +199,7 @@ pub enum BrokerMessage { Feedback(BrokerFeedbackRequest), CommitFeedback(BrokerCommitFeedbackRequest), Ask(BrokerAskRequest), + LoopSubmit(BrokerLoopSubmitRequest), } /// The wrapped outcome the main process returns over the same socket. @@ -317,6 +339,18 @@ pub async fn client_ask_round_trip( message_round_trip(socket_path, &BrokerMessage::Ask(req.clone())).await } +/// Dispatch a loop submission and read back the host's `ingest` outcome. The +/// host always replies with a `{ "ok": bool, .. }` envelope: `ok=true` carries +/// the persisted result under `result`, `ok=false` carries an agent-actionable +/// message under `error`. Transport-level failures surface as `io::Error` and +/// are rendered as a broker round-trip error to the agent. +pub async fn client_loop_submit_round_trip( + socket_path: &str, + req: &BrokerLoopSubmitRequest, +) -> io::Result { + message_round_trip(socket_path, &BrokerMessage::LoopSubmit(req.clone())).await +} + /// Total budget for `open()` retries on Windows named pipes. Has to be /// short enough that it nests comfortably inside the companion's /// `BROKER_CANCEL_BUDGET` (500 ms) — leaving ≥ 300 ms for the actual @@ -441,6 +475,26 @@ mod tests { } } + #[tokio::test] + async fn loop_submit_message_round_trip_in_memory() { + let (mut a, mut b) = duplex(8 * 1024); + let msg = BrokerMessage::LoopSubmit(BrokerLoopSubmitRequest { + token: "cap-tok".into(), + tool: "loop_submit_artifacts".into(), + payload: json!({ "artifacts": [{ "title": "Req", "content": "body" }] }), + }); + write_frame(&mut a, &msg).await.unwrap(); + let got: BrokerMessage = read_frame(&mut b).await.unwrap(); + match got { + BrokerMessage::LoopSubmit(req) => { + assert_eq!(req.token, "cap-tok"); + assert_eq!(req.tool, "loop_submit_artifacts"); + assert_eq!(req.payload["artifacts"][0]["title"], "Req"); + } + other => panic!("expected LoopSubmit variant, got {other:?}"), + } + } + #[tokio::test] async fn rejects_oversized_frame() { let (mut a, mut b) = duplex(8); diff --git a/src-tauri/src/acp/error.rs b/src-tauri/src/acp/error.rs index 00e88b7338..58114b3f78 100644 --- a/src-tauri/src/acp/error.rs +++ b/src-tauri/src/acp/error.rs @@ -44,6 +44,8 @@ pub enum AcpError { SdkNotInstalled(String), #[error("Agent did not respond to Initialize within 60 seconds. The cached binary may be outdated or incompatible. Try upgrading it from Agent Settings.")] InitializeTimeout, + #[error("Agent did not answer the session handshake (session/new or session/load) within 60 seconds. The agent may be stuck starting up, awaiting auth, or not ACP-compliant — try again or check the agent binary.")] + SessionStartTimeout, #[error("Agent did not publish its configurable options within 60 seconds. The probe was aborted; the agent may be slow, idle, or not ACP-compliant — try again or check the agent binary.")] ProbeTimedOut, } @@ -74,6 +76,7 @@ impl AcpError { Self::SdkNotInstalled(_) => Some("sdk_not_installed"), Self::PlatformNotSupported(_) => Some("platform_not_supported"), Self::InitializeTimeout => Some("initialize_timeout"), + Self::SessionStartTimeout => Some("session_start_timeout"), Self::ProbeTimedOut => Some("probe_timed_out"), Self::ProcessExited => Some("process_exited"), Self::TurnInProgress => Some("turn_in_progress"), diff --git a/src-tauri/src/acp/manager.rs b/src-tauri/src/acp/manager.rs index edec076ded..bec1541524 100644 --- a/src-tauri/src/acp/manager.rs +++ b/src-tauri/src/acp/manager.rs @@ -146,6 +146,20 @@ async fn wait_for_session_started( (outcome, start.elapsed()) } +/// Liveness of the agent turn backing a loop iteration's conversation. Used by +/// the driver's reconcile to settle finished-but-unsettled iterations without +/// disturbing genuinely in-flight ones. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TurnLiveness { + /// No live connection bound to this conversation (process gone / never bound). + Missing, + /// Connection alive but no turn in flight — the turn finished (its settle + /// event may have been dropped) and the agent is idle. + Idle, + /// A turn is actively in flight (the agent is working). + InFlight, +} + pub struct ConnectionManager { pub(crate) connections: Arc>>, /// Per-(agent, working_dir, session_id) async mutex. Held across the @@ -345,6 +359,11 @@ impl ConnectionManager { emitter: EventEmitter, preferred_mode_id: Option, preferred_config_values: BTreeMap, + // Per-iteration loop capability token. `Some` only on the loop-engine + // dispatch path (see `loop_engine::dispatch`); it is threaded straight + // through to `inject_codeg_mcp`, turning on the companion's loop tools + // for this one connection. `None` for every ordinary session. + loop_capability_token: Option, ) -> Result { // Connection dedup: when resuming an agent session (session_id is // Some), look for a live AgentConnection that already represents @@ -416,6 +435,7 @@ impl ConnectionManager { preferred_mode_id, preferred_config_values, self.delegation_snapshot(), + loop_capability_token, ) .await?; @@ -1493,6 +1513,7 @@ impl ConnectionManager { EventEmitter::Noop, None, BTreeMap::new(), + None, // not a loop iteration ) .await?; @@ -2082,6 +2103,26 @@ impl ConnectionManager { None } + /// Three-state liveness of the connection bound to `conversation_id`. The + /// driver's reconcile uses this to settle a finished-but-unsettled iteration + /// (its `TurnComplete` event was dropped/raced) without disturbing one whose + /// turn is genuinely in flight. Mirrors `find_connection_by_conversation_id`'s + /// lookup; the `read().await` avoids the `try_read`-skip false negative. + pub async fn connection_turn_state(&self, conversation_id: i32) -> TurnLiveness { + let connections = self.connections.lock().await; + for (_id, conn) in connections.iter() { + let state = conn.state.read().await; + if state.conversation_id == Some(conversation_id) { + return if state.turn_in_flight { + TurnLiveness::InFlight + } else { + TurnLiveness::Idle + }; + } + } + TurnLiveness::Missing + } + /// The in-flight user prompt for `conversation_id` and the instant its turn /// started, if a turn is currently running on its live connection. `Some` /// exactly between `UserMessage` and `TurnComplete` (see @@ -2226,6 +2267,7 @@ impl crate::acp::delegation::spawner::ConnectionSpawner for ConnectionManagerSpa emitter, preferred_mode_id, preferred_config_values, + None, // delegation children are not loop iterations ) .await .map_err(|e| SpawnerError::Spawn(e.to_string())) diff --git a/src-tauri/src/app_state.rs b/src-tauri/src/app_state.rs index 67eabca266..91cffea088 100644 --- a/src-tauri/src/app_state.rs +++ b/src-tauri/src/app_state.rs @@ -64,6 +64,10 @@ pub struct AppState { /// The upgrade UI subscribes to it and re-syncs from a snapshot on mount, /// so download progress survives settings-page navigation and reloads. pub update_state: crate::update::AppUpdateStateHandle, + /// Loop engineering engine. One instance per process; in desktop mode the + /// same `Arc` is also `app.manage`d so Tauri commands and HTTP handlers + /// drive identical drivers. + pub loop_engine: Arc, } pub fn default_system_op_lock() -> Arc> { @@ -193,6 +197,15 @@ impl AppState { question_config, ) = build_delegation_stack(&connection_manager, db.conn.clone(), data_dir.clone()); + let loop_engine = crate::loop_engine::LoopEngine::new( + crate::db::AppDatabase { + conn: db.conn.clone(), + }, + connection_manager.clone_ref(), + data_dir.clone(), + emitter.clone(), + ); + Self { db, connection_manager, @@ -216,6 +229,7 @@ impl AppState { question_config, system_op_lock: default_system_op_lock(), update_state: default_update_state(), + loop_engine, } } } diff --git a/src-tauri/src/bin/codeg_mcp.rs b/src-tauri/src/bin/codeg_mcp.rs index 86a5562b2f..e808d739b0 100644 --- a/src-tauri/src/bin/codeg_mcp.rs +++ b/src-tauri/src/bin/codeg_mcp.rs @@ -50,6 +50,10 @@ struct Args { /// by parents that predate feature gating; see `CompanionFeatures::parse` /// (defaults to delegation-only). features: Option, + /// Per-iteration loop capability token. Present ONLY for a loop-iteration + /// launch (`--features ...,loop`); the `loop_submit_*` tools send it so the + /// host can reverse-look-up the iteration. Omitted for ordinary launches. + capability_token: Option, } fn parse_args() -> Result { @@ -58,6 +62,7 @@ fn parse_args() -> Result { let mut token = None; let mut parent_pid = None; let mut features = None; + let mut capability_token = None; let mut iter = std::env::args().skip(1); while let Some(arg) = iter.next() { @@ -95,9 +100,15 @@ fn parse_args() -> Result { .ok_or_else(|| "--features requires a value".to_string())?, ); } + "--capability-token" => { + capability_token = Some( + iter.next() + .ok_or_else(|| "--capability-token requires a value".to_string())?, + ); + } "--help" | "-h" => { println!( - "codeg-mcp --parent-connection-id --socket-path --token [--parent-pid ] [--features delegation,feedback]" + "codeg-mcp --parent-connection-id --socket-path --token [--parent-pid ] [--features delegation,feedback,ask,loop] [--capability-token ]" ); std::process::exit(0); } @@ -111,6 +122,7 @@ fn parse_args() -> Result { token: token.ok_or_else(|| "missing --token".to_string())?, parent_pid, features, + capability_token, }) } @@ -144,6 +156,7 @@ async fn main() -> ExitCode { socket_path: args.socket_path, token: args.token, features: CompanionFeatures::parse(args.features.as_deref()), + capability_token: args.capability_token, }; let stdin = tokio::io::stdin(); diff --git a/src-tauri/src/bin/codeg_server.rs b/src-tauri/src/bin/codeg_server.rs index c5c8ef2d7e..27a2ad7086 100644 --- a/src-tauri/src/bin/codeg_server.rs +++ b/src-tauri/src/bin/codeg_server.rs @@ -38,6 +38,10 @@ fn main() { return; } + // Structured logging (§2.10a). After the early-exit fast paths so + // `--version` output stays clean; idempotent `try_init`. + codeg_lib::observability::init_tracing(); + // PATH initialisation MUST happen before the tokio runtime is created. // std::env::set_var is not thread-safe (unsafe in Rust edition 2024); // #[tokio::main] would spawn worker threads before we reach this point. @@ -238,6 +242,14 @@ async fn async_main() { db.conn.clone(), data_dir.clone(), ); + let loop_engine = codeg_lib::loop_engine::LoopEngine::new( + codeg_lib::db::AppDatabase { + conn: db.conn.clone(), + }, + connection_manager.clone_ref(), + data_dir.clone(), + emitter.clone(), + ); let state = Arc::new(AppState { db, connection_manager, @@ -259,6 +271,7 @@ async fn async_main() { question_config: question_config.clone(), system_op_lock: codeg_lib::app_state::default_system_op_lock(), update_state: codeg_lib::app_state::default_update_state(), + loop_engine: loop_engine.clone(), }); // Apply persisted delegation settings (depth, enabled) before @@ -298,6 +311,9 @@ async fn async_main() { Arc::new(codeg_lib::acp::manager::ConnectionManagerQuestionLookup { manager: Arc::new(state.connection_manager.clone_ref()), }), + Arc::new(codeg_lib::loop_engine::ingest::DbLoopIngest { + conn: state.db.conn.clone(), + }), ); let socket = delegation_socket_path.clone(); tokio::spawn(async move { @@ -307,6 +323,20 @@ async fn async_main() { }); } + // Reconcile interrupted loop iterations and restart drivers for every + // still-running issue, then supervise drivers forever (respawn any that die + // so a running issue never silently stalls). Idempotent. + { + let engine = loop_engine.clone(); + tokio::spawn(async move { + engine.recover_on_boot().await; + engine.supervisor_task().await; + }); + } + // React to loop iteration turn-completions via the in-process event bus + // (additive subscriber; never touches the delegation lifecycle path). + tokio::spawn(loop_engine.completion_watcher_task(state.acp_event_bus.clone())); + // Install bundled expert skills into the central store // (`~/.codeg/skills/`). Runs in the background; failures are logged // but non-fatal. diff --git a/src-tauri/src/chat_channel/session_commands.rs b/src-tauri/src/chat_channel/session_commands.rs index 8bf33781a6..6638a1311d 100644 --- a/src-tauri/src/chat_channel/session_commands.rs +++ b/src-tauri/src/chat_channel/session_commands.rs @@ -310,6 +310,7 @@ pub async fn handle_task( emitter.clone(), None, BTreeMap::new(), + None, // not a loop iteration ) .await { @@ -491,6 +492,7 @@ pub async fn handle_resume( emitter.clone(), None, BTreeMap::new(), + None, // not a loop iteration ) .await { diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index b6c07b8e56..96c544a527 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -4012,6 +4012,7 @@ pub async fn acp_connect( emitter, preferred_mode_id, preferred_config_values.unwrap_or_default(), + None, // not a loop iteration ) .await } diff --git a/src-tauri/src/commands/conversations.rs b/src-tauri/src/commands/conversations.rs index 94d76d7d9f..29a6bda75e 100644 --- a/src-tauri/src/commands/conversations.rs +++ b/src-tauri/src/commands/conversations.rs @@ -839,12 +839,15 @@ pub(crate) async fn emit_conversation_upsert( ) { match conversation_service::get_by_id(conn, conversation_id).await { Ok(summary) => { - // Sidebar shows ROOT conversations only — never broadcast a - // delegation child. The frontend also filters `parent_id != null`; - // this is the backend half of that invariant, so callers on agent - // paths (e.g. SessionStarted) can hand us any id without leaking - // child rows into every client's list. - if summary.parent_id.is_some() { + // Sidebar shows ROOT, non-loop conversations only — never broadcast a + // delegation child or a loop-engine iteration. The frontend also + // filters `parent_id != null` and `kind === "loop"`; this is the + // backend half of that invariant, so callers on agent paths (e.g. + // SessionStarted) can hand us any id without leaking hidden rows into + // every client's list. + if summary.parent_id.is_some() + || summary.kind == crate::db::entities::conversation::ConversationKind::Loop + { return; } emit_event( @@ -3082,4 +3085,27 @@ mod tests { "delegation child must not broadcast a sidebar upsert" ); } + + #[tokio::test] + async fn emit_conversation_upsert_skips_loop_iteration() { + // Loop-engine iterations (kind = loop) are never sidebar rows. + let db = fresh_in_memory_db().await; + let folder_id = seed_folder(&db, "/tmp/codeg-sync-loop-skip").await; + let conv = conversation_service::create_loop( + &db.conn, + folder_id, + AgentType::ClaudeCode, + Some("loop run".into()), + None, + ) + .await + .expect("loop conv"); + let (broadcaster, emitter) = sync_test_emitter(); + let mut rx = broadcaster.subscribe(); + emit_conversation_upsert(&emitter, &db.conn, conv.id).await; + assert!( + rx.try_recv().is_err(), + "loop iteration must not broadcast a sidebar upsert" + ); + } } diff --git a/src-tauri/src/commands/loops.rs b/src-tauri/src/commands/loops.rs new file mode 100644 index 0000000000..10dbe738a9 --- /dev/null +++ b/src-tauri/src/commands/loops.rs @@ -0,0 +1,1297 @@ +//! Loop engineering commands. `_core` functions hold the business logic shared +//! by the desktop (`#[tauri::command]`) and server (Axum handler) modes; every +//! successful write emits the coarse `loop://changed` event so all clients +//! refetch. M2.0 wires CRUD only — engine actions (trigger/pause/…) arrive in +//! M2.1+. + +use sea_orm::DatabaseConnection; + +use crate::app_error::AppCommandError; +use crate::db::entities::loop_artifact_revision::ActorKind; +use crate::db::entities::loop_inbox_item::{InboxKind, InboxStatus}; +use crate::db::entities::loop_issue::{self, IssuePriority, IssueStatus}; +use crate::db::entities::loop_iteration::Stage; +use crate::db::entities::loop_memory::{MemoryKind, MemoryStatus, TrustTier}; +use crate::db::service::folder_service; +use crate::db::service::loop_service::{ + artifact, inbox, issue, iteration, memory, space, validation, +}; +use crate::loop_engine::transitions::cas_issue_status; +use crate::loop_engine::worktree; +use std::path::Path; +use crate::models::loops::{ + IssueConfig, LoopArtifactDetail, LoopArtifactRow, LoopAttention, LoopChanged, LoopDagView, + LoopInboxItemRow, LoopIssueDetail, LoopIterationRow, LoopMemoryRow, LoopSpaceSummary, + LoopValidationRunRow, LOOP_CHANGED_EVENT, +}; +use crate::loop_engine::LoopEngine; +use crate::web::event_bridge::{emit_event, EventEmitter}; +use std::sync::Arc; + +#[cfg(feature = "tauri-runtime")] +use crate::db::AppDatabase; + +fn emit_loop_changed( + emitter: &EventEmitter, + space_id: i32, + issue_id: Option, + subject_kind: &str, + subject_id: i32, + kind: &str, +) { + emit_event( + emitter, + LOOP_CHANGED_EVENT, + LoopChanged { + v: 1, + space_id, + issue_id, + subject_kind: subject_kind.to_string(), + subject_id, + kind: kind.to_string(), + }, + ); +} + +async fn folder_is_git_repo(path: &str) -> bool { + tokio::process::Command::new("git") + .arg("-C") + .arg(path) + .arg("rev-parse") + .arg("--is-inside-work-tree") + .output() + .await + .map(|o| o.status.success()) + .unwrap_or(false) +} + +// ─── Spaces ────────────────────────────────────────────────────────────── + +pub async fn list_loop_spaces_core( + conn: &DatabaseConnection, +) -> Result, AppCommandError> { + Ok(space::list_spaces(conn).await?) +} + +pub async fn create_loop_space_core( + conn: &DatabaseConnection, + emitter: &EventEmitter, + name: String, + folder_id: i32, +) -> Result { + let folder = folder_service::get_folder_by_id(conn, folder_id) + .await? + .ok_or_else(|| AppCommandError::not_found("Folder not found"))?; + if !folder_is_git_repo(&folder.path).await { + return Err(AppCommandError::not_a_git_repository( + "Loop space folder must be a git repository", + )); + } + let created = space::create_space(conn, &name, folder_id).await?; + emit_loop_changed(emitter, created.id, None, "space", created.id, "created"); + summary_for(conn, created.id).await +} + +pub async fn update_loop_space_core( + conn: &DatabaseConnection, + emitter: &EventEmitter, + id: i32, + name: String, +) -> Result { + space::update_space(conn, id, &name).await?; + emit_loop_changed(emitter, id, None, "space", id, "updated"); + summary_for(conn, id).await +} + +pub async fn set_loop_space_default_config_core( + conn: &DatabaseConnection, + emitter: &EventEmitter, + id: i32, + config: IssueConfig, +) -> Result<(), AppCommandError> { + config + .validate() + .map_err(|m| AppCommandError::invalid_input(m.to_string()))?; + space::set_default_config(conn, id, &config).await?; + emit_loop_changed(emitter, id, None, "space", id, "updated"); + Ok(()) +} + +pub async fn delete_loop_space_core( + conn: &DatabaseConnection, + emitter: &EventEmitter, + id: i32, +) -> Result<(), AppCommandError> { + // Clean each issue's cross-subsystem artifacts (on-disk worktree, folder row, + // loop conversations) before the loop_* CASCADE removes the issues. + for issue_model in issue::list_models_for_space(conn, id).await? { + cleanup_issue_artifacts(conn, &issue_model).await; + } + space::delete_space(conn, id).await?; + emit_loop_changed(emitter, id, None, "space", id, "deleted"); + Ok(()) +} + +/// Best-effort cross-subsystem cleanup for a permanently deleted issue: remove +/// the on-disk git worktree, then its worktree `folder` row and `kind = loop` +/// conversations. The `loop_*` CASCADE never reaches the `folder`/`conversation` +/// tables. (Cancellation deliberately keeps these rows for audit — see +/// `LoopActions::cancel_issue`; deletion is permanent, so it removes them.) +/// No-op for an issue that never acquired a worktree. +async fn cleanup_issue_artifacts(conn: &DatabaseConnection, issue: &loop_issue::Model) { + let Some(worktree_folder_id) = issue.worktree_folder_id else { + return; + }; + // Resolve the repo once — needed both to remove the on-disk worktree and to + // drop the issue's engine-owned `loop/*` branch. + let repo_path = resolve_space_repo_path(conn, issue.space_id).await; + + if let Some(repo_path) = repo_path.as_deref() { + // On-disk worktree removal (best-effort; mirrors the cancel path). + if let Ok(Some(folder)) = folder_service::get_folder_by_id(conn, worktree_folder_id).await { + if Path::new(&folder.path).exists() { + if let Err(e) = + worktree::remove_worktree(Path::new(repo_path), Path::new(&folder.path)).await + { + eprintln!("[loop] delete: remove worktree {} failed: {e}", folder.path); + } + } + // Drop any per-task / integrate worktrees + their branches too + // (permanent delete discards everything by user intent). + let _ = + worktree::remove_issue_subtree(Path::new(repo_path), Path::new(&folder.path), true) + .await; + } + // Permanent delete discards everything → drop the branch too. Force (`-D`): + // it may carry unmerged WIP the user is intentionally deleting, and a DB + // reset leaves no other record by which to clean it up later. + let branch = format!("loop/{}/issue-{}", issue.space_id, issue.seq_no); + let _ = worktree::delete_branch(Path::new(repo_path), &branch, true).await; + } + + // DB orphans: the worktree folder row + its loop conversations. + if let Err(e) = issue::cleanup_worktree_rows(conn, worktree_folder_id).await { + eprintln!("[loop] delete: cleanup worktree rows ({worktree_folder_id}) failed: {e}"); + } +} + +/// The on-disk path of the git repo backing a space (the space folder's root), +/// or `None` if the space or its folder row is gone. +async fn resolve_space_repo_path(conn: &DatabaseConnection, space_id: i32) -> Option { + let space_row = space::get_space(conn, space_id).await.ok().flatten()?; + folder_service::get_folder_by_id(conn, space_row.folder_id) + .await + .ok() + .flatten() + .map(|f| f.path) +} + +async fn summary_for( + conn: &DatabaseConnection, + id: i32, +) -> Result { + space::list_spaces(conn) + .await? + .into_iter() + .find(|s| s.id == id) + .ok_or_else(|| AppCommandError::not_found("Loop space not found")) +} + +// ─── Issues ────────────────────────────────────────────────────────────── + +pub async fn list_loop_issues_core( + conn: &DatabaseConnection, + space_id: i32, + statuses: Option>, +) -> Result, AppCommandError> { + Ok(issue::list_issues(conn, space_id, statuses).await?) +} + +pub async fn get_loop_issue_core( + conn: &DatabaseConnection, + id: i32, +) -> Result, AppCommandError> { + Ok(issue::get_issue_detail(conn, id).await?) +} + +pub async fn create_loop_issue_core( + conn: &DatabaseConnection, + emitter: &EventEmitter, + space_id: i32, + title: String, + description: String, + priority: IssuePriority, + config: Option, +) -> Result { + // No explicit config → stored `config = NULL` → the issue inherits the space + // default (resolved at read time). An explicit config is validated and stored + // as the issue's own. + if let Some(c) = &config { + c.validate() + .map_err(|m| AppCommandError::invalid_input(m.to_string()))?; + } + let detail = + issue::create_issue(conn, space_id, &title, &description, priority, config.as_ref()).await?; + emit_loop_changed(emitter, space_id, Some(detail.row.id), "issue", detail.row.id, "created"); + Ok(detail) +} + +pub async fn delete_loop_issue_core( + conn: &DatabaseConnection, + emitter: &EventEmitter, + id: i32, +) -> Result<(), AppCommandError> { + let issue_model = issue::get_issue(conn, id).await?; + if let Some(ref m) = issue_model { + cleanup_issue_artifacts(conn, m).await; + } + issue::delete_issue(conn, id).await?; + if let Some(m) = issue_model { + emit_loop_changed(emitter, m.space_id, Some(id), "issue", id, "deleted"); + } + Ok(()) +} + +pub async fn update_loop_issue_config_core( + conn: &DatabaseConnection, + emitter: &EventEmitter, + id: i32, + config: Option, + token_budget: Option, +) -> Result<(), AppCommandError> { + // `None` → store NULL (inherit the space default); `Some` is validated. + if let Some(c) = &config { + c.validate() + .map_err(|m| AppCommandError::invalid_input(m.to_string()))?; + } + let space_id = issue::get_issue(conn, id).await?.map(|i| i.space_id); + issue::update_issue_config(conn, id, config.as_ref(), token_budget).await?; + if let Some(space_id) = space_id { + emit_loop_changed(emitter, space_id, Some(id), "issue", id, "updated"); + } + Ok(()) +} + +// ─── Engine actions (trigger / pause / resume / cancel) ───────────────────── + +pub async fn trigger_loop_issue_core( + conn: &DatabaseConnection, + emitter: &EventEmitter, + engine: &Arc, + id: i32, +) -> Result<(), AppCommandError> { + let issue = issue::get_issue(conn, id) + .await? + .ok_or_else(|| AppCommandError::not_found("Issue not found"))?; + engine.trigger_issue(id).await?; + emit_loop_changed(emitter, issue.space_id, Some(id), "issue", id, "triggered"); + Ok(()) +} + +pub async fn pause_loop_issue_core( + conn: &DatabaseConnection, + emitter: &EventEmitter, + engine: &Arc, + id: i32, +) -> Result<(), AppCommandError> { + let issue = issue::get_issue(conn, id) + .await? + .ok_or_else(|| AppCommandError::not_found("Issue not found"))?; + engine.pause_issue(id).await?; + emit_loop_changed(emitter, issue.space_id, Some(id), "issue", id, "paused"); + Ok(()) +} + +pub async fn resume_loop_issue_core( + conn: &DatabaseConnection, + emitter: &EventEmitter, + engine: &Arc, + id: i32, +) -> Result<(), AppCommandError> { + let issue = issue::get_issue(conn, id) + .await? + .ok_or_else(|| AppCommandError::not_found("Issue not found"))?; + engine.resume_issue(id).await?; + emit_loop_changed(emitter, issue.space_id, Some(id), "issue", id, "resumed"); + Ok(()) +} + +pub async fn cancel_loop_issue_core( + conn: &DatabaseConnection, + emitter: &EventEmitter, + engine: &Arc, + id: i32, +) -> Result<(), AppCommandError> { + let issue = issue::get_issue(conn, id) + .await? + .ok_or_else(|| AppCommandError::not_found("Issue not found"))?; + engine.cancel_issue(id).await?; + emit_loop_changed(emitter, issue.space_id, Some(id), "issue", id, "cancelled"); + Ok(()) +} + +/// Retry a blocked issue (inbox escape hatch): the engine re-arms the blocked +/// tasks, marks the blocking cards handled, and resumes the issue — emitting the +/// change itself, so this wrapper is thin. +pub async fn retry_loop_issue_core( + _conn: &DatabaseConnection, + _emitter: &EventEmitter, + engine: &Arc, + id: i32, +) -> Result<(), AppCommandError> { + engine.retry_issue(id).await?; + Ok(()) +} + +/// D15: force-complete a blocked, empty-diff task as a no-op (the engine emits the +/// change). `task_id` is the task artifact id. +pub async fn force_complete_loop_task_core( + _conn: &DatabaseConnection, + _emitter: &EventEmitter, + engine: &Arc, + task_id: i32, +) -> Result<(), AppCommandError> { + engine.force_complete_task(task_id).await?; + Ok(()) +} + +/// D17: override an oscillation breaker and re-arm the task (the engine emits the +/// change). `task_id` is the task artifact id. +pub async fn override_loop_oscillation_core( + _conn: &DatabaseConnection, + _emitter: &EventEmitter, + engine: &Arc, + task_id: i32, +) -> Result<(), AppCommandError> { + engine.override_oscillation(task_id).await?; + Ok(()) +} + +/// Add `additional` tokens to a budget-paused issue's budget and resume it (the +/// engine emits the change). +pub async fn add_loop_issue_budget_core( + _conn: &DatabaseConnection, + _emitter: &EventEmitter, + engine: &Arc, + id: i32, + additional: i64, +) -> Result<(), AppCommandError> { + engine.add_budget(id, additional).await?; + Ok(()) +} + +// ─── Merge gate (approve / reject the result) ─────────────────────────────── + +/// Approve a finalized issue's merge: the engine lands its loop branch on the +/// base branch (under a per-repo lock, with the stale-base check) and closes the +/// issue, or blocks it with an inbox card on any fault. The engine emits the +/// `loop://changed` event itself, covering both this path and auto-merge. +pub async fn approve_loop_merge_core( + _conn: &DatabaseConnection, + _emitter: &EventEmitter, + engine: &Arc, + id: i32, +) -> Result<(), AppCommandError> { + engine.merge_issue(id).await?; + Ok(()) +} + +/// Reject a finalized issue's merge: the work does not land. The issue is blocked +/// for human follow-up (cancel, or adjust and retrigger) with a card carrying the +/// reviewer's comment; any pending merge-approval card is marked handled. +pub async fn reject_loop_merge_core( + conn: &DatabaseConnection, + emitter: &EventEmitter, + engine: &Arc, + id: i32, + comment: Option, +) -> Result<(), AppCommandError> { + let issue = issue::get_issue(conn, id) + .await? + .ok_or_else(|| AppCommandError::not_found("Issue not found"))?; + // Clear a pending merge-approval card if the approval gate filed one. + let pending = inbox::list_inbox(conn, issue.space_id, Some(InboxStatus::Pending)).await?; + if let Some(card) = pending + .into_iter() + .find(|c| c.kind == InboxKind::Approval && c.subject_key == format!("merge:{id}")) + { + inbox::handle_inbox( + conn, + card.id, + serde_json::json!({ "action": "reject", "comment": comment }), + ) + .await?; + } + if !cas_issue_status(conn, id, IssueStatus::Running, IssueStatus::Blocked).await? { + return Err(crate::loop_engine::LoopError::Conflict.into()); + } + inbox::upsert_inbox( + conn, + issue.space_id, + id, + None, + InboxKind::Blocked, + &format!("merge_rejected:{id}"), + serde_json::json!({ "reason": "merge_rejected", "comment": comment }), + ) + .await?; + // Wake the parked driver so it re-ticks, sees the non-running status, and exits. + engine.wake(id).await; + emit_loop_changed(emitter, issue.space_id, Some(id), "issue", id, "merge_rejected"); + Ok(()) +} + +// ─── Design approval gate (route=full) ────────────────────────────────────── + +/// Approve the design gate: the engine marks the design done and advances the +/// issue to planning (and emits the change event). +pub async fn approve_loop_design_core( + _conn: &DatabaseConnection, + _emitter: &EventEmitter, + engine: &Arc, + id: i32, +) -> Result<(), AppCommandError> { + engine.approve_design(id).await?; + Ok(()) +} + +/// Reject the design gate with a comment: the engine supersedes the design and +/// re-runs design with the feedback (and emits the change event). +pub async fn reject_loop_design_core( + _conn: &DatabaseConnection, + _emitter: &EventEmitter, + engine: &Arc, + id: i32, + comment: Option, +) -> Result<(), AppCommandError> { + engine.reject_design(id, comment).await?; + Ok(()) +} + +// ─── Artifacts / DAG ─────────────────────────────────────────────────────── + +pub async fn get_loop_dag_core( + conn: &DatabaseConnection, + issue_id: i32, +) -> Result { + Ok(artifact::list_dag(conn, issue_id).await?) +} + +pub async fn list_loop_artifacts_core( + conn: &DatabaseConnection, + space_id: i32, +) -> Result, AppCommandError> { + Ok(artifact::list_artifacts_for_space(conn, space_id).await?) +} + +pub async fn get_loop_artifact_core( + conn: &DatabaseConnection, + id: i32, +) -> Result, AppCommandError> { + Ok(artifact::get_artifact_detail(conn, id).await?) +} + +// ─── Iterations ──────────────────────────────────────────────────────────── + +pub async fn list_loop_iterations_core( + conn: &DatabaseConnection, + space_id: i32, + issue_id: Option, +) -> Result, AppCommandError> { + Ok(match issue_id { + Some(issue_id) => iteration::list_iterations(conn, issue_id).await?, + None => iteration::list_iterations_for_space(conn, space_id).await?, + }) +} + +/// Targeted, bounded iteration history for one artifact (P3 drawer): the implement +/// + review attempts for a task, the producing run for a requirement/design/etc. +pub async fn get_loop_artifact_iterations_core( + conn: &DatabaseConnection, + artifact_id: i32, +) -> Result, AppCommandError> { + Ok(iteration::list_iterations_for_artifact(conn, artifact_id).await?) +} + +/// Phase-level (artifact-less) iteration history for an issue stage (P3 drawer): +/// the triage sessions behind an Issue node, the finalize sessions behind a Result +/// node. Enforces `target_artifact_id IS NULL` server-side. +pub async fn get_loop_phase_iterations_core( + conn: &DatabaseConnection, + issue_id: i32, + stage: Stage, +) -> Result, AppCommandError> { + Ok(iteration::list_iterations_for_phase(conn, issue_id, stage).await?) +} + +pub async fn list_loop_validations_core( + conn: &DatabaseConnection, + space_id: i32, +) -> Result, AppCommandError> { + Ok(validation::list_for_space(conn, space_id).await?) +} + +// ─── Inbox ───────────────────────────────────────────────────────────────── + +pub async fn list_loop_inbox_core( + conn: &DatabaseConnection, + space_id: i32, + status: Option, +) -> Result, AppCommandError> { + Ok(inbox::list_inbox(conn, space_id, status).await?) +} + +/// Cross-space pending-inbox attention rollup (D6/D7) — powers the always-visible +/// "who needs me" sidebar badge. No args: it aggregates every space at once. +pub async fn get_loop_attention_core( + conn: &DatabaseConnection, +) -> Result { + let per_space = inbox::aggregate_all(conn).await?; + let total_blocking = per_space.iter().map(|s| s.blocking).sum(); + let total_notice = per_space.iter().map(|s| s.notice).sum(); + Ok(LoopAttention { + total_blocking, + total_notice, + per_space, + }) +} + +/// Dismiss an informational inbox card (the reflect-exhausted notice, §4.4/D11): +/// mark it handled so it leaves the pending pane. Blocking cards (approval / +/// blocked / budget) are NOT dismissible here — they clear only via their gate +/// action — so a non-informational kind is rejected (the UI only offers Dismiss +/// on the informational card). Emits a `loop://changed` so other clients converge. +pub async fn dismiss_loop_inbox_core( + conn: &DatabaseConnection, + emitter: &EventEmitter, + id: i32, +) -> Result<(), AppCommandError> { + let item = inbox::get_inbox(conn, id) + .await? + .ok_or_else(|| AppCommandError::not_found(format!("inbox item {id}")))?; + if item.kind != InboxKind::ReflectionFailed { + return Err(AppCommandError::invalid_input( + "only informational inbox cards can be dismissed", + )); + } + inbox::handle_inbox(conn, id, serde_json::json!({ "action": "dismissed" })).await?; + emit_loop_changed( + emitter, + item.space_id, + Some(item.issue_id), + "issue", + item.issue_id, + "reflect_dismissed", + ); + Ok(()) +} + +// ─── Memory ──────────────────────────────────────────────────────────────── + +pub async fn list_loop_memory_core( + conn: &DatabaseConnection, + space_id: i32, +) -> Result, AppCommandError> { + Ok(memory::list_memory(conn, space_id).await?) +} + +pub async fn create_loop_memory_core( + conn: &DatabaseConnection, + emitter: &EventEmitter, + space_id: i32, + kind: MemoryKind, + title: String, + content: String, +) -> Result { + let m = memory::create_memory( + conn, + space_id, + kind, + ActorKind::Human, + &title, + None, + &content, + TrustTier::Human, + memory::MemoryProvenance::default(), + ) + .await?; + emit_loop_changed(emitter, space_id, None, "memory", m.id, "created"); + Ok(memory::to_row(m)) +} + +pub async fn update_loop_memory_core( + conn: &DatabaseConnection, + emitter: &EventEmitter, + space_id: i32, + id: i32, + title: String, + content: String, + status: MemoryStatus, +) -> Result<(), AppCommandError> { + memory::update_memory(conn, id, &title, &content, status).await?; + emit_loop_changed(emitter, space_id, None, "memory", id, "updated"); + Ok(()) +} + +pub async fn delete_loop_memory_core( + conn: &DatabaseConnection, + emitter: &EventEmitter, + space_id: i32, + id: i32, +) -> Result<(), AppCommandError> { + memory::delete_memory(conn, id).await?; + emit_loop_changed(emitter, space_id, None, "memory", id, "deleted"); + Ok(()) +} + +/// §2.10b engine health: DB-authoritative live counts + this process's +/// since-boot counters, for the workbench badge and ops. +pub async fn get_loop_engine_health_core( + engine: &Arc, +) -> Result { + Ok(engine.engine_health().await?) +} + +// ─── Tauri command wrappers (desktop) ────────────────────────────────────── + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn list_loop_spaces( + db: tauri::State<'_, AppDatabase>, +) -> Result, AppCommandError> { + list_loop_spaces_core(&db.conn).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn create_loop_space( + app: tauri::AppHandle, + db: tauri::State<'_, AppDatabase>, + name: String, + folder_id: i32, +) -> Result { + create_loop_space_core(&db.conn, &EventEmitter::Tauri(app), name, folder_id).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn update_loop_space( + app: tauri::AppHandle, + db: tauri::State<'_, AppDatabase>, + id: i32, + name: String, +) -> Result { + update_loop_space_core(&db.conn, &EventEmitter::Tauri(app), id, name).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn set_loop_space_default_config( + app: tauri::AppHandle, + db: tauri::State<'_, AppDatabase>, + id: i32, + config: IssueConfig, +) -> Result<(), AppCommandError> { + set_loop_space_default_config_core(&db.conn, &EventEmitter::Tauri(app), id, config).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn delete_loop_space( + app: tauri::AppHandle, + db: tauri::State<'_, AppDatabase>, + id: i32, +) -> Result<(), AppCommandError> { + delete_loop_space_core(&db.conn, &EventEmitter::Tauri(app), id).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn list_loop_issues( + db: tauri::State<'_, AppDatabase>, + space_id: i32, + statuses: Option>, +) -> Result, AppCommandError> { + list_loop_issues_core(&db.conn, space_id, statuses).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn get_loop_issue( + db: tauri::State<'_, AppDatabase>, + id: i32, +) -> Result, AppCommandError> { + get_loop_issue_core(&db.conn, id).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn create_loop_issue( + app: tauri::AppHandle, + db: tauri::State<'_, AppDatabase>, + space_id: i32, + title: String, + description: String, + priority: IssuePriority, + config: Option, +) -> Result { + create_loop_issue_core( + &db.conn, + &EventEmitter::Tauri(app), + space_id, + title, + description, + priority, + config, + ) + .await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn delete_loop_issue( + app: tauri::AppHandle, + db: tauri::State<'_, AppDatabase>, + id: i32, +) -> Result<(), AppCommandError> { + delete_loop_issue_core(&db.conn, &EventEmitter::Tauri(app), id).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn update_loop_issue_config( + app: tauri::AppHandle, + db: tauri::State<'_, AppDatabase>, + id: i32, + config: Option, + token_budget: Option, +) -> Result<(), AppCommandError> { + update_loop_issue_config_core(&db.conn, &EventEmitter::Tauri(app), id, config, token_budget) + .await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn trigger_loop_issue( + app: tauri::AppHandle, + db: tauri::State<'_, AppDatabase>, + engine: tauri::State<'_, Arc>, + id: i32, +) -> Result<(), AppCommandError> { + trigger_loop_issue_core(&db.conn, &EventEmitter::Tauri(app), engine.inner(), id).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn get_loop_engine_health( + engine: tauri::State<'_, Arc>, +) -> Result { + get_loop_engine_health_core(engine.inner()).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn pause_loop_issue( + app: tauri::AppHandle, + db: tauri::State<'_, AppDatabase>, + engine: tauri::State<'_, Arc>, + id: i32, +) -> Result<(), AppCommandError> { + pause_loop_issue_core(&db.conn, &EventEmitter::Tauri(app), engine.inner(), id).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn resume_loop_issue( + app: tauri::AppHandle, + db: tauri::State<'_, AppDatabase>, + engine: tauri::State<'_, Arc>, + id: i32, +) -> Result<(), AppCommandError> { + resume_loop_issue_core(&db.conn, &EventEmitter::Tauri(app), engine.inner(), id).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn cancel_loop_issue( + app: tauri::AppHandle, + db: tauri::State<'_, AppDatabase>, + engine: tauri::State<'_, Arc>, + id: i32, +) -> Result<(), AppCommandError> { + cancel_loop_issue_core(&db.conn, &EventEmitter::Tauri(app), engine.inner(), id).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn retry_loop_issue( + app: tauri::AppHandle, + db: tauri::State<'_, AppDatabase>, + engine: tauri::State<'_, Arc>, + id: i32, +) -> Result<(), AppCommandError> { + retry_loop_issue_core(&db.conn, &EventEmitter::Tauri(app), engine.inner(), id).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn force_complete_loop_task( + app: tauri::AppHandle, + db: tauri::State<'_, AppDatabase>, + engine: tauri::State<'_, Arc>, + task_id: i32, +) -> Result<(), AppCommandError> { + force_complete_loop_task_core(&db.conn, &EventEmitter::Tauri(app), engine.inner(), task_id).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn override_loop_oscillation( + app: tauri::AppHandle, + db: tauri::State<'_, AppDatabase>, + engine: tauri::State<'_, Arc>, + task_id: i32, +) -> Result<(), AppCommandError> { + override_loop_oscillation_core(&db.conn, &EventEmitter::Tauri(app), engine.inner(), task_id).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn add_loop_issue_budget( + app: tauri::AppHandle, + db: tauri::State<'_, AppDatabase>, + engine: tauri::State<'_, Arc>, + id: i32, + additional: i64, +) -> Result<(), AppCommandError> { + add_loop_issue_budget_core(&db.conn, &EventEmitter::Tauri(app), engine.inner(), id, additional) + .await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn approve_loop_merge( + app: tauri::AppHandle, + db: tauri::State<'_, AppDatabase>, + engine: tauri::State<'_, Arc>, + id: i32, +) -> Result<(), AppCommandError> { + approve_loop_merge_core(&db.conn, &EventEmitter::Tauri(app), engine.inner(), id).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn reject_loop_merge( + app: tauri::AppHandle, + db: tauri::State<'_, AppDatabase>, + engine: tauri::State<'_, Arc>, + id: i32, + comment: Option, +) -> Result<(), AppCommandError> { + reject_loop_merge_core(&db.conn, &EventEmitter::Tauri(app), engine.inner(), id, comment).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn approve_loop_design( + app: tauri::AppHandle, + db: tauri::State<'_, AppDatabase>, + engine: tauri::State<'_, Arc>, + id: i32, +) -> Result<(), AppCommandError> { + approve_loop_design_core(&db.conn, &EventEmitter::Tauri(app), engine.inner(), id).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn reject_loop_design( + app: tauri::AppHandle, + db: tauri::State<'_, AppDatabase>, + engine: tauri::State<'_, Arc>, + id: i32, + comment: Option, +) -> Result<(), AppCommandError> { + reject_loop_design_core(&db.conn, &EventEmitter::Tauri(app), engine.inner(), id, comment).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn get_loop_dag( + db: tauri::State<'_, AppDatabase>, + issue_id: i32, +) -> Result { + get_loop_dag_core(&db.conn, issue_id).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn list_loop_artifacts( + db: tauri::State<'_, AppDatabase>, + space_id: i32, +) -> Result, AppCommandError> { + list_loop_artifacts_core(&db.conn, space_id).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn get_loop_artifact( + db: tauri::State<'_, AppDatabase>, + id: i32, +) -> Result, AppCommandError> { + get_loop_artifact_core(&db.conn, id).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn list_loop_iterations( + db: tauri::State<'_, AppDatabase>, + space_id: i32, + issue_id: Option, +) -> Result, AppCommandError> { + list_loop_iterations_core(&db.conn, space_id, issue_id).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn get_loop_artifact_iterations( + db: tauri::State<'_, AppDatabase>, + artifact_id: i32, +) -> Result, AppCommandError> { + get_loop_artifact_iterations_core(&db.conn, artifact_id).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn get_loop_phase_iterations( + db: tauri::State<'_, AppDatabase>, + issue_id: i32, + stage: Stage, +) -> Result, AppCommandError> { + get_loop_phase_iterations_core(&db.conn, issue_id, stage).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn list_loop_validations( + db: tauri::State<'_, AppDatabase>, + space_id: i32, +) -> Result, AppCommandError> { + list_loop_validations_core(&db.conn, space_id).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn list_loop_inbox( + db: tauri::State<'_, AppDatabase>, + space_id: i32, + status: Option, +) -> Result, AppCommandError> { + list_loop_inbox_core(&db.conn, space_id, status).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn get_loop_attention( + db: tauri::State<'_, AppDatabase>, +) -> Result { + get_loop_attention_core(&db.conn).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn dismiss_loop_inbox( + app: tauri::AppHandle, + db: tauri::State<'_, AppDatabase>, + id: i32, +) -> Result<(), AppCommandError> { + dismiss_loop_inbox_core(&db.conn, &EventEmitter::Tauri(app), id).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn list_loop_memory( + db: tauri::State<'_, AppDatabase>, + space_id: i32, +) -> Result, AppCommandError> { + list_loop_memory_core(&db.conn, space_id).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn create_loop_memory( + app: tauri::AppHandle, + db: tauri::State<'_, AppDatabase>, + space_id: i32, + kind: MemoryKind, + title: String, + content: String, +) -> Result { + create_loop_memory_core(&db.conn, &EventEmitter::Tauri(app), space_id, kind, title, content) + .await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn update_loop_memory( + app: tauri::AppHandle, + db: tauri::State<'_, AppDatabase>, + space_id: i32, + id: i32, + title: String, + content: String, + status: MemoryStatus, +) -> Result<(), AppCommandError> { + update_loop_memory_core( + &db.conn, + &EventEmitter::Tauri(app), + space_id, + id, + title, + content, + status, + ) + .await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn delete_loop_memory( + app: tauri::AppHandle, + db: tauri::State<'_, AppDatabase>, + space_id: i32, + id: i32, +) -> Result<(), AppCommandError> { + delete_loop_memory_core(&db.conn, &EventEmitter::Tauri(app), space_id, id).await +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::entities::loop_issue; + use crate::db::test_helpers::{fresh_in_memory_db, seed_folder}; + use sea_orm::EntityTrait; + + async fn seed_space(db: &crate::db::AppDatabase) -> i32 { + let folder_id = seed_folder(db, "/tmp/loop-cmd").await; + space::create_space(&db.conn, "S", folder_id) + .await + .unwrap() + .id + } + + async fn issue_row(db: &crate::db::AppDatabase, id: i32) -> loop_issue::Model { + loop_issue::Entity::find_by_id(id) + .one(&db.conn) + .await + .unwrap() + .unwrap() + } + + #[tokio::test] + async fn create_without_config_inherits_space_default() { + let db = fresh_in_memory_db().await; + let space_id = seed_space(&db).await; + let detail = create_loop_issue_core( + &db.conn, + &EventEmitter::Noop, + space_id, + "Issue".into(), + "body".into(), + IssuePriority::Medium, + None, + ) + .await + .unwrap(); + // No explicit config → DTO `config` None and stored column NULL. + assert!(detail.config.is_none(), "no explicit config → inherits"); + assert!(issue_row(&db, detail.row.id).await.config.is_none()); + } + + #[tokio::test] + async fn create_with_config_is_custom() { + let db = fresh_in_memory_db().await; + let space_id = seed_space(&db).await; + let detail = create_loop_issue_core( + &db.conn, + &EventEmitter::Noop, + space_id, + "Issue".into(), + "body".into(), + IssuePriority::Medium, + Some(IssueConfig::default()), + ) + .await + .unwrap(); + assert!(detail.config.is_some(), "explicit config → custom"); + assert!(issue_row(&db, detail.row.id).await.config.is_some()); + } + + #[tokio::test] + async fn update_config_toggles_between_inherit_and_custom() { + let db = fresh_in_memory_db().await; + let space_id = seed_space(&db).await; + let detail = create_loop_issue_core( + &db.conn, + &EventEmitter::Noop, + space_id, + "Issue".into(), + "body".into(), + IssuePriority::Medium, + Some(IssueConfig { + max_attempts: 42, + ..IssueConfig::default() + }), + ) + .await + .unwrap(); + let id = detail.row.id; + + // Switch to inherit: the stored config becomes NULL (no preserved copy). + update_loop_issue_config_core(&db.conn, &EventEmitter::Noop, id, None, None) + .await + .unwrap(); + assert!( + issue_row(&db, id).await.config.is_none(), + "inherit → NULL config" + ); + + // Switch back to a custom config. + update_loop_issue_config_core( + &db.conn, + &EventEmitter::Noop, + id, + Some(IssueConfig { + max_attempts: 7, + ..IssueConfig::default() + }), + None, + ) + .await + .unwrap(); + let row = issue_row(&db, id).await; + let cfg: IssueConfig = serde_json::from_str(row.config.as_deref().unwrap()).unwrap(); + assert_eq!(cfg.max_attempts, 7); + } + + #[tokio::test] + async fn set_and_reset_space_default_config() { + let db = fresh_in_memory_db().await; + let space_id = seed_space(&db).await; + + set_loop_space_default_config_core( + &db.conn, + &EventEmitter::Noop, + space_id, + IssueConfig { + max_attempts: 13, + ..IssueConfig::default() + }, + ) + .await + .unwrap(); + let summary = summary_for(&db.conn, space_id).await.unwrap(); + assert_eq!(summary.default_config.max_attempts, 13); + + // "Reset" = store the engine default. + set_loop_space_default_config_core( + &db.conn, + &EventEmitter::Noop, + space_id, + IssueConfig::default(), + ) + .await + .unwrap(); + let summary = summary_for(&db.conn, space_id).await.unwrap(); + assert_eq!( + summary.default_config.max_attempts, + IssueConfig::default().max_attempts + ); + } + + #[tokio::test] + async fn delete_issue_cleans_worktree_folder_and_loop_conversations() { + use crate::db::entities::{conversation, folder, loop_artifact}; + use crate::db::service::conversation_service; + use sea_orm::{ActiveModelTrait, ColumnTrait, IntoActiveModel, QueryFilter, Set}; + + let db = fresh_in_memory_db().await; + let repo_folder_id = seed_folder(&db, "/tmp/loop-del-repo").await; + let space_id = space::create_space(&db.conn, "S", repo_folder_id) + .await + .unwrap() + .id; + let detail = create_loop_issue_core( + &db.conn, + &EventEmitter::Noop, + space_id, + "Issue".into(), + "body".into(), + IssuePriority::Medium, + None, + ) + .await + .unwrap(); + let issue_id = detail.row.id; + + // Simulate an engine worktree: a folder row + a loop conversation in it, + // bound to the issue. The path does not exist on disk, so the best-effort + // git-worktree removal is skipped and only the DB cleanup is exercised. + let wt_id = folder_service::add_loop_worktree_folder( + &db.conn, + "/tmp/loop-del-repo/.codeg/wt-issue", + repo_folder_id, + ) + .await + .unwrap() + .id; + let convo = conversation_service::create_loop( + &db.conn, + wt_id, + crate::models::agent::AgentType::ClaudeCode, + Some("iter".into()), + None, + ) + .await + .unwrap(); + let mut active = issue_row(&db, issue_id).await.into_active_model(); + active.worktree_folder_id = Set(Some(wt_id)); + active.update(&db.conn).await.unwrap(); + + delete_loop_issue_core(&db.conn, &EventEmitter::Noop, issue_id) + .await + .unwrap(); + + // Issue + its loop_* rows are gone (CASCADE); the worktree folder row and + // its loop conversation — which CASCADE does NOT reach — are gone too. + assert!(loop_issue::Entity::find_by_id(issue_id) + .one(&db.conn) + .await + .unwrap() + .is_none()); + assert!( + folder::Entity::find_by_id(wt_id) + .one(&db.conn) + .await + .unwrap() + .is_none(), + "worktree folder row removed" + ); + assert!( + conversation::Entity::find_by_id(convo.id) + .one(&db.conn) + .await + .unwrap() + .is_none(), + "loop conversation removed" + ); + assert!( + loop_artifact::Entity::find() + .filter(loop_artifact::Column::IssueId.eq(issue_id)) + .all(&db.conn) + .await + .unwrap() + .is_empty(), + "loop_* rows CASCADE-removed" + ); + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 0b30309dcd..3403c37fc3 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -11,6 +11,7 @@ pub mod feedback; pub mod file_io; pub mod folder_commands; pub mod folders; +pub mod loops; pub mod mcp; pub mod model_provider; #[cfg(feature = "tauri-runtime")] diff --git a/src-tauri/src/db/entities/folder.rs b/src-tauri/src/db/entities/folder.rs index a9498fa8c5..65f45c5893 100644 --- a/src-tauri/src/db/entities/folder.rs +++ b/src-tauri/src/db/entities/folder.rs @@ -4,8 +4,10 @@ use serde::{Deserialize, Serialize}; /// Folder classification. `regular` folders are user-facing; `chat` folders /// are hidden per-conversation scratch dirs backing folderless chat mode /// (excluded from folder lists; their conversations route to the sidebar -/// "Chat" group). A `loop_worktree` variant is reserved for M2+ engine-created -/// worktrees — add it then. Written once at insert, never updated. +/// "Chat" group). `loop_worktree` folders back per-issue engine worktrees: like +/// `chat` they are hidden from the user-facing folder lists, but their path is a +/// real git worktree the loop engine drives. Written once at insert, never +/// updated. #[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] #[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")] #[serde(rename_all = "snake_case")] @@ -14,6 +16,8 @@ pub enum FolderKind { Regular, #[sea_orm(string_value = "chat")] Chat, + #[sea_orm(string_value = "loop_worktree")] + LoopWorktree, } #[derive(Clone, Debug, PartialEq, DeriveEntityModel)] diff --git a/src-tauri/src/db/entities/loop_artifact.rs b/src-tauri/src/db/entities/loop_artifact.rs new file mode 100644 index 0000000000..82166e6146 --- /dev/null +++ b/src-tauri/src/db/entities/loop_artifact.rs @@ -0,0 +1,114 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +use super::loop_artifact_revision::ActorKind; + +/// DAG node kind = column in the per-issue lineage graph. +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] +#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")] +#[serde(rename_all = "snake_case")] +pub enum ArtifactKind { + #[sea_orm(string_value = "issue")] + Issue, + #[sea_orm(string_value = "requirement")] + Requirement, + #[sea_orm(string_value = "design")] + Design, + #[sea_orm(string_value = "task")] + Task, + #[sea_orm(string_value = "review")] + Review, + #[sea_orm(string_value = "result")] + Result, + /// Post-merge retrospective produced by the reflect stage; `derives_from` the + /// issue's result (else its root). At most one per issue (the durable memory + /// consolidation idempotency anchor, `uniq_reflection_per_issue`). See §4.4/P4. + #[sea_orm(string_value = "reflection")] + Reflection, +} + +/// Engine-driven node status (humans never hand-edit these except via gates). +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] +#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")] +#[serde(rename_all = "snake_case")] +pub enum ArtifactStatus { + #[sea_orm(string_value = "pending")] + Pending, + #[sea_orm(string_value = "in_progress")] + InProgress, + #[sea_orm(string_value = "awaiting_approval")] + AwaitingApproval, + #[sea_orm(string_value = "done")] + Done, + #[sea_orm(string_value = "blocked")] + Blocked, + #[sea_orm(string_value = "superseded")] + Superseded, + #[sea_orm(string_value = "cancelled")] + Cancelled, +} + +/// Verdict carried only by `kind = review` artifacts. +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] +#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")] +#[serde(rename_all = "snake_case")] +pub enum ReviewVerdict { + #[sea_orm(string_value = "pass")] + Pass, + #[sea_orm(string_value = "fail")] + Fail, +} + +/// Whether a Done task contributed a real diff (its frozen `fan_in_commit`) or was +/// an agent-declared no-op (already satisfied; `fan_in_commit IS NULL`). Only +/// meaningful for parallel fan-in participants (D12); serial tasks always record +/// `Delta` (the column is not read for serial issues). +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] +#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")] +#[serde(rename_all = "snake_case")] +pub enum ContributionKind { + #[sea_orm(string_value = "delta")] + Delta, + #[sea_orm(string_value = "no_op")] + NoOp, +} + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "loop_artifact")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub space_id: i32, + pub issue_id: i32, + pub kind: ArtifactKind, + pub title: String, + pub status: ArtifactStatus, + pub origin: ActorKind, + /// Iteration that produced this node (plain column, no FK — cycle break). + pub produced_by_iteration_id: Option, + /// Only set for `kind = review`. + pub verdict: Option, + /// Node-level rework counter (no-progress circuit breaker reads this). + pub attempt: i32, + pub last_failure_sig: Option, + /// Frozen integration commit SHA, recorded atomically when a `task` turns + /// `Done` (its accepted tip). The parallel result-stage fan-in merges these + /// SHAs, never live branch tips. `NULL` for non-task kinds / not-yet-done. + pub fan_in_commit: Option, + pub sort: i32, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + /// D12: real-diff (`Delta`) vs agent-declared no-op (`NoOp`) for a Done task. + /// Defaults to `Delta`; `no_op ⇔ fan_in_commit IS NULL` (parallel fan-in). + pub contribution_kind: ContributionKind, + /// D14: oscillation breaker epoch counter (consecutive same-signature blocks). + pub oscillation_count: i32, + /// D14: the `block_sig` of the current oscillation epoch (NULL when not blocked + /// in an epoch). Stepped/reset together with `oscillation_count`. + pub recent_failure_sig: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src-tauri/src/db/entities/loop_artifact_revision.rs b/src-tauri/src/db/entities/loop_artifact_revision.rs new file mode 100644 index 0000000000..e04d93b00b --- /dev/null +++ b/src-tauri/src/db/entities/loop_artifact_revision.rs @@ -0,0 +1,35 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +/// Who authored a write — a human or an agent. Shared across +/// `loop_artifact.origin`, `loop_artifact_revision.actor_kind` and +/// `loop_memory.source`. (Distinct from `loop_iteration::LaunchedBy`.) +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] +#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")] +#[serde(rename_all = "snake_case")] +pub enum ActorKind { + #[sea_orm(string_value = "human")] + Human, + #[sea_orm(string_value = "agent")] + Agent, +} + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "loop_artifact_revision")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub artifact_id: i32, + pub seq: i32, + pub content: String, + pub actor_kind: ActorKind, + /// Iteration that produced this revision (plain column, no FK — breaks the + /// artifact↔iteration cycle). + pub iteration_id: Option, + pub created_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src-tauri/src/db/entities/loop_coverage.rs b/src-tauri/src/db/entities/loop_coverage.rs new file mode 100644 index 0000000000..6baf808920 --- /dev/null +++ b/src-tauri/src/db/entities/loop_coverage.rs @@ -0,0 +1,20 @@ +use sea_orm::entity::prelude::*; + +/// Criterion-level coverage: a task artifact claims it satisfies a given +/// (acceptance) criterion. The unit of traceability — the driver's bounded +/// replan loop-back fires whenever a requirement criterion has no covering task. +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "loop_coverage")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub space_id: i32, + pub task_artifact_id: i32, + pub criterion_id: i32, + pub created_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src-tauri/src/db/entities/loop_criterion.rs b/src-tauri/src/db/entities/loop_criterion.rs new file mode 100644 index 0000000000..2a4d6106d8 --- /dev/null +++ b/src-tauri/src/db/entities/loop_criterion.rs @@ -0,0 +1,40 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +/// Criterion category — the typed unit of traceability and gating. Requirements +/// and tasks carry only `acceptance` (verifiable outcomes a task must satisfy); +/// designs carry `constraint`/`invariant`/`obligation` (cross-cutting properties +/// the implementation must uphold, never dropped on the floor). ingest enforces +/// the per-artifact-kind allow-set. +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] +#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")] +#[serde(rename_all = "snake_case")] +pub enum CriterionKind { + #[sea_orm(string_value = "acceptance")] + Acceptance, + #[sea_orm(string_value = "constraint")] + Constraint, + #[sea_orm(string_value = "invariant")] + Invariant, + #[sea_orm(string_value = "obligation")] + Obligation, +} + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "loop_criterion")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + /// Owning artifact (design or task). Reviews judge these criteria. + pub artifact_id: i32, + /// Auto-assigned label like `AC-1`. + pub label: String, + pub text: String, + pub sort: i32, + pub kind: CriterionKind, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src-tauri/src/db/entities/loop_criterion_check.rs b/src-tauri/src/db/entities/loop_criterion_check.rs new file mode 100644 index 0000000000..5550755803 --- /dev/null +++ b/src-tauri/src/db/entities/loop_criterion_check.rs @@ -0,0 +1,39 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +/// One reviewer's structured pass/fail of one criterion (§3.4) — the unit the +/// gate aggregates. Scoped to the artifact judged: a task (per-task review) or +/// the result (integration review). Idempotent on `(criterion, iteration, scope)` +/// so a crash replay of a review submission never double-writes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] +#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")] +#[serde(rename_all = "snake_case")] +pub enum CheckVerdict { + #[sea_orm(string_value = "pass")] + Pass, + #[sea_orm(string_value = "fail")] + Fail, +} + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "loop_criterion_check")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub space_id: i32, + pub criterion_id: i32, + /// The review iteration that produced this check (its slot identifies the + /// reviewer for per-criterion quorum aggregation). + pub iteration_id: i32, + /// The artifact this check judged: a task, or the result for the integration + /// gate. + pub scope_artifact_id: i32, + pub verdict: CheckVerdict, + pub evidence: String, + pub created_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src-tauri/src/db/entities/loop_gate_decision.rs b/src-tauri/src/db/entities/loop_gate_decision.rs new file mode 100644 index 0000000000..30a7f09b04 --- /dev/null +++ b/src-tauri/src/db/entities/loop_gate_decision.rs @@ -0,0 +1,44 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +/// The aggregated outcome of a gate over one target at one attempt (§3.4). +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] +#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")] +#[serde(rename_all = "snake_case")] +pub enum GateOutcome { + #[sea_orm(string_value = "pass")] + Pass, + #[sea_orm(string_value = "fail")] + Fail, + #[sea_orm(string_value = "undecided")] + Undecided, +} + +/// Immutable gate-decision audit: which structured checks a gate aggregated and +/// the outcome it reached, at `(target, stage, attempt)`. `input_check_ids` is the +/// JSON id list it aggregated; `input_digest` fingerprints those inputs so a +/// racing recompute is detected (insert-or-compare) and a later check supersede +/// never rewrites a recorded decision. +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "loop_gate_decision")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub space_id: i32, + pub issue_id: i32, + pub target_artifact_id: i32, + /// The gate's stage label (e.g. `review` for a task gate, `finalize` for the + /// integration gate). + pub stage: String, + pub attempt: i32, + pub policy_json: String, + pub input_check_ids: String, + pub input_digest: String, + pub outcome: GateOutcome, + pub created_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src-tauri/src/db/entities/loop_inbox_item.rs b/src-tauri/src/db/entities/loop_inbox_item.rs new file mode 100644 index 0000000000..d91bc233b6 --- /dev/null +++ b/src-tauri/src/db/entities/loop_inbox_item.rs @@ -0,0 +1,60 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +/// Inbox category. Blocking ops = `approval` / `blocked` / `budget_exhausted`; +/// the second pane is `question` (an agent's AskUserQuestion). +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] +#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")] +#[serde(rename_all = "snake_case")] +pub enum InboxKind { + #[sea_orm(string_value = "approval")] + Approval, + #[sea_orm(string_value = "blocked")] + Blocked, + #[sea_orm(string_value = "budget_exhausted")] + BudgetExhausted, + #[sea_orm(string_value = "question")] + Question, + /// Informational (non-blocking): post-merge memory consolidation exhausted its + /// bounded retries without producing a reflection. A `Done` issue is never + /// mislabeled blocked; the card is a dismissible notice (§4.4/§5.3, P4/D11). + #[sea_orm(string_value = "reflection_failed")] + ReflectionFailed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] +#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")] +#[serde(rename_all = "snake_case")] +pub enum InboxStatus { + #[sea_orm(string_value = "pending")] + Pending, + #[sea_orm(string_value = "handled")] + Handled, +} + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "loop_inbox_item")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub space_id: i32, + pub issue_id: i32, + /// Set for `question` items (the asking iteration; plain column). + pub iteration_id: Option, + pub kind: InboxKind, + /// Stable dedupe key; a partial unique index forbids two pending items with + /// the same `(issue_id, kind, subject_key)`. + pub subject_key: String, + /// JSON payload (shape depends on `kind`). + pub payload: String, + pub status: InboxStatus, + /// JSON resolution recorded when handled. + pub resolution: Option, + pub created_at: DateTimeUtc, + pub handled_at: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src-tauri/src/db/entities/loop_issue.rs b/src-tauri/src/db/entities/loop_issue.rs new file mode 100644 index 0000000000..d61f4bbecd --- /dev/null +++ b/src-tauri/src/db/entities/loop_issue.rs @@ -0,0 +1,119 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +/// Human-set urgency hint; influences (but does not strictly order) which issues +/// the engine surfaces first. +#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] +#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")] +#[serde(rename_all = "snake_case")] +pub enum IssuePriority { + #[sea_orm(string_value = "high")] + High, + #[sea_orm(string_value = "medium")] + Medium, + #[sea_orm(string_value = "low")] + Low, +} + +/// Issue lifecycle. `pending` = created but not triggered (the explicit human +/// gate); `running` = driver active; `paused` = stopped dispatching (see +/// `pause_reason`); `blocked` = needs a human via the inbox; terminal `done` / +/// `cancelled`. +#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] +#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")] +#[serde(rename_all = "snake_case")] +pub enum IssueStatus { + #[sea_orm(string_value = "pending")] + Pending, + #[sea_orm(string_value = "running")] + Running, + #[sea_orm(string_value = "paused")] + Paused, + #[sea_orm(string_value = "blocked")] + Blocked, + #[sea_orm(string_value = "done")] + Done, + #[sea_orm(string_value = "cancelled")] + Cancelled, +} + +/// Distinguishes a manual pause from a budget circuit-breaker pause. Only +/// meaningful while `status = paused`. +#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] +#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")] +#[serde(rename_all = "snake_case")] +pub enum PauseReason { + #[sea_orm(string_value = "manual")] + Manual, + #[sea_orm(string_value = "budget")] + Budget, +} + +/// Pipeline route decided by triage (or forced via config). `full` runs +/// refine→design→plan; `skip_design` skips design; `direct` skips both refine +/// and design (issue→plan). `undecided` until triage runs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] +#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")] +#[serde(rename_all = "snake_case")] +pub enum IssueRoute { + #[sea_orm(string_value = "undecided")] + Undecided, + #[sea_orm(string_value = "full")] + Full, + #[sea_orm(string_value = "skip_design")] + SkipDesign, + #[sea_orm(string_value = "direct")] + Direct, +} + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "loop_issue")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub space_id: i32, + pub seq_no: i32, + pub title: String, + pub description: String, + pub priority: IssuePriority, + pub status: IssueStatus, + pub pause_reason: Option, + pub route: IssueRoute, + /// Whether this issue's tasks run concurrently. `serial` / `parallel`, + /// decided once from the task DAG after planning settles (see + /// `driver::dag_has_parallelism`); `NULL` until then. Write-once — never + /// recomputed, so a half-built DAG can't latch the wrong mode. + pub execution_mode: Option, + /// JSON-encoded `models::loops::IssueConfig`, or `NULL` to inherit the + /// space's `default_config`. The single source of truth for inheritance — + /// there is no separate flag. + pub config: Option, + /// Engine-created worktree folder (`folder.id`, plain column). + pub worktree_folder_id: Option, + /// Merge baseline recorded at trigger time. + pub base_branch: Option, + pub base_commit: Option, + /// JSON-encoded fan-in session lock — the write-once, versioned manifest + /// (`{v, issue_base_oid, ordered:[{task_id, sha}]}`) claimed for the parallel + /// result-stage integration. `NULL` = no active fan-in session. Distinct from + /// the `uniq_active_finalize` agent lease. + pub fan_in_manifest: Option, + /// The integrate worktree tip at which a fan-in conflict resolver was last + /// dispatched. Lets the result-stage fan-in tell a crash-before-dispatch + /// `MERGE_HEAD` (re-dispatch a resolver) from a resolver that ran and left the + /// merge unresolved at the same tip (block). Cleared with the manifest at + /// session end; advances naturally as resolved conflicts move the tip. + pub fan_in_resolver_tip: Option, + pub token_used: i64, + /// NULL = unlimited (no artificial budget cap by default). + pub token_budget: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + pub triggered_at: Option, + pub ended_at: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src-tauri/src/db/entities/loop_iteration.rs b/src-tauri/src/db/entities/loop_iteration.rs new file mode 100644 index 0000000000..75b9b75884 --- /dev/null +++ b/src-tauri/src/db/entities/loop_iteration.rs @@ -0,0 +1,132 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +/// What an iteration's agent run does. (Note: `verify` is NOT a stage — it is a +/// deterministic engine step run between implement and review.) +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] +#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")] +#[serde(rename_all = "snake_case")] +pub enum Stage { + #[sea_orm(string_value = "triage")] + Triage, + #[sea_orm(string_value = "refine")] + Refine, + #[sea_orm(string_value = "design")] + Design, + #[sea_orm(string_value = "plan")] + Plan, + #[sea_orm(string_value = "implement")] + Implement, + #[sea_orm(string_value = "review")] + Review, + #[sea_orm(string_value = "finalize")] + Finalize, + /// Post-merge memory consolidation: distill durable lessons into a reflection + /// artifact + space memories. Issue-level (`target = None`), runs on a `Done` + /// issue, best-effort (never rolls back the merge). See §4.4/P4. + #[sea_orm(string_value = "reflect")] + Reflect, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] +#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")] +#[serde(rename_all = "snake_case")] +pub enum IterationStatus { + #[sea_orm(string_value = "queued")] + Queued, + #[sea_orm(string_value = "running")] + Running, + #[sea_orm(string_value = "succeeded")] + Succeeded, + #[sea_orm(string_value = "failed")] + Failed, + #[sea_orm(string_value = "interrupted")] + Interrupted, + #[sea_orm(string_value = "cancelled")] + Cancelled, +} + +/// Why an iteration ended (D11). Settlement/checkpoint write it once; `outcome` +/// is then immutable (see `set_iteration_outcome`). A NULL outcome is legal — the +/// run is still in flight, or it is a settled implement run before its checkpoint. +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] +#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")] +#[serde(rename_all = "snake_case")] +pub enum IterationOutcome { + /// A read-stage run that produced its artifact, or a validated implement. + #[sea_orm(string_value = "succeeded")] + Succeeded, + /// An implement run whose checkpoint found no file changes. + #[sea_orm(string_value = "empty_diff")] + EmptyDiff, + /// An implement run whose deterministic validation failed. + #[sea_orm(string_value = "validation_failed")] + ValidationFailed, + /// Written only in Phase C (agent-declared completion); enumerated now so the + /// CHECK/UI need no Phase-C edit. + #[sea_orm(string_value = "declared_complete")] + DeclaredComplete, + /// A read-stage run that settled without producing its expected artifact. + #[sea_orm(string_value = "no_artifacts")] + NoArtifacts, + /// Cancelled / failed / interrupted before settling a real outcome. + #[sea_orm(string_value = "abandoned")] + Abandoned, +} + +/// Who launched the iteration. Engine-driven by default; `human` covers extra +/// turns a person injects while observing. (Distinct from `ActorKind`.) +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] +#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")] +#[serde(rename_all = "snake_case")] +pub enum LaunchedBy { + #[sea_orm(string_value = "engine")] + Engine, + #[sea_orm(string_value = "human")] + Human, +} + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "loop_iteration")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub space_id: i32, + pub issue_id: i32, + pub stage: Stage, + /// Node being advanced/reviewed (plain column, no FK — cycle break). + pub target_artifact_id: Option, + /// Review slot `[0, reviewer_count)`; NULL for non-review stages. + pub slot_no: Option, + /// Backing loop conversation (`conversation.id`, plain column). NULL between + /// lease acquisition and conversation creation. + pub conversation_id: Option, + /// Unique secret injected into codeg-mcp; the host reverse-looks-up this + /// iteration's context from it (never trusts agent-supplied ids). + pub capability_token: String, + pub status: IterationStatus, + pub launched_by: LaunchedBy, + pub attempt: i32, + pub tokens_used: i64, + /// `true` when settlement could not read the session file's token total and + /// left it uncharged; a backfill sweep re-reads and clears this. Never + /// charged as `0` against the budget while pending (§2.7). + pub tokens_pending: bool, + /// JSON-encoded briefing manifest (audit). + pub context_manifest: Option, + pub created_at: DateTimeUtc, + pub started_at: Option, + pub ended_at: Option, + /// Why the run ended (D11). Write-once via `set_iteration_outcome`; NULL while + /// in flight or for a settled implement run awaiting its checkpoint. + pub outcome: Option, + /// D12: the implement agent's free-text reason when it declares the task already + /// satisfied (`loop_task_complete`), else NULL. Truncated at the write; read by + /// `finish_implement` (route to review) and the review briefing. + pub agent_completion_reason: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src-tauri/src/db/entities/loop_link.rs b/src-tauri/src/db/entities/loop_link.rs new file mode 100644 index 0000000000..b3fdd5c0ec --- /dev/null +++ b/src-tauri/src/db/entities/loop_link.rs @@ -0,0 +1,45 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +/// DAG edge kind. Canonical direction: `from` = the dependent node +/// (derived/review/result/successor), `to` = the referenced node (its source/ +/// parent/subject/predecessor). So `derives_from`: child→parent; `skips_to`: +/// reached-node→skipped-over ancestor; `reviews`: review→task; `depends_on`: +/// successor task→predecessor task; `results_from`: result→task. +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] +#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")] +#[serde(rename_all = "snake_case")] +pub enum LinkKind { + #[sea_orm(string_value = "derives_from")] + DerivesFrom, + #[sea_orm(string_value = "skips_to")] + SkipsTo, + #[sea_orm(string_value = "reviews")] + Reviews, + #[sea_orm(string_value = "depends_on")] + DependsOn, + #[sea_orm(string_value = "results_from")] + ResultsFrom, +} + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "loop_link")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub space_id: i32, + pub from_artifact_id: i32, + pub to_artifact_id: i32, + pub kind: LinkKind, + /// For `derives_from` edges from a design to a requirement: the requirement + /// revision this design was derived from — a content snapshot binding, so a + /// later requirement edit is detectable as a stale lineage. `None` for edges + /// that don't snapshot a source (task deps, reviews, results). + pub source_revision_id: Option, + pub created_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src-tauri/src/db/entities/loop_memory.rs b/src-tauri/src/db/entities/loop_memory.rs new file mode 100644 index 0000000000..940e0c69b7 --- /dev/null +++ b/src-tauri/src/db/entities/loop_memory.rs @@ -0,0 +1,83 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +use super::loop_artifact_revision::ActorKind; + +/// Memory category. `constitution` carries the space-level charter; the rest are +/// learnings injected per stage (see the briefing matrix). +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] +#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")] +#[serde(rename_all = "snake_case")] +pub enum MemoryKind { + #[sea_orm(string_value = "constitution")] + Constitution, + #[sea_orm(string_value = "constraint")] + Constraint, + #[sea_orm(string_value = "decision")] + Decision, + #[sea_orm(string_value = "preference")] + Preference, + #[sea_orm(string_value = "pitfall")] + Pitfall, + /// CoALA episodic layer: a note about what happened on a specific issue. + /// Reflect-authored only (humans curate the five semantic kinds above). + #[sea_orm(string_value = "episodic")] + Episodic, + /// CoALA procedural layer: a reusable recipe distilled from how an issue was + /// solved. Reflect-authored only. + #[sea_orm(string_value = "procedural")] + Procedural, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] +#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")] +#[serde(rename_all = "snake_case")] +pub enum MemoryStatus { + #[sea_orm(string_value = "active")] + Active, + #[sea_orm(string_value = "archived")] + Archived, + #[sea_orm(string_value = "superseded")] + Superseded, +} + +/// How much a memory is trusted: `human`-authored, `distilled` by the reflect +/// stage from confirmed work, or `proposed` (agent-recorded, unvetted). Shown in +/// the briefing index for the agent's judgment — never used to rank or filter. +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] +#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")] +#[serde(rename_all = "snake_case")] +pub enum TrustTier { + #[sea_orm(string_value = "human")] + Human, + #[sea_orm(string_value = "distilled")] + Distilled, + #[sea_orm(string_value = "proposed")] + Proposed, +} + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "loop_memory")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub space_id: i32, + pub kind: MemoryKind, + pub source: ActorKind, + pub title: String, + pub summary: Option, + pub content: String, + pub trust_tier: TrustTier, + pub status: MemoryStatus, + pub superseded_by: Option, + pub source_issue_id: Option, + pub source_artifact_id: Option, + pub produced_by_iteration_id: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src-tauri/src/db/entities/loop_space.rs b/src-tauri/src/db/entities/loop_space.rs new file mode 100644 index 0000000000..f5d15513ce --- /dev/null +++ b/src-tauri/src/db/entities/loop_space.rs @@ -0,0 +1,23 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "loop_space")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub name: String, + /// Bound root folder (must be a git repo). Plain column — cross-subsystem + /// reference to `folder.id`, no FK. + pub folder_id: i32, + /// Space default `IssueConfig` (JSON), `NOT NULL` — every space stores a + /// concrete config (the engine default is written at creation). Issues whose + /// own `config` is `NULL` resolve against this at read time. + pub default_config: String, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src-tauri/src/db/entities/loop_validation_run.rs b/src-tauri/src/db/entities/loop_validation_run.rs new file mode 100644 index 0000000000..413c99cebc --- /dev/null +++ b/src-tauri/src/db/entities/loop_validation_run.rs @@ -0,0 +1,27 @@ +use sea_orm::entity::prelude::*; + +/// One deterministic validation pass (the issue's `validation_commands` run in +/// the worktree). Engine-run, no agent/conversation. +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "loop_validation_run")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub space_id: i32, + pub issue_id: i32, + pub task_artifact_id: i32, + /// The implement iteration that triggered this run (plain column). + pub iteration_id: Option, + /// JSON array of commands. + pub commands: String, + /// JSON array of exit codes. + pub exit_codes: String, + pub output: String, + pub passed: bool, + pub created_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src-tauri/src/db/entities/mod.rs b/src-tauri/src/db/entities/mod.rs index 19aba98930..6d2f29e13c 100644 --- a/src-tauri/src/db/entities/mod.rs +++ b/src-tauri/src/db/entities/mod.rs @@ -6,6 +6,19 @@ pub mod chat_channel_sender_context; pub mod conversation; pub mod folder; pub mod folder_command; +pub mod loop_artifact; +pub mod loop_artifact_revision; +pub mod loop_coverage; +pub mod loop_criterion; +pub mod loop_criterion_check; +pub mod loop_gate_decision; +pub mod loop_inbox_item; +pub mod loop_iteration; +pub mod loop_issue; +pub mod loop_link; +pub mod loop_memory; +pub mod loop_space; +pub mod loop_validation_run; pub mod model_provider; pub mod opened_tab; pub mod prelude; diff --git a/src-tauri/src/db/migration/m20260612_000001_conversation_folder_kind.rs b/src-tauri/src/db/migration/m20260612_000001_conversation_folder_kind.rs index e507924815..d0324b5bda 100644 --- a/src-tauri/src/db/migration/m20260612_000001_conversation_folder_kind.rs +++ b/src-tauri/src/db/migration/m20260612_000001_conversation_folder_kind.rs @@ -171,8 +171,14 @@ mod tests { #[tokio::test] async fn backfills_folder_and_conversation_kind() { let conn = Database::connect("sqlite::memory:").await.expect("db"); - let total = ::migrations().len() as u32; - Migrator::up(&conn, Some(total - 1)) + // Run every migration strictly *before* this one, then seed legacy rows. + // Pin to this migration's own index by name so appending later + // migrations (e.g. the loop tables) never shifts the cut point. + let idx = ::migrations() + .iter() + .position(|m| m.name() == "m20260612_000001_conversation_folder_kind") + .expect("migration present") as u32; + Migrator::up(&conn, Some(idx)) .await .expect("legacy migrations"); diff --git a/src-tauri/src/db/migration/m20260613_000001_loop_tables.rs b/src-tauri/src/db/migration/m20260613_000001_loop_tables.rs new file mode 100644 index 0000000000..67c6a12e51 --- /dev/null +++ b/src-tauri/src/db/migration/m20260613_000001_loop_tables.rs @@ -0,0 +1,418 @@ +//! Loop engineering schema (M2): 10 tables backing spaces, issues, the per-issue +//! artifact DAG, iterations (agent runs), deterministic validation runs, the +//! two-category inbox and the memory layer. +//! +//! Raw SQLite DDL is used deliberately: the project is SQLite-only, the four +//! *partial* unique indexes (the dispatch leases + pending-inbox dedupe) cannot +//! be expressed through SeaORM's `Index` builder, and 10 tables read far more +//! clearly as DDL than as builder chains. Cross-subsystem refs (`folder_id`, +//! `conversation_id`) are plain columns. The artifact↔iteration cycle +//! (`produced_by_iteration_id` / `target_artifact_id`) is intentionally left +//! without FK constraints to avoid a circular dependency; every other loop-table +//! reference is a real FK enforced in the test pool. +//! +//! Every enum-backed column carries a `CHECK (col IN (...))` mirroring its +//! `DeriveActiveEnum` `string_value`s — the DB is the last line of defence +//! against a stray write, not just the Rust layer. `loop_space.default_config` +//! is `NOT NULL` (every space stores a concrete `IssueConfig`); `loop_issue.config` +//! is nullable, where `NULL` means "inherit the space default" (single source of +//! truth — there is no separate inherit flag). + +use sea_orm::ConnectionTrait; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +const UP: &[&str] = &[ + "CREATE TABLE loop_space ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + folder_id INTEGER NOT NULL, + default_config TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )", + "CREATE TABLE loop_issue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + space_id INTEGER NOT NULL REFERENCES loop_space(id) ON DELETE CASCADE, + seq_no INTEGER NOT NULL, + title TEXT NOT NULL, + description TEXT NOT NULL, + priority TEXT NOT NULL DEFAULT 'medium' + CHECK (priority IN ('high','medium','low')), + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending','running','paused','blocked','done','cancelled')), + pause_reason TEXT CHECK (pause_reason IS NULL OR pause_reason IN ('manual','budget')), + route TEXT NOT NULL DEFAULT 'undecided' + CHECK (route IN ('undecided','full','skip_design','direct')), + execution_mode TEXT + CHECK (execution_mode IS NULL OR execution_mode IN ('serial','parallel')), + config TEXT, + worktree_folder_id INTEGER, + base_branch TEXT, + base_commit TEXT, + fan_in_manifest TEXT, + fan_in_resolver_tip TEXT, + token_used BIGINT NOT NULL DEFAULT 0, + token_budget BIGINT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + triggered_at TEXT, + ended_at TEXT + )", + "CREATE UNIQUE INDEX uniq_loop_issue_seq ON loop_issue(space_id, seq_no)", + "CREATE INDEX idx_loop_issue_space_status ON loop_issue(space_id, status)", + "CREATE TABLE loop_artifact ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + space_id INTEGER NOT NULL REFERENCES loop_space(id) ON DELETE CASCADE, + issue_id INTEGER NOT NULL REFERENCES loop_issue(id) ON DELETE CASCADE, + kind TEXT NOT NULL + CHECK (kind IN ('issue','requirement','design','task','review','result','reflection')), + title TEXT NOT NULL, + status TEXT NOT NULL + CHECK (status IN ('pending','in_progress','awaiting_approval','done','blocked','superseded','cancelled')), + origin TEXT NOT NULL CHECK (origin IN ('human','agent')), + produced_by_iteration_id INTEGER, + verdict TEXT CHECK (verdict IS NULL OR verdict IN ('pass','fail')), + attempt INTEGER NOT NULL DEFAULT 0, + last_failure_sig TEXT, + fan_in_commit TEXT, + sort INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + -- C-phase (D12): whether a Done task contributed a real diff (`delta`, with a + -- frozen fan_in_commit) or was an agent-declared no-op (`no_op`, fan_in_commit + -- NULL). Only meaningful for parallel fan-in participants; serial tasks always + -- record 'delta'. The invariant `no_op ⇔ fan_in_commit IS NULL` is upheld at + -- the write (cas_task_done_with_contribution). + contribution_kind TEXT NOT NULL DEFAULT 'delta' + CHECK (contribution_kind IN ('delta','no_op')), + -- C-phase (D14): oscillation breaker epoch counters, keyed per task. Stepped + -- only when a genuine new block lands (mark_blocked, blocked_now); reset on + -- real forward progress / override / force-complete. + oscillation_count INTEGER NOT NULL DEFAULT 0 CHECK (oscillation_count >= 0), + recent_failure_sig TEXT + )", + "CREATE INDEX idx_loop_artifact_issue_kind ON loop_artifact(issue_id, kind)", + "CREATE INDEX idx_loop_artifact_space ON loop_artifact(space_id)", + "CREATE INDEX idx_loop_artifact_produced_by ON loop_artifact(produced_by_iteration_id)", + // C-phase (D13/D14): re-park and oscillation-set queries scan a single issue's + // tasks by status (the active blocked set, plus pending/in_progress presence). + "CREATE INDEX idx_loop_artifact_issue_status ON loop_artifact(issue_id, status)", + // At most one LIVE result artifact per issue (the engine-synthesized capstone). + // Excludes superseded/cancelled so an integration loop-back can supersede a + // failed result and a fresh finalize can produce a new one (§3.6 / P2.4). + "CREATE UNIQUE INDEX uniq_result_per_issue ON loop_artifact(issue_id) \ + WHERE kind = 'result' AND status NOT IN ('superseded','cancelled')", + // At most one reflection artifact per issue, EVER — the durable idempotency + // anchor for memory consolidation (§4.4/§5.5, P4/D12). Reflect runs best-effort + // post-merge from several paths (merge hook, settle self-retry, boot recovery); + // this index makes the consolidated-signal a DB fact, so a crash between the + // ingest commit and settle can never double-distill on replay. + "CREATE UNIQUE INDEX uniq_reflection_per_issue ON loop_artifact(issue_id) \ + WHERE kind = 'reflection'", + "CREATE TABLE loop_artifact_revision ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + artifact_id INTEGER NOT NULL REFERENCES loop_artifact(id) ON DELETE CASCADE, + seq INTEGER NOT NULL, + content TEXT NOT NULL, + actor_kind TEXT NOT NULL CHECK (actor_kind IN ('human','agent')), + iteration_id INTEGER, + created_at TEXT NOT NULL + )", + "CREATE UNIQUE INDEX uniq_loop_revision ON loop_artifact_revision(artifact_id, seq)", + "CREATE TABLE loop_link ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + space_id INTEGER NOT NULL REFERENCES loop_space(id) ON DELETE CASCADE, + from_artifact_id INTEGER NOT NULL REFERENCES loop_artifact(id) ON DELETE CASCADE, + to_artifact_id INTEGER NOT NULL REFERENCES loop_artifact(id) ON DELETE CASCADE, + kind TEXT NOT NULL + CHECK (kind IN ('derives_from','skips_to','reviews','depends_on','results_from')), + source_revision_id INTEGER REFERENCES loop_artifact_revision(id) ON DELETE SET NULL, + created_at TEXT NOT NULL + )", + "CREATE UNIQUE INDEX uniq_loop_link ON loop_link(from_artifact_id, to_artifact_id, kind)", + "CREATE INDEX idx_loop_link_to ON loop_link(to_artifact_id, kind)", + "CREATE TABLE loop_criterion ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + artifact_id INTEGER NOT NULL REFERENCES loop_artifact(id) ON DELETE CASCADE, + label TEXT NOT NULL, + text TEXT NOT NULL, + sort INTEGER NOT NULL DEFAULT 0, + kind TEXT NOT NULL DEFAULT 'acceptance' + CHECK (kind IN ('acceptance','constraint','invariant','obligation')) + )", + "CREATE INDEX idx_loop_criterion_artifact ON loop_criterion(artifact_id)", + // Criterion-level coverage: which task artifact satisfies which (acceptance) + // criterion. The unit of traceability — a requirement AC is "covered" once a + // task claims it, and the driver's bounded replan loop-back fires on any gap. + "CREATE TABLE loop_coverage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + space_id INTEGER NOT NULL REFERENCES loop_space(id) ON DELETE CASCADE, + task_artifact_id INTEGER NOT NULL REFERENCES loop_artifact(id) ON DELETE CASCADE, + criterion_id INTEGER NOT NULL REFERENCES loop_criterion(id) ON DELETE CASCADE, + created_at TEXT NOT NULL + )", + "CREATE UNIQUE INDEX uniq_loop_coverage ON loop_coverage(task_artifact_id, criterion_id)", + "CREATE INDEX idx_loop_coverage_criterion ON loop_coverage(criterion_id)", + "CREATE TABLE loop_iteration ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + space_id INTEGER NOT NULL REFERENCES loop_space(id) ON DELETE CASCADE, + issue_id INTEGER NOT NULL REFERENCES loop_issue(id) ON DELETE CASCADE, + stage TEXT NOT NULL + CHECK (stage IN ('triage','refine','design','plan','implement','review','finalize','reflect')), + target_artifact_id INTEGER, + slot_no INTEGER, + conversation_id INTEGER, + capability_token TEXT NOT NULL, + status TEXT NOT NULL + CHECK (status IN ('queued','running','succeeded','failed','interrupted','cancelled')), + launched_by TEXT NOT NULL CHECK (launched_by IN ('engine','human')), + attempt INTEGER NOT NULL DEFAULT 0, + tokens_used BIGINT NOT NULL DEFAULT 0, + tokens_pending INTEGER NOT NULL DEFAULT 0 CHECK (tokens_pending IN (0,1)), + context_manifest TEXT, + created_at TEXT NOT NULL, + started_at TEXT, + ended_at TEXT, + -- Why an iteration ended (D11). NULL is legal: while the run is in flight, + -- or for a settled implement run before its checkpoint has written the + -- real outcome. `declared_complete` is a Phase-C value, enumerated now so + -- the CHECK/UI need no Phase-C edit. + outcome TEXT CHECK (outcome IS NULL OR outcome IN ('succeeded','empty_diff','validation_failed','declared_complete','no_artifacts','abandoned')), + -- C-phase (D12): the implement agent's free-text reason when it declares the + -- task already satisfied (loop_task_complete), else NULL. Truncated to + -- MAX_CONTENT at the write; read by finish_implement (route to review) and the + -- review briefing (verify-against-HEAD note). + agent_completion_reason TEXT + )", + "CREATE UNIQUE INDEX uniq_loop_iteration_token ON loop_iteration(capability_token)", + "CREATE INDEX idx_loop_iteration_issue ON loop_iteration(issue_id)", + "CREATE INDEX idx_loop_iteration_issue_status ON loop_iteration(issue_id, status)", + "CREATE INDEX idx_loop_iteration_space ON loop_iteration(space_id)", + "CREATE INDEX idx_loop_iteration_conv ON loop_iteration(conversation_id)", + // D10: the artifact drawer lists every iteration that targeted an artifact. + "CREATE INDEX idx_loop_iteration_target ON loop_iteration(target_artifact_id)", + // Dispatch leases (DB-authoritative double-dispatch guards). Partial unique + // indexes — SeaORM's Index builder can't express the WHERE clause. + // Task parallelism (phase 2) drops the old per-issue `uniq_active_write` (one + // implement-or-finalize per issue): several tasks now implement/review at once, + // each guarded by `uniq_active_node(target, stage)`. Finalize stays singular + // per issue (one fan-in / result-stage agent), so it keeps its own lease. + "CREATE UNIQUE INDEX uniq_active_finalize ON loop_iteration(issue_id) \ + WHERE stage = 'finalize' AND status IN ('queued','running')", + // One active reflect per issue (issue-level lease, mirrors uniq_active_finalize; + // §4.7/P4/D5). target_artifact_id is NULL for reflect, so uniq_active_node does + // not constrain it (SQLite treats NULLs as distinct). + "CREATE UNIQUE INDEX uniq_active_reflect ON loop_iteration(issue_id) \ + WHERE stage = 'reflect' AND status IN ('queued','running')", + "CREATE UNIQUE INDEX uniq_active_node ON loop_iteration(target_artifact_id, stage) \ + WHERE status IN ('queued','running') AND stage <> 'review'", + "CREATE UNIQUE INDEX uniq_review_slot ON loop_iteration(target_artifact_id, slot_no) \ + WHERE stage = 'review' AND status IN ('queued','running')", + // Per-criterion structured judgement (§3.4): one reviewer's pass/fail of one + // criterion, scoped to the artifact it judged (a task, or the result for the + // integration gate). Idempotent on (criterion, iteration, scope) so a crash + // replay of a review submission never double-writes. + "CREATE TABLE loop_criterion_check ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + space_id INTEGER NOT NULL REFERENCES loop_space(id) ON DELETE CASCADE, + criterion_id INTEGER NOT NULL REFERENCES loop_criterion(id) ON DELETE CASCADE, + iteration_id INTEGER NOT NULL REFERENCES loop_iteration(id) ON DELETE CASCADE, + scope_artifact_id INTEGER NOT NULL REFERENCES loop_artifact(id) ON DELETE CASCADE, + verdict TEXT NOT NULL CHECK (verdict IN ('pass','fail')), + evidence TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL + )", + "CREATE UNIQUE INDEX uniq_loop_criterion_check \ + ON loop_criterion_check(criterion_id, iteration_id, scope_artifact_id)", + "CREATE INDEX idx_loop_criterion_check_scope ON loop_criterion_check(scope_artifact_id)", + // Immutable gate-decision audit (§3.4): the aggregated outcome of a gate over a + // target at one attempt, recording WHICH checks it aggregated (input_check_ids) + // + a digest of the inputs, so a later check supersede never rewrites history. + "CREATE TABLE loop_gate_decision ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + space_id INTEGER NOT NULL REFERENCES loop_space(id) ON DELETE CASCADE, + issue_id INTEGER NOT NULL REFERENCES loop_issue(id) ON DELETE CASCADE, + target_artifact_id INTEGER NOT NULL REFERENCES loop_artifact(id) ON DELETE CASCADE, + stage TEXT NOT NULL, + attempt INTEGER NOT NULL, + policy_json TEXT NOT NULL, + input_check_ids TEXT NOT NULL, + input_digest TEXT NOT NULL, + outcome TEXT NOT NULL CHECK (outcome IN ('pass','fail','undecided')), + created_at TEXT NOT NULL + )", + "CREATE UNIQUE INDEX uniq_loop_gate_decision \ + ON loop_gate_decision(target_artifact_id, stage, attempt)", + "CREATE INDEX idx_loop_gate_decision_issue ON loop_gate_decision(issue_id)", + "CREATE TABLE loop_validation_run ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + space_id INTEGER NOT NULL REFERENCES loop_space(id) ON DELETE CASCADE, + issue_id INTEGER NOT NULL REFERENCES loop_issue(id) ON DELETE CASCADE, + task_artifact_id INTEGER NOT NULL REFERENCES loop_artifact(id) ON DELETE CASCADE, + iteration_id INTEGER, + commands TEXT NOT NULL, + exit_codes TEXT NOT NULL, + output TEXT NOT NULL, + passed BOOLEAN NOT NULL, + created_at TEXT NOT NULL + )", + "CREATE INDEX idx_loop_validation_run_issue ON loop_validation_run(issue_id)", + "CREATE INDEX idx_loop_validation_run_task ON loop_validation_run(task_artifact_id)", + "CREATE INDEX idx_loop_validation_run_iter ON loop_validation_run(iteration_id)", + "CREATE TABLE loop_inbox_item ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + space_id INTEGER NOT NULL REFERENCES loop_space(id) ON DELETE CASCADE, + issue_id INTEGER NOT NULL REFERENCES loop_issue(id) ON DELETE CASCADE, + iteration_id INTEGER, + kind TEXT NOT NULL + CHECK (kind IN ('approval','blocked','budget_exhausted','question','reflection_failed')), + subject_key TEXT NOT NULL, + payload TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending','handled')), + resolution TEXT, + created_at TEXT NOT NULL, + handled_at TEXT + )", + "CREATE UNIQUE INDEX uniq_inbox_pending ON loop_inbox_item(issue_id, kind, subject_key) \ + WHERE status = 'pending'", + "CREATE INDEX idx_loop_inbox_space_status ON loop_inbox_item(space_id, status)", + // D6: per-issue pending-inbox aggregation (blocking/notice counts on issue rows). + "CREATE INDEX idx_loop_inbox_issue_status ON loop_inbox_item(issue_id, status)", + "CREATE TABLE loop_memory ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + space_id INTEGER NOT NULL REFERENCES loop_space(id) ON DELETE CASCADE, + kind TEXT NOT NULL + CHECK (kind IN ('constitution','constraint','decision','preference','pitfall','episodic','procedural')), + source TEXT NOT NULL CHECK (source IN ('human','agent')), + title TEXT NOT NULL, + summary TEXT, + content TEXT NOT NULL, + trust_tier TEXT NOT NULL DEFAULT 'proposed' + CHECK (trust_tier IN ('human','distilled','proposed')), + status TEXT NOT NULL DEFAULT 'active' + CHECK (status IN ('active','archived','superseded')), + superseded_by INTEGER REFERENCES loop_memory(id) ON DELETE SET NULL, + source_issue_id INTEGER, + source_artifact_id INTEGER, + produced_by_iteration_id INTEGER, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )", + "CREATE INDEX idx_loop_memory_lookup ON loop_memory(space_id, kind, status)", +]; + +/// Reverse dependency order (children before parents). +const DOWN: &[&str] = &[ + "DROP TABLE IF EXISTS loop_gate_decision", + "DROP TABLE IF EXISTS loop_criterion_check", + "DROP TABLE IF EXISTS loop_coverage", + "DROP TABLE IF EXISTS loop_validation_run", + "DROP TABLE IF EXISTS loop_inbox_item", + "DROP TABLE IF EXISTS loop_memory", + "DROP TABLE IF EXISTS loop_criterion", + "DROP TABLE IF EXISTS loop_link", + "DROP TABLE IF EXISTS loop_artifact_revision", + "DROP TABLE IF EXISTS loop_iteration", + "DROP TABLE IF EXISTS loop_artifact", + "DROP TABLE IF EXISTS loop_issue", + "DROP TABLE IF EXISTS loop_space", +]; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + for stmt in UP { + db.execute_unprepared(stmt).await?; + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + for stmt in DOWN { + db.execute_unprepared(stmt).await?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use sea_orm::{ConnectionTrait, Database, DbBackend, Statement}; + use sea_orm_migration::MigratorTrait; + + use crate::db::migration::Migrator; + + fn sql(s: &str) -> Statement { + Statement::from_string(DbBackend::Sqlite, s.to_owned()) + } + + async fn count(conn: &sea_orm::DatabaseConnection, kind: &str, name: &str) -> i32 { + let row = conn + .query_one(sql(&format!( + "SELECT COUNT(*) AS n FROM sqlite_master WHERE type='{kind}' AND name='{name}'" + ))) + .await + .expect("query") + .expect("row"); + row.try_get::("", "n").expect("n") + } + + #[tokio::test] + async fn creates_all_loop_tables_and_partial_indexes() { + let conn = Database::connect("sqlite::memory:").await.expect("db"); + Migrator::up(&conn, None).await.expect("migrations"); + + for table in [ + "loop_space", + "loop_issue", + "loop_artifact", + "loop_artifact_revision", + "loop_link", + "loop_criterion", + "loop_coverage", + "loop_criterion_check", + "loop_gate_decision", + "loop_iteration", + "loop_validation_run", + "loop_inbox_item", + "loop_memory", + ] { + assert_eq!(count(&conn, "table", table).await, 1, "table {table} missing"); + } + + for index in [ + // Partial unique dispatch leases + pending-inbox dedupe. + "uniq_active_finalize", + "uniq_active_reflect", + "uniq_active_node", + "uniq_review_slot", + "uniq_inbox_pending", + "uniq_result_per_issue", + "uniq_reflection_per_issue", + "uniq_loop_coverage", + "uniq_loop_criterion_check", + "uniq_loop_gate_decision", + // Plain lookup indexes. + "idx_loop_iteration_issue_status", + "idx_loop_iteration_target", + "idx_loop_artifact_produced_by", + "idx_loop_link_to", + "idx_loop_criterion_artifact", + "idx_loop_coverage_criterion", + "idx_loop_validation_run_issue", + "idx_loop_validation_run_task", + "idx_loop_validation_run_iter", + "idx_loop_inbox_space_status", + "idx_loop_inbox_issue_status", + "idx_loop_memory_lookup", + ] { + assert_eq!(count(&conn, "index", index).await, 1, "index {index} missing"); + } + } +} diff --git a/src-tauri/src/db/migration/mod.rs b/src-tauri/src/db/migration/mod.rs index 0dea0785fa..a452c07b80 100644 --- a/src-tauri/src/db/migration/mod.rs +++ b/src-tauri/src/db/migration/mod.rs @@ -22,6 +22,7 @@ mod m20260608_000001_conversation_title_locked; mod m20260610_000001_conversation_pinned_at; mod m20260611_000001_folder_is_chat; mod m20260612_000001_conversation_folder_kind; +mod m20260613_000001_loop_tables; pub struct Migrator; #[async_trait::async_trait] @@ -50,6 +51,7 @@ impl MigratorTrait for Migrator { Box::new(m20260610_000001_conversation_pinned_at::Migration), Box::new(m20260611_000001_folder_is_chat::Migration), Box::new(m20260612_000001_conversation_folder_kind::Migration), + Box::new(m20260613_000001_loop_tables::Migration), ] } } diff --git a/src-tauri/src/db/service/conversation_service.rs b/src-tauri/src/db/service/conversation_service.rs index efaf57b08a..b2fc3f04ab 100644 --- a/src-tauri/src/db/service/conversation_service.rs +++ b/src-tauri/src/db/service/conversation_service.rs @@ -51,6 +51,28 @@ pub async fn create_chat( .await } +/// Mirror of [`create`] for loop-engine iterations: `kind = 'loop'`, so the row +/// is excluded from the sidebar entirely (see `list_all` and +/// `emit_conversation_upsert`). Each iteration's worktree folder backs it. +pub async fn create_loop( + conn: &DatabaseConnection, + folder_id: i32, + agent_type: AgentType, + title: Option, + git_branch: Option, +) -> Result { + create_inner( + conn, + folder_id, + agent_type, + title, + git_branch, + None, + ConversationKind::Loop, + ) + .await +} + /// Mirror of [`create`] plus optional delegation linkage. Used by the /// multi-agent broker when spawning a child sub-session — populates /// `parent_id` / `parent_tool_use_id` / `delegation_call_id` so the lifecycle diff --git a/src-tauri/src/db/service/folder_service.rs b/src-tauri/src/db/service/folder_service.rs index fdada73e16..200f8ca981 100644 --- a/src-tauri/src/db/service/folder_service.rs +++ b/src-tauri/src/db/service/folder_service.rs @@ -190,6 +190,67 @@ pub async fn add_chat_folder( Ok(to_detail(model)) } +/// Register (or re-attach) the hidden folder backing an issue's git worktree. +/// +/// `kind = loop_worktree` keeps it out of every user-facing folder list while +/// still resolvable by id for cwd. `parent_id` is the space's repo-root folder, +/// written authoritatively on both insert and reopen so crash recovery can +/// re-attach the same path without leaving a stale relationship or kind. +pub async fn add_loop_worktree_folder( + conn: &DatabaseConnection, + path: &str, + parent_id: i32, +) -> Result { + let now = Utc::now(); + let name = std::path::Path::new(path) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| path.to_string()); + + let existing = folder::Entity::find() + .filter(folder::Column::Path.eq(path)) + .one(conn) + .await?; + + let model = if let Some(row) = existing { + let mut active = row.into_active_model(); + active.name = Set(name); + active.last_opened_at = Set(now); + active.updated_at = Set(now); + active.deleted_at = Set(None); + active.is_open = Set(true); + active.parent_id = Set(Some(parent_id)); + active.kind = Set(FolderKind::LoopWorktree); + active.update(conn).await? + } else { + let max_order = folder::Entity::find() + .order_by_desc(folder::Column::SortOrder) + .one(conn) + .await? + .map(|m| m.sort_order) + .unwrap_or(0); + let active = folder::ActiveModel { + id: NotSet, + name: Set(name), + path: Set(path.to_string()), + git_branch: Set(None), + default_agent_type: Set(None), + last_opened_at: Set(now), + created_at: Set(now), + updated_at: Set(now), + deleted_at: Set(None), + is_open: Set(true), + sort_order: Set(max_order + 1), + color: Set(DEFAULT_FOLDER_COLOR.to_string()), + parent_id: Set(Some(parent_id)), + kind: Set(FolderKind::LoopWorktree), + }; + active.insert(conn).await? + }; + + Ok(to_entry(model)) +} + pub async fn update_folder_color( conn: &DatabaseConnection, folder_id: i32, diff --git a/src-tauri/src/db/service/loop_service/artifact.rs b/src-tauri/src/db/service/loop_service/artifact.rs new file mode 100644 index 0000000000..0bd1720401 --- /dev/null +++ b/src-tauri/src/db/service/loop_service/artifact.rs @@ -0,0 +1,449 @@ +use std::collections::HashMap; + +use chrono::Utc; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set, TransactionTrait, +}; + +use crate::db::entities::loop_artifact::{ArtifactKind, ArtifactStatus, ReviewVerdict}; +use crate::db::entities::loop_artifact_revision::ActorKind; +use crate::db::entities::loop_criterion::CriterionKind; +use crate::db::entities::loop_link::LinkKind; +use crate::db::entities::{ + conversation, loop_artifact, loop_artifact_revision, loop_criterion, loop_issue, + loop_iteration, loop_link, +}; +use crate::db::error::DbError; +use crate::models::loops::{ + ArtifactIterationRef, LoopArtifactDetail, LoopArtifactRow, LoopCriterionRow, LoopDagView, + LoopLinkRow, LoopRevision, +}; + +use super::link::to_link_row; + +pub fn to_artifact_row(m: &loop_artifact::Model, issue_seq: i32) -> LoopArtifactRow { + LoopArtifactRow { + id: m.id, + issue_id: m.issue_id, + issue_seq, + kind: m.kind, + title: m.title.clone(), + status: m.status, + origin: m.origin, + produced_by_iteration_id: m.produced_by_iteration_id, + verdict: m.verdict, + attempt: m.attempt, + contribution_kind: m.contribution_kind, + sort: m.sort, + updated_at: m.updated_at, + } +} + +fn to_revision(m: loop_artifact_revision::Model) -> LoopRevision { + LoopRevision { + id: m.id, + seq: m.seq, + content: m.content, + actor_kind: m.actor_kind, + iteration_id: m.iteration_id, + created_at: m.created_at, + } +} + +fn to_criterion_row(m: loop_criterion::Model) -> LoopCriterionRow { + LoopCriterionRow { + id: m.id, + label: m.label, + text: m.text, + sort: m.sort, + kind: m.kind, + } +} + +#[allow(clippy::too_many_arguments)] +pub async fn create_artifact( + conn: &impl sea_orm::ConnectionTrait, + space_id: i32, + issue_id: i32, + kind: ArtifactKind, + title: &str, + status: ArtifactStatus, + origin: ActorKind, + produced_by_iteration_id: Option, +) -> Result { + let now = Utc::now(); + let sort = loop_artifact::Entity::find() + .filter(loop_artifact::Column::IssueId.eq(issue_id)) + .filter(loop_artifact::Column::Kind.eq(kind)) + .order_by_desc(loop_artifact::Column::Sort) + .one(conn) + .await? + .map(|m| m.sort + 1) + .unwrap_or(0); + Ok(loop_artifact::ActiveModel { + space_id: Set(space_id), + issue_id: Set(issue_id), + kind: Set(kind), + title: Set(title.to_string()), + status: Set(status), + origin: Set(origin), + produced_by_iteration_id: Set(produced_by_iteration_id), + verdict: Set(None), + attempt: Set(0), + last_failure_sig: Set(None), + sort: Set(sort), + created_at: Set(now), + updated_at: Set(now), + ..Default::default() + } + .insert(conn) + .await?) +} + +pub async fn add_revision( + conn: &impl sea_orm::ConnectionTrait, + artifact_id: i32, + content: &str, + actor_kind: ActorKind, + iteration_id: Option, +) -> Result { + let seq = loop_artifact_revision::Entity::find() + .filter(loop_artifact_revision::Column::ArtifactId.eq(artifact_id)) + .order_by_desc(loop_artifact_revision::Column::Seq) + .one(conn) + .await? + .map(|m| m.seq + 1) + .unwrap_or(1); + Ok(loop_artifact_revision::ActiveModel { + artifact_id: Set(artifact_id), + seq: Set(seq), + content: Set(content.to_string()), + actor_kind: Set(actor_kind), + iteration_id: Set(iteration_id), + created_at: Set(Utc::now()), + ..Default::default() + } + .insert(conn) + .await?) +} + +/// The id of the artifact's most recent revision (highest seq), if any. Used to +/// bind a design→requirement lineage edge to the exact requirement content the +/// design derived from. +pub async fn latest_revision_id( + conn: &sea_orm::DatabaseConnection, + artifact_id: i32, +) -> Result, DbError> { + Ok(loop_artifact_revision::Entity::find() + .filter(loop_artifact_revision::Column::ArtifactId.eq(artifact_id)) + .order_by_desc(loop_artifact_revision::Column::Seq) + .one(conn) + .await? + .map(|m| m.id)) +} + +/// Auto-labels `AC-{n}` and appends at the end. `kind` types the criterion +/// (acceptance for requirements/tasks; constraint/invariant/obligation for +/// designs) — ingest enforces the per-artifact-kind allow-set before calling. +pub async fn add_criterion( + conn: &impl sea_orm::ConnectionTrait, + artifact_id: i32, + kind: CriterionKind, + text: &str, +) -> Result { + let next = loop_criterion::Entity::find() + .filter(loop_criterion::Column::ArtifactId.eq(artifact_id)) + .order_by_desc(loop_criterion::Column::Sort) + .one(conn) + .await? + .map(|m| m.sort + 1) + .unwrap_or(0); + Ok(loop_criterion::ActiveModel { + artifact_id: Set(artifact_id), + label: Set(format!("AC-{}", next + 1)), + text: Set(text.to_string()), + sort: Set(next), + kind: Set(kind), + ..Default::default() + } + .insert(conn) + .await?) +} + +pub async fn get_artifact_detail( + conn: &sea_orm::DatabaseConnection, + id: i32, +) -> Result, DbError> { + let Some(artifact) = loop_artifact::Entity::find_by_id(id).one(conn).await? else { + return Ok(None); + }; + let issue_seq = loop_issue::Entity::find_by_id(artifact.issue_id) + .one(conn) + .await? + .map(|i| i.seq_no) + .unwrap_or(0); + + let revisions = loop_artifact_revision::Entity::find() + .filter(loop_artifact_revision::Column::ArtifactId.eq(id)) + .order_by_asc(loop_artifact_revision::Column::Seq) + .all(conn) + .await? + .into_iter() + .map(to_revision) + .collect(); + + let criteria = loop_criterion::Entity::find() + .filter(loop_criterion::Column::ArtifactId.eq(id)) + .order_by_asc(loop_criterion::Column::Sort) + .all(conn) + .await? + .into_iter() + .map(to_criterion_row) + .collect(); + + // Edges touching this node in either direction. + let links: Vec = loop_link::Entity::find() + .filter( + loop_link::Column::FromArtifactId + .eq(id) + .or(loop_link::Column::ToArtifactId.eq(id)), + ) + .all(conn) + .await? + .into_iter() + .map(to_link_row) + .collect(); + + Ok(Some(LoopArtifactDetail { + row: to_artifact_row(&artifact, issue_seq), + revisions, + criteria, + links, + })) +} + +pub async fn list_dag( + conn: &sea_orm::DatabaseConnection, + issue_id: i32, +) -> Result { + // Read the whole view inside one transaction so every slice — artifacts, + // links, coverage, checks, gate decisions, and in-flight iterations — is a + // single consistent snapshot. Without it, an iteration settling mid-read + // could be captured as NEITHER a ghost (already gone from the live set) nor + // its landed artifact (read before it appeared), making the node blink out of + // the DAG/board for one poll. The frontend dedups the "both present" overlap + // (ghost vs. landed artifact) by `produced_by_iteration_id`, so a snapshot + // that errs toward showing both is safe; one that shows neither is not. + let txn = conn.begin().await?; + + let issue_seq = loop_issue::Entity::find_by_id(issue_id) + .one(&txn) + .await? + .map(|i| i.seq_no) + .unwrap_or(0); + + let artifact_models = loop_artifact::Entity::find() + .filter(loop_artifact::Column::IssueId.eq(issue_id)) + .order_by_asc(loop_artifact::Column::Id) + .all(&txn) + .await?; + let artifact_ids: Vec = artifact_models.iter().map(|m| m.id).collect(); + let artifacts = artifact_models + .iter() + .map(|m| to_artifact_row(m, issue_seq)) + .collect(); + + // Every edge of this issue's DAG has its `from` node inside the issue. + let links = if artifact_ids.is_empty() { + Vec::new() + } else { + loop_link::Entity::find() + .filter(loop_link::Column::FromArtifactId.is_in(artifact_ids)) + .all(&txn) + .await? + .into_iter() + .map(to_link_row) + .collect() + }; + + let coverage = super::coverage::list_for_issue(&txn, issue_id).await?; + let criterion_checks = super::criterion_check::list_for_issue(&txn, issue_id).await?; + let gate_decisions = super::gate_decision::list_for_issue(&txn, issue_id).await?; + let live_iterations = super::iteration::list_live_for_issue(&txn, issue_id).await?; + + // P3 agent facet: resolve each artifact's producing iteration WITHIN this issue + // so the graph can overlay the agent/session + a per-artifact attempt count. + // Bounded — one pass over the issue's iterations plus two batched queries. Only + // artifacts whose `produced_by_iteration_id` resolves to an in-issue iteration + // get a ref; orphan / cross-issue references are omitted, and the frontend + // infers an unresolved producer from "facet on, but no ref for this node". + let iterations = loop_iteration::Entity::find() + .filter(loop_iteration::Column::IssueId.eq(issue_id)) + .all(&txn) + .await?; + let iter_by_id: HashMap = + iterations.iter().map(|m| (m.id, m)).collect(); + // Per-target attempt counts (task/requirement/design/reflection) and the + // finalize count (result). Review's count is always 1 (its single producer). + let mut count_by_target: HashMap = HashMap::new(); + let mut finalize_count = 0i32; + for it in &iterations { + if let Some(tid) = it.target_artifact_id { + *count_by_target.entry(tid).or_insert(0) += 1; + } + if it.stage == loop_iteration::Stage::Finalize { + finalize_count += 1; + } + } + // agent_type for the producing iterations' conversations (one batched query). + let producing_conv_ids: Vec = artifact_models + .iter() + .filter_map(|a| a.produced_by_iteration_id) + .filter_map(|iid| iter_by_id.get(&iid).copied()) + .filter_map(|it| it.conversation_id) + .collect(); + let conv_agent: HashMap = if producing_conv_ids.is_empty() { + HashMap::new() + } else { + conversation::Entity::find() + .filter(conversation::Column::Id.is_in(producing_conv_ids)) + .all(&txn) + .await? + .into_iter() + .map(|c| (c.id, c.agent_type)) + .collect() + }; + let artifact_iteration_refs: Vec = artifact_models + .iter() + .filter_map(|a| { + let it = *iter_by_id.get(&a.produced_by_iteration_id?)?; + let attempt_count = match a.kind { + ArtifactKind::Result => finalize_count, + ArtifactKind::Review => 1, + // An issue artifact is produced by triage (which targets NULL), + // so nothing ever targets it — its attempt count is always 0. + ArtifactKind::Issue => 0, + _ => count_by_target.get(&a.id).copied().unwrap_or(0), + }; + let agent_type = it + .conversation_id + .and_then(|cid| conv_agent.get(&cid).cloned()); + Some(ArtifactIterationRef { + artifact_id: a.id, + iteration_id: it.id, + stage: it.stage, + status: it.status, + outcome: it.outcome, + agent_type, + conversation_id: it.conversation_id, + attempt_count, + }) + }) + .collect(); + + txn.commit().await?; + + Ok(LoopDagView { + artifacts, + links, + coverage, + criterion_checks, + gate_decisions, + live_iterations, + artifact_iteration_refs, + }) +} + +pub async fn list_artifacts_for_space( + conn: &sea_orm::DatabaseConnection, + space_id: i32, +) -> Result, DbError> { + let seqs: HashMap = loop_issue::Entity::find() + .filter(loop_issue::Column::SpaceId.eq(space_id)) + .all(conn) + .await? + .into_iter() + .map(|i| (i.id, i.seq_no)) + .collect(); + + Ok(loop_artifact::Entity::find() + .filter(loop_artifact::Column::SpaceId.eq(space_id)) + .order_by_desc(loop_artifact::Column::Id) + .all(conn) + .await? + .iter() + .map(|m| to_artifact_row(m, *seqs.get(&m.issue_id).unwrap_or(&0))) + .collect()) +} + +/// Findings text from the most recent FAIL-verdict reviews of a task — the round +/// that triggered rework — newest first. Fed into the next implement briefing so +/// the re-attempt addresses the reviewers' objections instead of repeating them. +/// +/// "Most recent round" is scoped by the producing review iteration's `attempt` +/// (reviews are dispatched at the task's attempt), so stale findings from an +/// earlier round are excluded. +pub async fn latest_failed_review_findings( + conn: &sea_orm::DatabaseConnection, + task_artifact_id: i32, +) -> Result, DbError> { + // Review artifacts that point at this task. + let review_ids: Vec = loop_link::Entity::find() + .filter(loop_link::Column::ToArtifactId.eq(task_artifact_id)) + .filter(loop_link::Column::Kind.eq(LinkKind::Reviews)) + .all(conn) + .await? + .into_iter() + .map(|l| l.from_artifact_id) + .collect(); + if review_ids.is_empty() { + return Ok(Vec::new()); + } + + let mut reviews = loop_artifact::Entity::find() + .filter(loop_artifact::Column::Id.is_in(review_ids)) + .filter(loop_artifact::Column::Kind.eq(ArtifactKind::Review)) + .filter(loop_artifact::Column::Verdict.eq(ReviewVerdict::Fail)) + .all(conn) + .await?; + if reviews.is_empty() { + return Ok(Vec::new()); + } + + // Scope to the latest round = highest producing-iteration attempt. + let iter_ids: Vec = reviews + .iter() + .filter_map(|r| r.produced_by_iteration_id) + .collect(); + let attempt_of: HashMap = loop_iteration::Entity::find() + .filter(loop_iteration::Column::Id.is_in(iter_ids)) + .all(conn) + .await? + .into_iter() + .map(|i| (i.id, i.attempt)) + .collect(); + let attempt = |r: &loop_artifact::Model| { + r.produced_by_iteration_id + .and_then(|id| attempt_of.get(&id).copied()) + .unwrap_or(0) + }; + let max_attempt = reviews.iter().map(attempt).max().unwrap_or(0); + reviews.retain(|r| attempt(r) == max_attempt); + reviews.sort_by(|a, b| b.id.cmp(&a.id)); + + let mut out = Vec::new(); + for r in reviews { + if let Some(rev) = loop_artifact_revision::Entity::find() + .filter(loop_artifact_revision::Column::ArtifactId.eq(r.id)) + .order_by_desc(loop_artifact_revision::Column::Id) + .one(conn) + .await? + { + let findings = rev.content.trim(); + if !findings.is_empty() { + out.push(findings.to_string()); + } + } + } + Ok(out) +} diff --git a/src-tauri/src/db/service/loop_service/coverage.rs b/src-tauri/src/db/service/loop_service/coverage.rs new file mode 100644 index 0000000000..e6265ad308 --- /dev/null +++ b/src-tauri/src/db/service/loop_service/coverage.rs @@ -0,0 +1,162 @@ +use std::collections::HashSet; + +use chrono::Utc; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set}; + +use crate::db::entities::loop_artifact::{self, ArtifactKind, ArtifactStatus}; +use crate::db::entities::loop_criterion::{self, CriterionKind}; +use crate::db::entities::loop_coverage; +use crate::db::error::DbError; +use crate::models::loops::LoopCoverageRow; + +pub fn to_coverage_row(m: loop_coverage::Model) -> LoopCoverageRow { + LoopCoverageRow { + id: m.id, + task_artifact_id: m.task_artifact_id, + criterion_id: m.criterion_id, + } +} + +/// Idempotent: a repeated `(task, criterion)` pair returns the existing row +/// instead of inserting a duplicate (also guarded by `uniq_loop_coverage`). +pub async fn create_coverage( + conn: &impl sea_orm::ConnectionTrait, + space_id: i32, + task_artifact_id: i32, + criterion_id: i32, +) -> Result { + if let Some(existing) = loop_coverage::Entity::find() + .filter(loop_coverage::Column::TaskArtifactId.eq(task_artifact_id)) + .filter(loop_coverage::Column::CriterionId.eq(criterion_id)) + .one(conn) + .await? + { + return Ok(existing); + } + Ok(loop_coverage::ActiveModel { + space_id: Set(space_id), + task_artifact_id: Set(task_artifact_id), + criterion_id: Set(criterion_id), + created_at: Set(Utc::now()), + ..Default::default() + } + .insert(conn) + .await?) +} + +/// All coverage edges whose task artifact belongs to `issue_id`. Joined through +/// the artifact's `issue_id` (coverage carries only `space_id`, not `issue_id`). +pub async fn list_for_issue( + conn: &impl sea_orm::ConnectionTrait, + issue_id: i32, +) -> Result, DbError> { + let task_ids: Vec = loop_artifact::Entity::find() + .filter(loop_artifact::Column::IssueId.eq(issue_id)) + .all(conn) + .await? + .into_iter() + .map(|m| m.id) + .collect(); + if task_ids.is_empty() { + return Ok(Vec::new()); + } + Ok(loop_coverage::Entity::find() + .filter(loop_coverage::Column::TaskArtifactId.is_in(task_ids)) + .all(conn) + .await? + .into_iter() + .map(to_coverage_row) + .collect()) +} + +/// Ordered `(requirement_id, [acceptance criterion ids])` for an issue's live +/// (non-superseded/cancelled) done requirements — the single source of the +/// stable `R{i}.AC{j}` coverage ordinals. Requirements ordered by `(sort, id)`, +/// criteria by `(sort, id)`. ingest's `covers` map, the driver's coverage gate, +/// and the planner briefing all build their ordinals from this one function, so +/// `R1.AC1` means the same criterion everywhere. +pub async fn acceptance_ordinals_for_issue( + conn: &sea_orm::DatabaseConnection, + issue_id: i32, +) -> Result)>, DbError> { + let reqs = loop_artifact::Entity::find() + .filter(loop_artifact::Column::IssueId.eq(issue_id)) + .filter(loop_artifact::Column::Kind.eq(ArtifactKind::Requirement)) + .filter(loop_artifact::Column::Status.eq(ArtifactStatus::Done)) + .order_by_asc(loop_artifact::Column::Sort) + .order_by_asc(loop_artifact::Column::Id) + .all(conn) + .await?; + let mut out = Vec::with_capacity(reqs.len()); + for r in reqs { + let crits = loop_criterion::Entity::find() + .filter(loop_criterion::Column::ArtifactId.eq(r.id)) + .filter(loop_criterion::Column::Kind.eq(CriterionKind::Acceptance)) + .order_by_asc(loop_criterion::Column::Sort) + .order_by_asc(loop_criterion::Column::Id) + .all(conn) + .await?; + out.push((r.id, crits.into_iter().map(|c| c.id).collect())); + } + Ok(out) +} + +/// The `R{i}.AC{j}` ordinals (1-based) whose criterion no *live* task covers. +/// Empty ⇒ coverage complete (vacuously so when there are no acceptance +/// criteria, e.g. the direct route). Pure — the driver's bounded replan +/// loop-back fires whenever this is non-empty. +pub fn uncovered_ordinals( + ordinals: &[(i32, Vec)], + coverage: &[LoopCoverageRow], + live_tasks: &HashSet, +) -> Vec { + let covered: HashSet = coverage + .iter() + .filter(|c| live_tasks.contains(&c.task_artifact_id)) + .map(|c| c.criterion_id) + .collect(); + let mut out = Vec::new(); + for (ri, (_req, crits)) in ordinals.iter().enumerate() { + for (ci, cid) in crits.iter().enumerate() { + if !covered.contains(cid) { + out.push(format!("R{}.AC{}", ri + 1, ci + 1)); + } + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cov(task: i32, crit: i32) -> LoopCoverageRow { + LoopCoverageRow { + id: 0, + task_artifact_id: task, + criterion_id: crit, + } + } + + #[test] + fn uncovered_ordinals_reports_only_gaps_from_live_tasks() { + // R1 has AC1(=10), AC2(=11); R2 has AC1(=20). + let ordinals = vec![(1, vec![10, 11]), (2, vec![20])]; + let live: HashSet = [100, 101].into_iter().collect(); + + // Full coverage by live tasks → no gaps. + let full = vec![cov(100, 10), cov(100, 11), cov(101, 20)]; + assert!(uncovered_ordinals(&ordinals, &full, &live).is_empty()); + + // R1.AC2 uncovered. + let partial = vec![cov(100, 10), cov(101, 20)]; + assert_eq!(uncovered_ordinals(&ordinals, &partial, &live), vec!["R1.AC2"]); + + // Coverage by a non-live (superseded) task doesn't count. + let stale = vec![cov(100, 10), cov(100, 11), cov(999, 20)]; + assert_eq!(uncovered_ordinals(&ordinals, &stale, &live), vec!["R2.AC1"]); + + // No requirements → vacuously complete. + assert!(uncovered_ordinals(&[], &full, &live).is_empty()); + } +} diff --git a/src-tauri/src/db/service/loop_service/criterion_check.rs b/src-tauri/src/db/service/loop_service/criterion_check.rs new file mode 100644 index 0000000000..a4d6a0565a --- /dev/null +++ b/src-tauri/src/db/service/loop_service/criterion_check.rs @@ -0,0 +1,177 @@ +use chrono::Utc; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; + +use crate::db::entities::loop_artifact; +use crate::db::entities::loop_criterion_check::{self, CheckVerdict}; +use crate::db::error::DbError; +use crate::models::loops::LoopCriterionCheckRow; + +pub fn to_check_row(m: loop_criterion_check::Model) -> LoopCriterionCheckRow { + LoopCriterionCheckRow { + id: m.id, + criterion_id: m.criterion_id, + iteration_id: m.iteration_id, + scope_artifact_id: m.scope_artifact_id, + verdict: m.verdict, + evidence: m.evidence, + } +} + +/// Idempotent on `(criterion, iteration, scope)` (also guarded by +/// `uniq_loop_criterion_check`): a crash replay of a review submission returns +/// the existing check instead of inserting a duplicate. +pub async fn create_check( + conn: &impl sea_orm::ConnectionTrait, + space_id: i32, + criterion_id: i32, + iteration_id: i32, + scope_artifact_id: i32, + verdict: CheckVerdict, + evidence: &str, +) -> Result { + if let Some(existing) = loop_criterion_check::Entity::find() + .filter(loop_criterion_check::Column::CriterionId.eq(criterion_id)) + .filter(loop_criterion_check::Column::IterationId.eq(iteration_id)) + .filter(loop_criterion_check::Column::ScopeArtifactId.eq(scope_artifact_id)) + .one(conn) + .await? + { + return Ok(existing); + } + Ok(loop_criterion_check::ActiveModel { + space_id: Set(space_id), + criterion_id: Set(criterion_id), + iteration_id: Set(iteration_id), + scope_artifact_id: Set(scope_artifact_id), + verdict: Set(verdict), + evidence: Set(evidence.to_string()), + created_at: Set(Utc::now()), + ..Default::default() + } + .insert(conn) + .await?) +} + +/// All checks whose scope artifact belongs to `issue_id` — the per-issue trace +/// matrix (checks carry only `space_id`, so the issue is resolved via the scope +/// artifact). +pub async fn list_for_issue( + conn: &impl sea_orm::ConnectionTrait, + issue_id: i32, +) -> Result, DbError> { + let art_ids: Vec = loop_artifact::Entity::find() + .filter(loop_artifact::Column::IssueId.eq(issue_id)) + .all(conn) + .await? + .into_iter() + .map(|m| m.id) + .collect(); + if art_ids.is_empty() { + return Ok(Vec::new()); + } + Ok(loop_criterion_check::Entity::find() + .filter(loop_criterion_check::Column::ScopeArtifactId.is_in(art_ids)) + .all(conn) + .await? + .into_iter() + .map(to_check_row) + .collect()) +} + +/// Checks for one scope artifact produced by the given review iterations — the +/// inputs the gate aggregates for one deciding attempt. +pub async fn for_scope_iterations( + conn: &impl sea_orm::ConnectionTrait, + scope_artifact_id: i32, + iteration_ids: &[i32], +) -> Result, DbError> { + if iteration_ids.is_empty() { + return Ok(Vec::new()); + } + Ok(loop_criterion_check::Entity::find() + .filter(loop_criterion_check::Column::ScopeArtifactId.eq(scope_artifact_id)) + .filter(loop_criterion_check::Column::IterationId.is_in(iteration_ids.to_vec())) + .all(conn) + .await? + .into_iter() + .map(to_check_row) + .collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::entities::loop_artifact::{ArtifactKind, ArtifactStatus}; + use crate::db::entities::loop_artifact_revision::ActorKind; + use crate::db::entities::loop_criterion::CriterionKind; + use crate::db::entities::loop_issue::IssuePriority; + use crate::db::entities::loop_iteration::Stage; + use crate::db::service::loop_service::{artifact, issue, space}; + use crate::db::test_helpers::{fresh_in_memory_db, seed_folder}; + use crate::loop_engine::transitions::{try_claim_iteration, IterationClaim}; + use crate::models::loops::IssueConfig; + + #[tokio::test] + async fn create_check_is_idempotent_on_the_unique_key() { + let db = fresh_in_memory_db().await; + let folder = seed_folder(&db, "/repo").await; + let space = space::create_space(&db.conn, "S", folder).await.unwrap(); + let iss = issue::create_issue( + &db.conn, + space.id, + "I", + "b", + IssuePriority::Medium, + Some(&IssueConfig::default()), + ) + .await + .unwrap(); + let task = artifact::create_artifact( + &db.conn, + space.id, + iss.row.id, + ArtifactKind::Task, + "T", + ArtifactStatus::InProgress, + ActorKind::Agent, + None, + ) + .await + .unwrap(); + let crit = artifact::add_criterion(&db.conn, task.id, CriterionKind::Acceptance, "ac") + .await + .unwrap(); + let it = try_claim_iteration( + &db.conn, + IterationClaim { + space_id: space.id, + issue_id: iss.row.id, + stage: Stage::Review, + target_artifact_id: Some(task.id), + slot_no: Some(0), + capability_token: "tok".into(), + attempt: 0, + }, + ) + .await + .unwrap() + .unwrap(); + + let a = create_check(&db.conn, space.id, crit.id, it.id, task.id, CheckVerdict::Pass, "ok") + .await + .unwrap(); + let b = create_check( + &db.conn, + space.id, + crit.id, + it.id, + task.id, + CheckVerdict::Fail, + "changed", + ) + .await + .unwrap(); + assert_eq!(a.id, b.id, "same (criterion,iteration,scope) returns existing"); + assert_eq!(b.verdict, CheckVerdict::Pass, "first write wins; no overwrite"); + } +} diff --git a/src-tauri/src/db/service/loop_service/criterion_ordinals.rs b/src-tauri/src/db/service/loop_service/criterion_ordinals.rs new file mode 100644 index 0000000000..668de786c3 --- /dev/null +++ b/src-tauri/src/db/service/loop_service/criterion_ordinals.rs @@ -0,0 +1,334 @@ +//! The single source of every criterion *handle* a review/integration gate +//! injects and resolves against (spec §3.4/§3.6, plan D9/D10). +//! +//! A handle is a stable ordinal string — never a DB id — so the agent trust +//! boundary is preserved (ingest resolves ordinals, never ids). Three maps: +//! +//! • [`task_review_ordinals`] — what a TASK review must check: the acceptance +//! criteria the task `covers` (`R{i}.AC{j}`) plus the task's own acceptance +//! (`T{n}`); a task that covers nothing and has no own acceptance falls back +//! to all requirement acceptance (D11, never vacuous). +//! • [`integration_ordinals`] — what an INTEGRATION review (target = result) +//! must check: all requirement acceptance (`R{i}.AC{j}`) plus all design +//! obligations (`D{k}`); on the `direct` route (no requirements) it degrades +//! to the tasks' own acceptance (`T{n}.AC{j}`). +//! • [`obligation_ordinals`] — the `D{k}` design-obligation map, shown in a +//! task briefing as awareness-only context. +//! +//! The acceptance handles are built on top of the SAME ordering as +//! [`super::coverage::acceptance_ordinals_for_issue`], so `R1.AC1` means the same +//! criterion here as in `covers`, the coverage gate, and the planner briefing. +//! The map is persisted into the iteration's `context_manifest` at dispatch and +//! ingest resolves submitted handles against that stored copy, so a concurrent +//! replan can never drift the handles a reviewer was shown. + +use std::collections::{HashMap, HashSet}; + +use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder}; + +use crate::db::entities::loop_artifact::{self, ArtifactKind, ArtifactStatus}; +use crate::db::entities::loop_coverage; +use crate::db::entities::loop_criterion::{self, CriterionKind}; +use crate::db::error::DbError; + +/// One injectable criterion: its stable handle, the DB id it resolves to, and +/// the text + kind for rendering the briefing checklist. +#[derive(Debug, Clone)] +pub struct OrdinalEntry { + pub handle: String, + pub criterion_id: i32, + pub text: String, + pub kind: CriterionKind, +} + +/// `criterion_id → text` for a set of ids, in one query. +async fn criterion_texts( + conn: &impl sea_orm::ConnectionTrait, + ids: &[i32], +) -> Result, DbError> { + if ids.is_empty() { + return Ok(HashMap::new()); + } + Ok(loop_criterion::Entity::find() + .filter(loop_criterion::Column::Id.is_in(ids.to_vec())) + .all(conn) + .await? + .into_iter() + .map(|c| (c.id, c.text)) + .collect()) +} + +/// `R{i}.AC{j}` entries for the issue's live done requirements, ordered exactly +/// as [`super::coverage::acceptance_ordinals_for_issue`] (the canonical id +/// ordering) so the handles are byte-identical to `covers` / the coverage gate. +async fn requirement_acceptance_entries( + conn: &sea_orm::DatabaseConnection, + issue_id: i32, +) -> Result, DbError> { + let ordered = super::coverage::acceptance_ordinals_for_issue(conn, issue_id).await?; + let ids: Vec = ordered.iter().flat_map(|(_, cs)| cs.iter().copied()).collect(); + let texts = criterion_texts(conn, &ids).await?; + let mut out = Vec::new(); + for (ri, (_req, crits)) in ordered.iter().enumerate() { + for (ci, cid) in crits.iter().enumerate() { + out.push(OrdinalEntry { + handle: format!("R{}.AC{}", ri + 1, ci + 1), + criterion_id: *cid, + text: texts.get(cid).cloned().unwrap_or_default(), + kind: CriterionKind::Acceptance, + }); + } + } + Ok(out) +} + +/// A task's own acceptance criteria as `T{n}` entries, ordered by `(sort, id)`. +async fn task_acceptance_entries( + conn: &sea_orm::DatabaseConnection, + task_id: i32, +) -> Result, DbError> { + let crits = loop_criterion::Entity::find() + .filter(loop_criterion::Column::ArtifactId.eq(task_id)) + .filter(loop_criterion::Column::Kind.eq(CriterionKind::Acceptance)) + .order_by_asc(loop_criterion::Column::Sort) + .order_by_asc(loop_criterion::Column::Id) + .all(conn) + .await?; + Ok(crits + .into_iter() + .enumerate() + .map(|(n, c)| OrdinalEntry { + handle: format!("T{}", n + 1), + criterion_id: c.id, + text: c.text, + kind: c.kind, + }) + .collect()) +} + +/// The criteria a TASK review must check (D9): the acceptance criteria the task +/// `covers` (`R{i}.AC{j}`, canonical order) plus the task's own acceptance +/// (`T{n}`). Empty-set fallback (D11): a task that covers nothing AND has no own +/// acceptance requires checks against ALL requirement acceptance — never vacuous. +pub async fn task_review_ordinals( + conn: &sea_orm::DatabaseConnection, + issue_id: i32, + task_id: i32, +) -> Result, DbError> { + let req_acc = requirement_acceptance_entries(conn, issue_id).await?; + let covered: HashSet = loop_coverage::Entity::find() + .filter(loop_coverage::Column::TaskArtifactId.eq(task_id)) + .all(conn) + .await? + .into_iter() + .map(|c| c.criterion_id) + .collect(); + + let mut out: Vec = req_acc + .iter() + .filter(|e| covered.contains(&e.criterion_id)) + .cloned() + .collect(); + out.extend(task_acceptance_entries(conn, task_id).await?); + + // D11: whole task-scoped set empty → fall back to all requirement acceptance. + if out.is_empty() && !req_acc.is_empty() { + out = req_acc; + } + Ok(out) +} + +/// The design obligations (`D{k}`) of the issue — `constraint | invariant | +/// obligation` criteria on live done designs, flat-numbered across designs by +/// `(design sort,id)` then `(criterion sort,id)`. Requirements never carry these +/// (P1 typed allow-set), so this is exactly the cross-cutting set. +pub async fn obligation_ordinals( + conn: &sea_orm::DatabaseConnection, + issue_id: i32, +) -> Result, DbError> { + let designs = loop_artifact::Entity::find() + .filter(loop_artifact::Column::IssueId.eq(issue_id)) + .filter(loop_artifact::Column::Kind.eq(ArtifactKind::Design)) + .filter(loop_artifact::Column::Status.eq(ArtifactStatus::Done)) + .order_by_asc(loop_artifact::Column::Sort) + .order_by_asc(loop_artifact::Column::Id) + .all(conn) + .await?; + let mut out = Vec::new(); + let mut k = 0; + for d in designs { + let crits = loop_criterion::Entity::find() + .filter(loop_criterion::Column::ArtifactId.eq(d.id)) + .order_by_asc(loop_criterion::Column::Sort) + .order_by_asc(loop_criterion::Column::Id) + .all(conn) + .await?; + for c in crits { + if matches!( + c.kind, + CriterionKind::Constraint | CriterionKind::Invariant | CriterionKind::Obligation + ) { + k += 1; + out.push(OrdinalEntry { + handle: format!("D{k}"), + criterion_id: c.id, + text: c.text, + kind: c.kind, + }); + } + } + } + Ok(out) +} + +/// The whole-issue closure an INTEGRATION review (target = result) must check +/// (D9): all requirement acceptance (`R{i}.AC{j}`) plus all design obligations +/// (`D{k}`). Route degradation: on `direct` (no requirements) the acceptance +/// closure becomes the live tasks' own acceptance (`T{n}.AC{j}`); `skip_design` +/// naturally yields no `D{k}`. +pub async fn integration_ordinals( + conn: &sea_orm::DatabaseConnection, + issue_id: i32, +) -> Result, DbError> { + let mut out = requirement_acceptance_entries(conn, issue_id).await?; + if out.is_empty() { + // direct route: no requirements → the live tasks' own acceptance. + let tasks = loop_artifact::Entity::find() + .filter(loop_artifact::Column::IssueId.eq(issue_id)) + .filter(loop_artifact::Column::Kind.eq(ArtifactKind::Task)) + .filter( + loop_artifact::Column::Status + .is_not_in([ArtifactStatus::Superseded, ArtifactStatus::Cancelled]), + ) + .order_by_asc(loop_artifact::Column::Sort) + .order_by_asc(loop_artifact::Column::Id) + .all(conn) + .await?; + for (n, t) in tasks.iter().enumerate() { + let crits = loop_criterion::Entity::find() + .filter(loop_criterion::Column::ArtifactId.eq(t.id)) + .filter(loop_criterion::Column::Kind.eq(CriterionKind::Acceptance)) + .order_by_asc(loop_criterion::Column::Sort) + .order_by_asc(loop_criterion::Column::Id) + .all(conn) + .await?; + for (j, c) in crits.iter().enumerate() { + out.push(OrdinalEntry { + handle: format!("T{}.AC{}", n + 1, j + 1), + criterion_id: c.id, + text: c.text.clone(), + kind: c.kind, + }); + } + } + } + out.extend(obligation_ordinals(conn, issue_id).await?); + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::entities::loop_artifact_revision::ActorKind; + use crate::db::entities::loop_issue::IssuePriority; + use crate::db::service::loop_service::{artifact, coverage, issue, space}; + use crate::db::test_helpers::{fresh_in_memory_db, seed_folder}; + use crate::models::loops::IssueConfig; + + /// Two requirements (R1: AC1; R2: AC1), one design with an invariant, two + /// tasks (T1 covers R1.AC1 + has its own acceptance; T2 covers R2.AC1). + /// Returns `(db, space_id, issue_id, t1, t2, design)`. + async fn seed_full() -> (crate::db::AppDatabase, i32, i32, i32, i32, i32) { + let db = fresh_in_memory_db().await; + let folder = seed_folder(&db, "/repo").await; + let sp = space::create_space(&db.conn, "S", folder).await.unwrap(); + let iss = issue::create_issue( + &db.conn, + sp.id, + "I", + "b", + IssuePriority::Medium, + Some(&IssueConfig::default()), + ) + .await + .unwrap(); + let r1 = artifact::create_artifact(&db.conn, sp.id, iss.row.id, ArtifactKind::Requirement, "R1", ArtifactStatus::Done, ActorKind::Agent, None).await.unwrap(); + let r1ac = artifact::add_criterion(&db.conn, r1.id, CriterionKind::Acceptance, "r1 holds").await.unwrap(); + let r2 = artifact::create_artifact(&db.conn, sp.id, iss.row.id, ArtifactKind::Requirement, "R2", ArtifactStatus::Done, ActorKind::Agent, None).await.unwrap(); + let r2ac = artifact::add_criterion(&db.conn, r2.id, CriterionKind::Acceptance, "r2 holds").await.unwrap(); + let design = artifact::create_artifact(&db.conn, sp.id, iss.row.id, ArtifactKind::Design, "D", ArtifactStatus::Done, ActorKind::Agent, None).await.unwrap(); + artifact::add_criterion(&db.conn, design.id, CriterionKind::Invariant, "stays O(1)").await.unwrap(); + let t1 = artifact::create_artifact(&db.conn, sp.id, iss.row.id, ArtifactKind::Task, "T1", ArtifactStatus::Pending, ActorKind::Agent, None).await.unwrap(); + artifact::add_criterion(&db.conn, t1.id, CriterionKind::Acceptance, "t1 own").await.unwrap(); + coverage::create_coverage(&db.conn, sp.id, t1.id, r1ac.id).await.unwrap(); + let t2 = artifact::create_artifact(&db.conn, sp.id, iss.row.id, ArtifactKind::Task, "T2", ArtifactStatus::Pending, ActorKind::Agent, None).await.unwrap(); + coverage::create_coverage(&db.conn, sp.id, t2.id, r2ac.id).await.unwrap(); + (db, sp.id, iss.row.id, t1.id, t2.id, design.id) + } + + #[tokio::test] + async fn task_review_ordinals_cover_plus_own_acceptance() { + let (db, _sp, issue_id, t1, t2, _d) = seed_full().await; + + // T1: covered R1.AC1 + its own T1 acceptance; NOT the unrelated R2.AC1. + let m1 = task_review_ordinals(&db.conn, issue_id, t1).await.unwrap(); + let handles: Vec<&str> = m1.iter().map(|e| e.handle.as_str()).collect(); + assert_eq!(handles, vec!["R1.AC1", "T1"], "covered AC then own acceptance"); + assert!(!handles.contains(&"R2.AC1"), "unrelated requirement AC not injected"); + + // T2: covered R2.AC1; no own acceptance. + let m2 = task_review_ordinals(&db.conn, issue_id, t2).await.unwrap(); + assert_eq!(m2.iter().map(|e| e.handle.as_str()).collect::>(), vec!["R2.AC1"]); + } + + #[tokio::test] + async fn task_review_empty_falls_back_to_all_requirement_acceptance() { + let (db, sp, issue_id, _t1, _t2, _d) = seed_full().await; + // A task that covers nothing and has no own acceptance. + let bare = artifact::create_artifact(&db.conn, sp, issue_id, ArtifactKind::Task, "T3", ArtifactStatus::Pending, ActorKind::Agent, None).await.unwrap(); + let m = task_review_ordinals(&db.conn, issue_id, bare.id).await.unwrap(); + assert_eq!( + m.iter().map(|e| e.handle.as_str()).collect::>(), + vec!["R1.AC1", "R2.AC1"], + "empty closure falls back to every requirement acceptance" + ); + } + + #[tokio::test] + async fn integration_ordinals_is_requirements_plus_obligations() { + let (db, _sp, issue_id, _t1, _t2, _d) = seed_full().await; + let m = integration_ordinals(&db.conn, issue_id).await.unwrap(); + assert_eq!( + m.iter().map(|e| e.handle.as_str()).collect::>(), + vec!["R1.AC1", "R2.AC1", "D1"], + "whole-issue closure = all requirement acceptance + design obligations" + ); + } + + #[tokio::test] + async fn obligation_ordinals_only_design_cross_cutting() { + let (db, _sp, issue_id, _t1, _t2, _d) = seed_full().await; + let m = obligation_ordinals(&db.conn, issue_id).await.unwrap(); + assert_eq!(m.len(), 1); + assert_eq!(m[0].handle, "D1"); + assert_eq!(m[0].kind, CriterionKind::Invariant); + } + + #[tokio::test] + async fn integration_direct_route_uses_task_acceptance() { + // No requirements, no design → direct route closure = tasks' own acceptance. + let db = fresh_in_memory_db().await; + let folder = seed_folder(&db, "/repo2").await; + let sp = space::create_space(&db.conn, "S", folder).await.unwrap(); + let iss = issue::create_issue(&db.conn, sp.id, "I", "b", IssuePriority::Medium, Some(&IssueConfig::default())).await.unwrap(); + let t = artifact::create_artifact(&db.conn, sp.id, iss.row.id, ArtifactKind::Task, "T1", ArtifactStatus::Pending, ActorKind::Agent, None).await.unwrap(); + artifact::add_criterion(&db.conn, t.id, CriterionKind::Acceptance, "a").await.unwrap(); + artifact::add_criterion(&db.conn, t.id, CriterionKind::Acceptance, "b").await.unwrap(); + let m = integration_ordinals(&db.conn, iss.row.id).await.unwrap(); + assert_eq!( + m.iter().map(|e| e.handle.as_str()).collect::>(), + vec!["T1.AC1", "T1.AC2"], + "direct route degrades to the tasks' own acceptance" + ); + } +} diff --git a/src-tauri/src/db/service/loop_service/gate_decision.rs b/src-tauri/src/db/service/loop_service/gate_decision.rs new file mode 100644 index 0000000000..9055acbc85 --- /dev/null +++ b/src-tauri/src/db/service/loop_service/gate_decision.rs @@ -0,0 +1,287 @@ +use chrono::Utc; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set}; + +use crate::db::entities::loop_gate_decision::{self, GateOutcome}; +use crate::db::entities::loop_criterion_check::CheckVerdict; +use crate::db::error::DbError; +use crate::models::loops::{LoopCriterionCheckRow, LoopGateDecisionRow}; + +pub fn to_decision_row(m: loop_gate_decision::Model) -> LoopGateDecisionRow { + LoopGateDecisionRow { + id: m.id, + target_artifact_id: m.target_artifact_id, + stage: m.stage, + attempt: m.attempt, + outcome: m.outcome, + input_check_ids: serde_json::from_str(&m.input_check_ids).unwrap_or_default(), + created_at: m.created_at.to_rfc3339(), + } +} + +fn verdict_str(v: CheckVerdict) -> &'static str { + match v { + CheckVerdict::Pass => "pass", + CheckVerdict::Fail => "fail", + } +} + +/// Canonical, order-independent fingerprint of a gate's inputs: every aggregated +/// check as `(criterion, scope, iteration, verdict)` (iteration = stable +/// per-attempt reviewer identity, never submission order), the injected criterion +/// id set, and the policy. Two ticks that aggregate the same inputs produce the +/// same digest, so a replay is idempotent and a divergent recompute is detectable. +pub fn canonical_digest( + checks: &[LoopCriterionCheckRow], + injected_ids: &[i32], + policy_json: &str, +) -> String { + let mut tuples: Vec = checks + .iter() + .map(|c| { + format!( + "{}:{}:{}:{}", + c.criterion_id, + c.scope_artifact_id, + c.iteration_id, + verdict_str(c.verdict) + ) + }) + .collect(); + tuples.sort(); + let mut inj: Vec = injected_ids.to_vec(); + inj.sort_unstable(); + inj.dedup(); + format!("c=[{}]|i={inj:?}|p={policy_json}", tuples.join(",")) +} + +/// Outcome of recording a decision under the `(target, stage, attempt)` unique key. +pub enum RecordedDecision { + /// Inserted now, or an existing row whose digest matches (idempotent replay). + Settled(loop_gate_decision::Model), + /// A row already exists for this key with a DIFFERENT digest — a racing + /// recompute aggregated different inputs. The caller must re-tick against + /// fresh state; the recorded decision is never silently overwritten. + Conflict(loop_gate_decision::Model), +} + +/// Insert-or-compare the immutable gate decision (§3.4). The `(target, stage, +/// attempt)` unique index makes this the durable pivot: a replay with the same +/// inputs returns `Settled(existing)`; a recompute with different inputs returns +/// `Conflict(existing)`. +#[allow(clippy::too_many_arguments)] +pub async fn record_decision( + conn: &impl sea_orm::ConnectionTrait, + space_id: i32, + issue_id: i32, + target_artifact_id: i32, + stage: &str, + attempt: i32, + checks: &[LoopCriterionCheckRow], + injected_ids: &[i32], + policy_json: &str, + outcome: GateOutcome, +) -> Result { + let digest = canonical_digest(checks, injected_ids, policy_json); + let mut ids: Vec = checks.iter().map(|c| c.id).collect(); + ids.sort_unstable(); + let ids_json = serde_json::to_string(&ids).unwrap_or_else(|_| "[]".to_string()); + + let settle = |existing: loop_gate_decision::Model| { + if existing.input_digest == digest { + RecordedDecision::Settled(existing) + } else { + RecordedDecision::Conflict(existing) + } + }; + + if let Some(existing) = find_decision(conn, target_artifact_id, stage, attempt).await? { + return Ok(settle(existing)); + } + let am = loop_gate_decision::ActiveModel { + space_id: Set(space_id), + issue_id: Set(issue_id), + target_artifact_id: Set(target_artifact_id), + stage: Set(stage.to_string()), + attempt: Set(attempt), + policy_json: Set(policy_json.to_string()), + input_check_ids: Set(ids_json), + input_digest: Set(digest.clone()), + outcome: Set(outcome), + created_at: Set(Utc::now()), + ..Default::default() + }; + match am.insert(conn).await { + Ok(m) => Ok(RecordedDecision::Settled(m)), + Err(e) => { + // A racing insert may have won the unique key; re-read and compare + // instead of surfacing the violation as a hard error. + if let Some(existing) = find_decision(conn, target_artifact_id, stage, attempt).await? { + Ok(settle(existing)) + } else { + Err(e.into()) + } + } + } +} + +async fn find_decision( + conn: &impl sea_orm::ConnectionTrait, + target_artifact_id: i32, + stage: &str, + attempt: i32, +) -> Result, DbError> { + Ok(loop_gate_decision::Entity::find() + .filter(loop_gate_decision::Column::TargetArtifactId.eq(target_artifact_id)) + .filter(loop_gate_decision::Column::Stage.eq(stage)) + .filter(loop_gate_decision::Column::Attempt.eq(attempt)) + .one(conn) + .await?) +} + +/// The recorded outcome for a gate at `(target, stage, attempt)`, if any. +pub async fn outcome_for( + conn: &impl sea_orm::ConnectionTrait, + target_artifact_id: i32, + stage: &str, + attempt: i32, +) -> Result, DbError> { + Ok(find_decision(conn, target_artifact_id, stage, attempt) + .await? + .map(|m| m.outcome)) +} + +/// Number of `fail` decisions recorded for an issue at a stage — the bound for the +/// integration loop-back (counts failed integration attempts durably). +pub async fn count_fail( + conn: &impl sea_orm::ConnectionTrait, + issue_id: i32, + stage: &str, +) -> Result { + Ok(loop_gate_decision::Entity::find() + .filter(loop_gate_decision::Column::IssueId.eq(issue_id)) + .filter(loop_gate_decision::Column::Stage.eq(stage)) + .filter(loop_gate_decision::Column::Outcome.eq(GateOutcome::Fail)) + .count(conn) + .await? as u32) +} + +pub async fn list_for_issue( + conn: &impl sea_orm::ConnectionTrait, + issue_id: i32, +) -> Result, DbError> { + Ok(loop_gate_decision::Entity::find() + .filter(loop_gate_decision::Column::IssueId.eq(issue_id)) + .all(conn) + .await? + .into_iter() + .map(to_decision_row) + .collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::entities::loop_artifact::{ArtifactKind, ArtifactStatus}; + use crate::db::entities::loop_artifact_revision::ActorKind; + use crate::db::entities::loop_issue::IssuePriority; + use crate::db::service::loop_service::{artifact, issue, space}; + use crate::db::test_helpers::{fresh_in_memory_db, seed_folder}; + use crate::models::loops::IssueConfig; + + fn chk(id: i32, criterion: i32, iteration: i32, scope: i32, v: CheckVerdict) -> LoopCriterionCheckRow { + LoopCriterionCheckRow { + id, + criterion_id: criterion, + iteration_id: iteration, + scope_artifact_id: scope, + verdict: v, + evidence: String::new(), + } + } + + #[test] + fn digest_is_order_independent() { + let a = vec![ + chk(1, 10, 100, 5, CheckVerdict::Pass), + chk(2, 11, 101, 5, CheckVerdict::Fail), + ]; + let b = vec![ + chk(2, 11, 101, 5, CheckVerdict::Fail), + chk(1, 10, 100, 5, CheckVerdict::Pass), + ]; + assert_eq!( + canonical_digest(&a, &[11, 10], "p"), + canonical_digest(&b, &[10, 11], "p"), + "digest ignores check + injected-id ordering" + ); + assert_ne!( + canonical_digest(&a, &[10, 11], "p"), + canonical_digest(&a, &[10, 11], "p2"), + "policy participates in the digest" + ); + } + + #[tokio::test] + async fn record_decision_idempotent_then_conflict() { + let db = fresh_in_memory_db().await; + let folder = seed_folder(&db, "/repo").await; + let sp = space::create_space(&db.conn, "S", folder).await.unwrap(); + let iss = issue::create_issue( + &db.conn, + sp.id, + "I", + "b", + IssuePriority::Medium, + Some(&IssueConfig::default()), + ) + .await + .unwrap(); + let target = artifact::create_artifact( + &db.conn, + sp.id, + iss.row.id, + ArtifactKind::Task, + "T", + ArtifactStatus::InProgress, + ActorKind::Agent, + None, + ) + .await + .unwrap(); + let checks = vec![chk(1, 10, 100, target.id, CheckVerdict::Pass)]; + + let first = record_decision( + &db.conn, sp.id, iss.row.id, target.id, "review", 0, &checks, &[10], "{}", GateOutcome::Pass, + ) + .await + .unwrap(); + assert!(matches!(first, RecordedDecision::Settled(_))); + + // Same inputs → idempotent Settled (no second row). + let again = record_decision( + &db.conn, sp.id, iss.row.id, target.id, "review", 0, &checks, &[10], "{}", GateOutcome::Pass, + ) + .await + .unwrap(); + assert!(matches!(again, RecordedDecision::Settled(_))); + assert_eq!(count_fail(&db.conn, iss.row.id, "review").await.unwrap(), 0); + assert_eq!( + outcome_for(&db.conn, target.id, "review", 0).await.unwrap(), + Some(GateOutcome::Pass) + ); + + // Different inputs at the same key → Conflict (existing kept). + let diverged = vec![chk(2, 10, 100, target.id, CheckVerdict::Fail)]; + let conflict = record_decision( + &db.conn, sp.id, iss.row.id, target.id, "review", 0, &diverged, &[10], "{}", GateOutcome::Fail, + ) + .await + .unwrap(); + assert!(matches!(conflict, RecordedDecision::Conflict(_))); + assert_eq!( + outcome_for(&db.conn, target.id, "review", 0).await.unwrap(), + Some(GateOutcome::Pass), + "the original decision is never overwritten" + ); + } +} diff --git a/src-tauri/src/db/service/loop_service/inbox.rs b/src-tauri/src/db/service/loop_service/inbox.rs new file mode 100644 index 0000000000..53accdddfe --- /dev/null +++ b/src-tauri/src/db/service/loop_service/inbox.rs @@ -0,0 +1,580 @@ +use std::collections::HashMap; + +use chrono::Utc; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, IntoActiveModel, QueryFilter, + QueryOrder, QuerySelect, Set, +}; + +use crate::db::entities::loop_artifact::{self, ArtifactKind, ArtifactStatus}; +use crate::db::entities::loop_inbox_item::{self, InboxKind, InboxStatus}; +use crate::db::entities::{loop_issue, loop_iteration}; +use crate::db::error::DbError; +use crate::models::loops::{LoopInboxItemRow, LoopSpaceAttention}; + +fn to_row(m: loop_inbox_item::Model, issue_seq: i32) -> LoopInboxItemRow { + LoopInboxItemRow { + id: m.id, + issue_id: m.issue_id, + issue_seq, + iteration_id: m.iteration_id, + kind: m.kind, + subject_key: m.subject_key, + payload: serde_json::from_str(&m.payload).unwrap_or(serde_json::Value::Null), + status: m.status, + // Resolved at read time in `list_inbox` (B4); default for non-list callers. + subject_artifact_id: None, + subject_title: None, + created_at: m.created_at, + } +} + +/// Whether a pending inbox card demands a human before the issue can proceed +/// (`Blocking`) or is merely informational (`Notice`). +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum AttentionClass { + Blocking, + Notice, +} + +/// Classify an inbox kind for the attention rollup (D6). Exhaustive over the typed +/// `InboxKind` — no `_` arm, so a future kind forces a compile error here until it +/// is classified (never silently dropped). `question` is Blocking: a pending agent +/// question needs the human to answer before the issue can advance (resolves the +/// spec D6 gap, which omitted `question`). +pub fn attention_class(kind: InboxKind) -> AttentionClass { + match kind { + InboxKind::Approval + | InboxKind::Blocked + | InboxKind::BudgetExhausted + | InboxKind::Question => AttentionClass::Blocking, + InboxKind::ReflectionFailed => AttentionClass::Notice, + } +} + +/// Fold pending-card kinds into `(blocking, notice)` counts. +fn tally(kinds: impl IntoIterator) -> (i64, i64) { + let (mut blocking, mut notice) = (0i64, 0i64); + for k in kinds { + match attention_class(k) { + AttentionClass::Blocking => blocking += 1, + AttentionClass::Notice => notice += 1, + } + } + (blocking, notice) +} + +/// `(blocking, notice)` pending-inbox counts for one space (D6). Selects only the +/// `kind` column (never the payload) and classifies in Rust — the pending set is +/// small and this avoids a fragile SQL `GROUP BY` over enum strings. +pub async fn aggregate_for_space( + conn: &impl sea_orm::ConnectionTrait, + space_id: i32, +) -> Result<(i64, i64), DbError> { + let kinds: Vec = loop_inbox_item::Entity::find() + .select_only() + .column(loop_inbox_item::Column::Kind) + .filter(loop_inbox_item::Column::SpaceId.eq(space_id)) + .filter(loop_inbox_item::Column::Status.eq(InboxStatus::Pending)) + .into_tuple::() + .all(conn) + .await?; + Ok(tally(kinds)) +} + +/// `(blocking, notice)` pending-inbox counts per issue (D6). Issues with no +/// pending cards are absent from the map (the caller defaults them to 0). One +/// batched query for the whole issue list — no N+1. +pub async fn aggregate_for_issues( + conn: &impl sea_orm::ConnectionTrait, + issue_ids: &[i32], +) -> Result, DbError> { + if issue_ids.is_empty() { + return Ok(HashMap::new()); + } + let rows: Vec<(i32, InboxKind)> = loop_inbox_item::Entity::find() + .select_only() + .column(loop_inbox_item::Column::IssueId) + .column(loop_inbox_item::Column::Kind) + .filter(loop_inbox_item::Column::IssueId.is_in(issue_ids.to_vec())) + .filter(loop_inbox_item::Column::Status.eq(InboxStatus::Pending)) + .into_tuple::<(i32, InboxKind)>() + .all(conn) + .await?; + let mut map: HashMap = HashMap::new(); + for (issue_id, kind) in rows { + let entry = map.entry(issue_id).or_insert((0, 0)); + match attention_class(kind) { + AttentionClass::Blocking => entry.0 += 1, + AttentionClass::Notice => entry.1 += 1, + } + } + Ok(map) +} + +/// Per-space attention across ALL spaces — the global "who needs me" rollup (D6/D7). +/// Sorted by `space_id` for stable output. +pub async fn aggregate_all( + conn: &impl sea_orm::ConnectionTrait, +) -> Result, DbError> { + let rows: Vec<(i32, InboxKind)> = loop_inbox_item::Entity::find() + .select_only() + .column(loop_inbox_item::Column::SpaceId) + .column(loop_inbox_item::Column::Kind) + .filter(loop_inbox_item::Column::Status.eq(InboxStatus::Pending)) + .into_tuple::<(i32, InboxKind)>() + .all(conn) + .await?; + let mut map: HashMap = HashMap::new(); + for (space_id, kind) in rows { + let entry = map.entry(space_id).or_insert((0, 0)); + match attention_class(kind) { + AttentionClass::Blocking => entry.0 += 1, + AttentionClass::Notice => entry.1 += 1, + } + } + let mut per_space: Vec = map + .into_iter() + .map(|(space_id, (blocking, notice))| LoopSpaceAttention { + space_id, + blocking, + notice, + }) + .collect(); + per_space.sort_by_key(|s| s.space_id); + Ok(per_space) +} + +/// Outcome of [`upsert_inbox`]. Lets callers emit `loop://changed` only on a real +/// change (`Created`/`Updated`) and stay silent on a no-op recurrence +/// (`Unchanged`), so a card repeated every driver tick never spams the realtime +/// channel. The resulting row is carried in every variant. +pub enum InboxUpsert { + Created(loop_inbox_item::Model), + Updated(loop_inbox_item::Model), + Unchanged(loop_inbox_item::Model), +} + +impl InboxUpsert { + /// The resulting row, whether or not it changed. + pub fn into_model(self) -> loop_inbox_item::Model { + match self { + InboxUpsert::Created(m) | InboxUpsert::Updated(m) | InboxUpsert::Unchanged(m) => m, + } + } + + /// Borrow the resulting row. + pub fn model(&self) -> &loop_inbox_item::Model { + match self { + InboxUpsert::Created(m) | InboxUpsert::Updated(m) | InboxUpsert::Unchanged(m) => m, + } + } + + /// True when a card was created or its payload changed — i.e. when the caller + /// should emit a realtime change event. `Unchanged` returns false. + pub fn changed(&self) -> bool { + matches!(self, InboxUpsert::Created(_) | InboxUpsert::Updated(_)) + } +} + +/// Shallow-merge `new` over `base`: when both are JSON objects, each key of `new` +/// overwrites/extends `base` (new keys win, base-only keys preserved). Otherwise +/// `new` replaces `base` wholesale. Preserves diagnostic fields (`failure_sig`, +/// `attempt`, `stage`, output tails) that a thinner recurrence omits (Codex r2 N1). +fn shallow_merge(base: serde_json::Value, new: serde_json::Value) -> serde_json::Value { + match (base, new) { + (serde_json::Value::Object(mut b), serde_json::Value::Object(n)) => { + for (k, v) in n { + b.insert(k, v); + } + serde_json::Value::Object(b) + } + (_, new) => new, + } +} + +/// Insert a pending inbox card, or fold a recurrence into the existing pending one +/// with the same `(issue_id, kind, subject_key)` — recovery and repeated ticks +/// must not stack duplicate cards (also guarded by `uniq_inbox_pending`). +/// +/// On recurrence the new payload is **merge-preserved** over the existing one +/// (never dropping fields a thinner payload omits): an equal merge yields +/// `Unchanged` (no write, no event); a differing merge is persisted and yields +/// `Updated`. A first occurrence yields `Created`. +pub async fn upsert_inbox( + conn: &sea_orm::DatabaseConnection, + space_id: i32, + issue_id: i32, + iteration_id: Option, + kind: InboxKind, + subject_key: &str, + payload: serde_json::Value, +) -> Result { + if let Some(existing) = loop_inbox_item::Entity::find() + .filter(loop_inbox_item::Column::IssueId.eq(issue_id)) + .filter(loop_inbox_item::Column::Kind.eq(kind)) + .filter(loop_inbox_item::Column::SubjectKey.eq(subject_key)) + .filter(loop_inbox_item::Column::Status.eq(InboxStatus::Pending)) + .one(conn) + .await? + { + let existing_payload: serde_json::Value = + serde_json::from_str(&existing.payload).unwrap_or(serde_json::Value::Null); + let merged = shallow_merge(existing_payload.clone(), payload); + if merged == existing_payload { + return Ok(InboxUpsert::Unchanged(existing)); + } + let mut active = existing.into_active_model(); + active.payload = Set(merged.to_string()); + return Ok(InboxUpsert::Updated(active.update(conn).await?)); + } + let inserted = loop_inbox_item::ActiveModel { + space_id: Set(space_id), + issue_id: Set(issue_id), + iteration_id: Set(iteration_id), + kind: Set(kind), + subject_key: Set(subject_key.to_string()), + payload: Set(payload.to_string()), + status: Set(InboxStatus::Pending), + resolution: Set(None), + created_at: Set(Utc::now()), + handled_at: Set(None), + ..Default::default() + } + .insert(conn) + .await?; + Ok(InboxUpsert::Created(inserted)) +} + +/// How a card's `subject_key` resolves to the artifact it concerns (D9). The +/// `{prefix}:{id}` suffix means different things per family, so the prefix is +/// classified first and the id resolved accordingly — an issue-keyed suffix is +/// NEVER treated as an artifact id (Codex r1 I4). +enum SubjectResolution { + /// Task-level: the id IS a task artifact id (task ≡ artifact). + Artifact(i32), + /// The named issue's live design artifact. + DesignOf(i32), + /// The named issue's live result artifact. + ResultOf(i32), + /// The named iteration's `target_artifact_id`. + IterationTarget(i32), + /// Issue-level card (no backing artifact) or an unknown prefix. + None, +} + +/// Split a `{prefix}:{id}` subject key. Returns `None` if it has no integer tail. +fn parse_subject_key(key: &str) -> Option<(&str, i32)> { + let (prefix, rest) = key.split_once(':')?; + Some((prefix, rest.parse::().ok()?)) +} + +/// Classify a card into its resolution intent (no DB access). `iteration_id` is +/// the card's column (authoritative for iteration-keyed cards). +fn classify_subject( + subject_key: &str, + payload: &serde_json::Value, + iteration_id: Option, +) -> SubjectResolution { + let parsed = parse_subject_key(subject_key); + let prefix = parsed.map(|(p, _)| p).unwrap_or(""); + let suffix = parsed.map(|(_, id)| id); + match prefix { + // task ≡ artifact: prefer the payload's explicit artifact id; the suffix is + // itself the task artifact id (no separate task id), so it is a safe fallback. + "no_progress" | "validation_blocked" | "infra_failure" | "oscillation" => payload + .get("task_artifact_id") + .or_else(|| payload.get("node_artifact_id")) + .and_then(|v| v.as_i64()) + .map(|n| n as i32) + .or(suffix) + .map(SubjectResolution::Artifact) + .unwrap_or(SubjectResolution::None), + // issue-keyed: the suffix is an ISSUE id — resolve via the issue's artifact, + // never as an artifact id directly. + "design" | "design_rejected" => { + suffix.map(SubjectResolution::DesignOf).unwrap_or(SubjectResolution::None) + } + "merge" | "merge_blocked" | "merge_rejected" | "finalize_dirty" | "unverifiable" + | "integration_gap" => { + suffix.map(SubjectResolution::ResultOf).unwrap_or(SubjectResolution::None) + } + // iteration-keyed: the `iteration_id` column is authoritative. `dispatch_failed` + // / `stalled` also carry it as the suffix; `question`'s suffix is a question id + // (NOT an iteration id), so it relies on the column only. + "dispatch_failed" | "stalled" => iteration_id + .or(suffix) + .map(SubjectResolution::IterationTarget) + .unwrap_or(SubjectResolution::None), + "question" => iteration_id + .map(SubjectResolution::IterationTarget) + .unwrap_or(SubjectResolution::None), + // issue-level cards (budget, coverage_gap, …) and any unknown prefix have no + // backing artifact → the frontend roots them at the issue. + _ => SubjectResolution::None, + } +} + +/// `issue_id → live artifact id` for one kind (latest non-dead). Batched (no N+1). +async fn live_artifact_by_issue( + conn: &sea_orm::DatabaseConnection, + issue_ids: &[i32], + kind: ArtifactKind, +) -> Result, DbError> { + if issue_ids.is_empty() { + return Ok(HashMap::new()); + } + let mut map = HashMap::new(); + // Ascending id, overwrite → the highest live id (the latest) wins per issue. + for a in loop_artifact::Entity::find() + .filter(loop_artifact::Column::IssueId.is_in(issue_ids.to_vec())) + .filter(loop_artifact::Column::Kind.eq(kind)) + .filter(loop_artifact::Column::Status.ne(ArtifactStatus::Superseded)) + .filter(loop_artifact::Column::Status.ne(ArtifactStatus::Cancelled)) + .order_by_asc(loop_artifact::Column::Id) + .all(conn) + .await? + { + map.insert(a.issue_id, a.id); + } + Ok(map) +} + +pub async fn list_inbox( + conn: &sea_orm::DatabaseConnection, + space_id: i32, + status: Option, +) -> Result, DbError> { + let seqs: HashMap = loop_issue::Entity::find() + .filter(loop_issue::Column::SpaceId.eq(space_id)) + .all(conn) + .await? + .into_iter() + .map(|i| (i.id, i.seq_no)) + .collect(); + let mut query = loop_inbox_item::Entity::find() + .filter(loop_inbox_item::Column::SpaceId.eq(space_id)) + .order_by_desc(loop_inbox_item::Column::Id); + if let Some(status) = status { + query = query.filter(loop_inbox_item::Column::Status.eq(status)); + } + let models = query.all(conn).await?; + + // Classify every card's subject (no DB access), then resolve in a few batched + // queries (D9): issue→design, issue→result, iteration→target, then id→title. + let resolutions: Vec = models + .iter() + .map(|m| { + let payload = serde_json::from_str(&m.payload).unwrap_or(serde_json::Value::Null); + classify_subject(&m.subject_key, &payload, m.iteration_id) + }) + .collect(); + + let (mut design_issue_ids, mut result_issue_ids, mut iteration_ids) = + (Vec::new(), Vec::new(), Vec::new()); + for r in &resolutions { + match r { + SubjectResolution::DesignOf(id) => design_issue_ids.push(*id), + SubjectResolution::ResultOf(id) => result_issue_ids.push(*id), + SubjectResolution::IterationTarget(id) => iteration_ids.push(*id), + SubjectResolution::Artifact(_) | SubjectResolution::None => {} + } + } + + let design_by_issue = live_artifact_by_issue(conn, &design_issue_ids, ArtifactKind::Design).await?; + let result_by_issue = live_artifact_by_issue(conn, &result_issue_ids, ArtifactKind::Result).await?; + let target_by_iter: HashMap> = if iteration_ids.is_empty() { + HashMap::new() + } else { + loop_iteration::Entity::find() + .filter(loop_iteration::Column::Id.is_in(iteration_ids)) + .all(conn) + .await? + .into_iter() + .map(|it| (it.id, it.target_artifact_id)) + .collect() + }; + + // Resolve each card to its artifact id (Option), then fetch all titles at once. + let resolved_ids: Vec> = resolutions + .iter() + .map(|r| match r { + SubjectResolution::Artifact(id) => Some(*id), + SubjectResolution::DesignOf(id) => design_by_issue.get(id).copied(), + SubjectResolution::ResultOf(id) => result_by_issue.get(id).copied(), + SubjectResolution::IterationTarget(id) => target_by_iter.get(id).copied().flatten(), + SubjectResolution::None => None, + }) + .collect(); + + let artifact_ids: Vec = resolved_ids.iter().flatten().copied().collect(); + let titles: HashMap = if artifact_ids.is_empty() { + HashMap::new() + } else { + loop_artifact::Entity::find() + .filter(loop_artifact::Column::Id.is_in(artifact_ids)) + .all(conn) + .await? + .into_iter() + .map(|a| (a.id, a.title)) + .collect() + }; + + Ok(models + .into_iter() + .zip(resolved_ids) + .map(|(m, art_id)| { + let seq = *seqs.get(&m.issue_id).unwrap_or(&0); + let mut row = to_row(m, seq); + row.subject_artifact_id = art_id; + row.subject_title = art_id.and_then(|id| titles.get(&id).cloned()); + row + }) + .collect()) +} + +/// Fetch a single inbox item by id — used by the command layer to guard a +/// dismiss to informational cards before marking it handled. +pub async fn get_inbox( + conn: &sea_orm::DatabaseConnection, + id: i32, +) -> Result, DbError> { + Ok(loop_inbox_item::Entity::find_by_id(id).one(conn).await?) +} + +/// Mark a pending card handled. Returns `true` if it actually transitioned a +/// pending card to handled, `false` if it was already handled (idempotent) — so +/// callers emit `loop://changed` (the badge dropping) only on a real change. +pub async fn handle_inbox( + conn: &impl ConnectionTrait, + id: i32, + resolution: serde_json::Value, +) -> Result { + let row = loop_inbox_item::Entity::find_by_id(id) + .one(conn) + .await? + .ok_or_else(|| { + DbError::Database(sea_orm::DbErr::RecordNotFound(format!("loop_inbox_item {id}"))) + })?; + if row.status == InboxStatus::Handled { + return Ok(false); + } + let mut active = row.into_active_model(); + active.status = Set(InboxStatus::Handled); + active.resolution = Set(Some(resolution.to_string())); + active.handled_at = Set(Some(Utc::now())); + active.update(conn).await?; + Ok(true) +} + +/// Resolve a single task's pending blocker cards by task-level `subject_key` +/// (`{prefix}:{task_id}` for each prefix in `subjects` — `no_progress` / +/// `validation_blocked` / `infra_failure` / `oscillation`). Used by the +/// oscillation promotion and `retry` (both EXCLUDING `oscillation`, which clears +/// only via an explicit human exit) and by force-complete / override (INCLUDING +/// `oscillation`). Returns how many cards it actually handled (callers may emit on +/// `> 0`). Takes `&impl ConnectionTrait` so it runs both directly and inside the +/// exit-action transactions (C8/C10). +pub async fn resolve_task_blocker_cards( + conn: &impl ConnectionTrait, + issue_id: i32, + task_id: i32, + subjects: &[&str], + resolution: serde_json::Value, +) -> Result { + let keys: Vec = subjects.iter().map(|p| format!("{p}:{task_id}")).collect(); + let cards = loop_inbox_item::Entity::find() + .filter(loop_inbox_item::Column::IssueId.eq(issue_id)) + .filter(loop_inbox_item::Column::Status.eq(InboxStatus::Pending)) + .filter(loop_inbox_item::Column::SubjectKey.is_in(keys)) + .all(conn) + .await?; + let mut n = 0; + for c in cards { + if handle_inbox(conn, c.id, resolution.clone()).await? { + n += 1; + } + } + Ok(n) +} + +/// D15: the `failure_sig` of a task's MOST RECENT pending blocker card — the +/// authoritative CURRENT block reason (Codex r1). Force-complete gates on THIS, +/// not the artifact's `recent/last_failure_sig` columns, which non-no-progress +/// block paths (validation-unrunnable, infra) leave stale: a task re-blocked for +/// validation after an earlier empty-diff must NOT pass the empty-diff guard. +/// Returns None when there is no pending blocker card or it carries no +/// `failure_sig` (e.g. a validation/infra card). +pub async fn task_blocker_failure_sig( + conn: &impl ConnectionTrait, + issue_id: i32, + task_id: i32, +) -> Result, DbError> { + let keys: Vec = ["no_progress", "validation_blocked", "infra_failure", "oscillation"] + .iter() + .map(|p| format!("{p}:{task_id}")) + .collect(); + let card = loop_inbox_item::Entity::find() + .filter(loop_inbox_item::Column::IssueId.eq(issue_id)) + .filter(loop_inbox_item::Column::Status.eq(InboxStatus::Pending)) + .filter(loop_inbox_item::Column::SubjectKey.is_in(keys)) + .order_by_desc(loop_inbox_item::Column::Id) + .one(conn) + .await?; + Ok(card.and_then(|c| { + serde_json::from_str::(&c.payload) + .ok() + .and_then(|p| { + p.get("failure_sig") + .and_then(|v| v.as_str()) + .map(String::from) + }) + })) +} + +/// D17: whether the task currently has a pending `oscillation:{task_id}` card — the +/// precondition for `override_oscillation`. Distinguishes a genuinely breaker-promoted +/// task from any other blocked task, so the override endpoint can reject a generic +/// blocked-task reset (the UI only ever offers override on oscillation cards). +pub async fn has_pending_oscillation_card( + conn: &impl ConnectionTrait, + issue_id: i32, + task_id: i32, +) -> Result { + Ok(loop_inbox_item::Entity::find() + .filter(loop_inbox_item::Column::IssueId.eq(issue_id)) + .filter(loop_inbox_item::Column::SubjectKey.eq(format!("oscillation:{task_id}"))) + .filter(loop_inbox_item::Column::Status.eq(InboxStatus::Pending)) + .one(conn) + .await? + .is_some()) +} + +/// D13: resolve ALL of an issue's pending `Blocked`-kind cards EXCEPT task-level +/// `oscillation:` cards (those clear only via override / force-complete). This is +/// the broad `retry` sweep — every issue-level block (dirty finalize, merge fault, +/// dependency, ...) plus the re-armed tasks' ordinary blockers — without +/// enumerating subjects, so it never drifts as new block reasons are added. +/// Returns how many it handled. +pub async fn resolve_blocked_cards_except_oscillation( + conn: &impl ConnectionTrait, + issue_id: i32, + resolution: serde_json::Value, +) -> Result { + let cards = loop_inbox_item::Entity::find() + .filter(loop_inbox_item::Column::IssueId.eq(issue_id)) + .filter(loop_inbox_item::Column::Kind.eq(InboxKind::Blocked)) + .filter(loop_inbox_item::Column::Status.eq(InboxStatus::Pending)) + .all(conn) + .await?; + let mut n = 0; + for c in cards { + if c.subject_key.starts_with("oscillation:") { + continue; + } + if handle_inbox(conn, c.id, resolution.clone()).await? { + n += 1; + } + } + Ok(n) +} diff --git a/src-tauri/src/db/service/loop_service/issue.rs b/src-tauri/src/db/service/loop_service/issue.rs new file mode 100644 index 0000000000..d0494ba90f --- /dev/null +++ b/src-tauri/src/db/service/loop_service/issue.rs @@ -0,0 +1,258 @@ +use chrono::Utc; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, QueryOrder, Set, + TransactionTrait, +}; + +use crate::db::entities::conversation::{self, ConversationKind}; +use crate::db::entities::loop_artifact::{ArtifactKind, ArtifactStatus}; +use crate::db::entities::loop_artifact_revision::ActorKind; +use crate::db::entities::loop_issue::{IssuePriority, IssueRoute, IssueStatus}; +use crate::db::entities::{folder, loop_artifact, loop_artifact_revision, loop_issue}; +use crate::db::error::DbError; +use crate::models::loops::{IssueConfig, LoopIssueDetail, LoopIssueRow}; + +fn not_found(id: i32) -> DbError { + DbError::Database(sea_orm::DbErr::RecordNotFound(format!("loop_issue {id}"))) +} + +/// Map a serde error from config (de)serialization to a `DbError` so it +/// propagates through the service layer like any other persistence failure. +fn config_err(e: serde_json::Error) -> DbError { + DbError::Database(sea_orm::DbErr::Custom(format!("loop issue config: {e}"))) +} + +pub fn to_issue_row(m: &loop_issue::Model) -> LoopIssueRow { + LoopIssueRow { + id: m.id, + space_id: m.space_id, + seq_no: m.seq_no, + title: m.title.clone(), + priority: m.priority.clone(), + status: m.status.clone(), + pause_reason: m.pause_reason.clone(), + route: m.route, + token_used: m.token_used, + token_budget: m.token_budget, + // Filled by `list_issues` from a batched aggregate (B2); 0 otherwise. + blocking_count: 0, + notice_count: 0, + created_at: m.created_at, + updated_at: m.updated_at, + } +} + +pub fn to_issue_detail(m: loop_issue::Model) -> Result { + // `config = NULL` → inheriting (None). A malformed stored config is a real + // error, not a silent fall-back to inherit. + let config = m + .config + .as_deref() + .map(serde_json::from_str::) + .transpose() + .map_err(config_err)?; + let row = to_issue_row(&m); + Ok(LoopIssueDetail { + row, + description: m.description, + config, + worktree_folder_id: m.worktree_folder_id, + base_branch: m.base_branch, + base_commit: m.base_commit, + }) +} + +/// Create an issue and its root `kind = issue` artifact (with a first revision +/// holding the description) in one transaction. The issue starts `pending` +/// (awaiting an explicit human trigger) with route `undecided`. +pub async fn create_issue( + conn: &sea_orm::DatabaseConnection, + space_id: i32, + title: &str, + description: &str, + priority: IssuePriority, + config: Option<&IssueConfig>, +) -> Result { + let now = Utc::now(); + // `None` → stored `config = NULL` (the issue inherits the space default). + let config_json = config + .map(serde_json::to_string) + .transpose() + .map_err(config_err)?; + + let txn = conn.begin().await?; + + let seq_no = loop_issue::Entity::find() + .filter(loop_issue::Column::SpaceId.eq(space_id)) + .order_by_desc(loop_issue::Column::SeqNo) + .one(&txn) + .await? + .map(|m| m.seq_no + 1) + .unwrap_or(1); + + let issue = loop_issue::ActiveModel { + space_id: Set(space_id), + seq_no: Set(seq_no), + title: Set(title.to_string()), + description: Set(description.to_string()), + priority: Set(priority), + status: Set(IssueStatus::Pending), + pause_reason: Set(None), + route: Set(IssueRoute::Undecided), + config: Set(config_json), + worktree_folder_id: Set(None), + base_branch: Set(None), + base_commit: Set(None), + token_used: Set(0), + token_budget: Set(None), + created_at: Set(now), + updated_at: Set(now), + triggered_at: Set(None), + ended_at: Set(None), + ..Default::default() + } + .insert(&txn) + .await?; + + let root = loop_artifact::ActiveModel { + space_id: Set(space_id), + issue_id: Set(issue.id), + kind: Set(ArtifactKind::Issue), + title: Set(title.to_string()), + status: Set(ArtifactStatus::Done), + origin: Set(ActorKind::Human), + produced_by_iteration_id: Set(None), + verdict: Set(None), + attempt: Set(0), + last_failure_sig: Set(None), + sort: Set(0), + created_at: Set(now), + updated_at: Set(now), + ..Default::default() + } + .insert(&txn) + .await?; + + loop_artifact_revision::ActiveModel { + artifact_id: Set(root.id), + seq: Set(1), + content: Set(description.to_string()), + actor_kind: Set(ActorKind::Human), + iteration_id: Set(None), + created_at: Set(now), + ..Default::default() + } + .insert(&txn) + .await?; + + txn.commit().await?; + to_issue_detail(issue) +} + +pub async fn get_issue( + conn: &sea_orm::DatabaseConnection, + id: i32, +) -> Result, DbError> { + Ok(loop_issue::Entity::find_by_id(id).one(conn).await?) +} + +pub async fn get_issue_detail( + conn: &sea_orm::DatabaseConnection, + id: i32, +) -> Result, DbError> { + loop_issue::Entity::find_by_id(id) + .one(conn) + .await? + .map(to_issue_detail) + .transpose() +} + +pub async fn list_issues( + conn: &sea_orm::DatabaseConnection, + space_id: i32, + statuses: Option>, +) -> Result, DbError> { + let mut query = loop_issue::Entity::find() + .filter(loop_issue::Column::SpaceId.eq(space_id)) + .order_by_desc(loop_issue::Column::SeqNo); + if let Some(statuses) = statuses { + if !statuses.is_empty() { + query = query.filter(loop_issue::Column::Status.is_in(statuses)); + } + } + let models = query.all(conn).await?; + // D6: batch the per-issue pending-inbox attention in one query, no N+1. + let issue_ids: Vec = models.iter().map(|m| m.id).collect(); + let attention = super::inbox::aggregate_for_issues(conn, &issue_ids).await?; + Ok(models + .iter() + .map(|m| { + let mut row = to_issue_row(m); + if let Some(&(blocking, notice)) = attention.get(&m.id) { + row.blocking_count = blocking; + row.notice_count = notice; + } + row + }) + .collect()) +} + +pub async fn delete_issue(conn: &sea_orm::DatabaseConnection, id: i32) -> Result<(), DbError> { + loop_issue::Entity::delete_by_id(id).exec(conn).await?; + Ok(()) +} + +/// Set an issue's config. `Some(c)` stores `c` as the issue's own config; +/// `None` stores `NULL` so it inherits the space default (resolved at read time). +pub async fn update_issue_config( + conn: &sea_orm::DatabaseConnection, + id: i32, + config: Option<&IssueConfig>, + token_budget: Option, +) -> Result<(), DbError> { + let row = loop_issue::Entity::find_by_id(id) + .one(conn) + .await? + .ok_or_else(|| not_found(id))?; + let mut active = row.into_active_model(); + active.config = Set(config + .map(serde_json::to_string) + .transpose() + .map_err(config_err)?); + active.token_budget = Set(token_budget); + active.updated_at = Set(Utc::now()); + active.update(conn).await?; + Ok(()) +} + +/// All issue models for a space (full rows). Used at delete time, which needs +/// each issue's `worktree_folder_id` for cross-subsystem cleanup. +pub async fn list_models_for_space( + conn: &sea_orm::DatabaseConnection, + space_id: i32, +) -> Result, DbError> { + Ok(loop_issue::Entity::find() + .filter(loop_issue::Column::SpaceId.eq(space_id)) + .all(conn) + .await?) +} + +/// Hard-delete the cross-subsystem rows an issue's engine worktree left behind: +/// its `kind = loop` conversations and the worktree `folder` row. The `loop_*` +/// CASCADE never reaches these — they live in the `conversation` / `folder` +/// tables, referenced only by plain (FK-less) columns. Call before deleting the +/// issue/space row. +pub async fn cleanup_worktree_rows( + conn: &sea_orm::DatabaseConnection, + worktree_folder_id: i32, +) -> Result<(), DbError> { + conversation::Entity::delete_many() + .filter(conversation::Column::FolderId.eq(worktree_folder_id)) + .filter(conversation::Column::Kind.eq(ConversationKind::Loop)) + .exec(conn) + .await?; + folder::Entity::delete_by_id(worktree_folder_id) + .exec(conn) + .await?; + Ok(()) +} diff --git a/src-tauri/src/db/service/loop_service/iteration.rs b/src-tauri/src/db/service/loop_service/iteration.rs new file mode 100644 index 0000000000..91e2205471 --- /dev/null +++ b/src-tauri/src/db/service/loop_service/iteration.rs @@ -0,0 +1,875 @@ +use std::collections::HashMap; + +use sea_orm::sea_query::Expr; +use sea_orm::{ActiveEnum, ColumnTrait, EntityTrait, QueryFilter, QueryOrder}; + +use crate::db::entities::loop_iteration::IterationOutcome; +use crate::db::entities::{loop_artifact, loop_issue, loop_iteration}; +use crate::db::error::DbError; +use crate::models::loops::LoopIterationRow; + +fn to_iteration_row( + m: &loop_iteration::Model, + issue_seq: i32, + target_title: Option, + agent_type: Option, +) -> LoopIterationRow { + LoopIterationRow { + id: m.id, + issue_id: m.issue_id, + issue_seq, + stage: m.stage, + target_artifact_id: m.target_artifact_id, + target_title, + conversation_id: m.conversation_id, + agent_type, + status: m.status, + launched_by: m.launched_by, + attempt: m.attempt, + tokens_used: m.tokens_used, + outcome: m.outcome, + created_at: m.created_at, + started_at: m.started_at, + ended_at: m.ended_at, + } +} + +/// Batch-resolve `conversation_id → agent_type` for a set of iterations (P3 facet). +/// `conversation.agent_type` is the serde wire form of `AgentType` (a plain String +/// column), so the value is passed through untransformed — the frontend maps it to +/// its `AgentType` union with an icon fallback for unknown values. Iterations with +/// no `conversation_id` simply have no entry. One query, no N+1. +async fn agent_types( + conn: &impl sea_orm::ConnectionTrait, + iterations: &[loop_iteration::Model], +) -> Result, DbError> { + use crate::db::entities::conversation; + let ids: Vec = iterations + .iter() + .filter_map(|i| i.conversation_id) + .collect(); + if ids.is_empty() { + return Ok(HashMap::new()); + } + Ok(conversation::Entity::find() + .filter(conversation::Column::Id.is_in(ids)) + .all(conn) + .await? + .into_iter() + .map(|c| (c.id, c.agent_type)) + .collect()) +} + +pub async fn get_iteration( + conn: &sea_orm::DatabaseConnection, + id: i32, +) -> Result, DbError> { + Ok(loop_iteration::Entity::find_by_id(id).one(conn).await?) +} + +/// D12: the reason from the most recent implement iteration of `task_id` that +/// declared the task already complete (via `loop_task_complete`) AND routed there +/// as a genuine no-op, if any. The review briefing surfaces it so the reviewer +/// verifies the acceptance criteria against the current worktree HEAD rather than +/// expecting a fresh checkpoint commit to inspect. +/// +/// Gated on `outcome = declared_complete` (Codex r1): an agent that calls +/// `loop_task_complete` but ALSO makes a real diff settles with +/// `outcome = succeeded` (the non-empty checkpoint path), so its reason must NOT +/// surface the misleading "no checkpoint commit" note. Only the actual empty-diff +/// declared path records `declared_complete`. +pub async fn latest_declared_completion_reason( + conn: &impl sea_orm::ConnectionTrait, + issue_id: i32, + task_id: i32, +) -> Result, DbError> { + Ok(loop_iteration::Entity::find() + .filter(loop_iteration::Column::IssueId.eq(issue_id)) + .filter(loop_iteration::Column::TargetArtifactId.eq(task_id)) + .filter(loop_iteration::Column::Stage.eq(loop_iteration::Stage::Implement)) + .filter(loop_iteration::Column::Outcome.eq(IterationOutcome::DeclaredComplete)) + .filter(loop_iteration::Column::AgentCompletionReason.is_not_null()) + .order_by_desc(loop_iteration::Column::Id) + .one(conn) + .await? + .and_then(|m| m.agent_completion_reason)) +} + +/// D12: clear the declared-completion reason on ALL of a task's implement +/// iterations. Called when review REJECTS a declared no-op, so a stale claim can +/// never route a future empty attempt straight to review again (the next empty +/// diff must be treated as genuine no-progress). +pub async fn clear_declared_completion( + conn: &impl sea_orm::ConnectionTrait, + issue_id: i32, + task_id: i32, +) -> Result<(), DbError> { + loop_iteration::Entity::update_many() + .col_expr( + loop_iteration::Column::AgentCompletionReason, + Expr::value(Option::::None), + ) + .filter(loop_iteration::Column::IssueId.eq(issue_id)) + .filter(loop_iteration::Column::TargetArtifactId.eq(task_id)) + .filter(loop_iteration::Column::Stage.eq(loop_iteration::Stage::Implement)) + .exec(conn) + .await?; + Ok(()) +} + +/// Write-once outcome (D11): set `outcome` only while it is still NULL. Returns +/// `true` iff it wrote. Making the column immutable once set means a stale / +/// CAS-lost `abandoned` write can never clobber a real `succeeded` / `empty_diff` +/// / `validation_failed` (Codex r2 C2). The bulk abandon paths additionally filter +/// on the iteration's active status, so they only touch unsettled (NULL) rows. +pub async fn set_iteration_outcome( + conn: &impl sea_orm::ConnectionTrait, + id: i32, + outcome: IterationOutcome, +) -> Result { + let res = loop_iteration::Entity::update_many() + .col_expr(loop_iteration::Column::Outcome, Expr::value(outcome.to_value())) + .filter(loop_iteration::Column::Id.eq(id)) + .filter(loop_iteration::Column::Outcome.is_null()) + .exec(conn) + .await?; + Ok(res.rows_affected == 1) +} + +async fn target_titles( + conn: &impl sea_orm::ConnectionTrait, + iterations: &[loop_iteration::Model], +) -> Result, DbError> { + let ids: Vec = iterations + .iter() + .filter_map(|i| i.target_artifact_id) + .collect(); + if ids.is_empty() { + return Ok(HashMap::new()); + } + Ok(loop_artifact::Entity::find() + .filter(loop_artifact::Column::Id.is_in(ids)) + .all(conn) + .await? + .into_iter() + .map(|a| (a.id, a.title)) + .collect()) +} + +pub async fn list_iterations( + conn: &sea_orm::DatabaseConnection, + issue_id: i32, +) -> Result, DbError> { + let issue_seq = loop_issue::Entity::find_by_id(issue_id) + .one(conn) + .await? + .map(|i| i.seq_no) + .unwrap_or(0); + let rows = loop_iteration::Entity::find() + .filter(loop_iteration::Column::IssueId.eq(issue_id)) + .order_by_desc(loop_iteration::Column::Id) + .all(conn) + .await?; + let titles = target_titles(conn, &rows).await?; + let agents = agent_types(conn, &rows).await?; + Ok(rows + .iter() + .map(|m| { + let title = m + .target_artifact_id + .and_then(|tid| titles.get(&tid).cloned()); + let agent_type = m + .conversation_id + .and_then(|cid| agents.get(&cid).cloned()); + to_iteration_row(m, issue_seq, title, agent_type) + }) + .collect()) +} + +/// In-flight (`queued`|`running`) iterations for an issue, ascending by id. +/// Powers the real-time DAG/board ghost nodes + stage rail (spec D1); rides on +/// `LoopDagView.live_iterations` so the graph view is a single authoritative fetch. +pub async fn list_live_for_issue( + conn: &impl sea_orm::ConnectionTrait, + issue_id: i32, +) -> Result, DbError> { + use crate::db::entities::loop_iteration::IterationStatus; + let issue_seq = loop_issue::Entity::find_by_id(issue_id) + .one(conn) + .await? + .map(|i| i.seq_no) + .unwrap_or(0); + let rows = loop_iteration::Entity::find() + .filter(loop_iteration::Column::IssueId.eq(issue_id)) + .filter( + loop_iteration::Column::Status + .is_in([IterationStatus::Queued, IterationStatus::Running]), + ) + .order_by_asc(loop_iteration::Column::Id) + .all(conn) + .await?; + let titles = target_titles(conn, &rows).await?; + let agents = agent_types(conn, &rows).await?; + Ok(rows + .iter() + .map(|m| { + let title = m + .target_artifact_id + .and_then(|tid| titles.get(&tid).cloned()); + let agent_type = m + .conversation_id + .and_then(|cid| agents.get(&cid).cloned()); + to_iteration_row(m, issue_seq, title, agent_type) + }) + .collect()) +} + +pub async fn list_iterations_for_space( + conn: &sea_orm::DatabaseConnection, + space_id: i32, +) -> Result, DbError> { + let seqs: HashMap = loop_issue::Entity::find() + .filter(loop_issue::Column::SpaceId.eq(space_id)) + .all(conn) + .await? + .into_iter() + .map(|i| (i.id, i.seq_no)) + .collect(); + let rows = loop_iteration::Entity::find() + .filter(loop_iteration::Column::SpaceId.eq(space_id)) + .order_by_desc(loop_iteration::Column::Id) + .all(conn) + .await?; + let titles = target_titles(conn, &rows).await?; + let agents = agent_types(conn, &rows).await?; + Ok(rows + .iter() + .map(|m| { + let title = m + .target_artifact_id + .and_then(|tid| titles.get(&tid).cloned()); + let agent_type = m + .conversation_id + .and_then(|cid| agents.get(&cid).cloned()); + to_iteration_row(m, *seqs.get(&m.issue_id).unwrap_or(&0), title, agent_type) + }) + .collect()) +} + +/// Iterations that targeted `artifact_id` — the artifact's own timeline (a task's +/// implement + review attempts, the producing run for a requirement/design/etc.), +/// ascending by `(attempt, id)`, each carrying its producing agent. Issue-bounded +/// by construction (a target artifact belongs to one issue). Powers the artifact +/// drawer's lazy, targeted iteration history (spec §4.4) instead of an +/// O(all-issue) scan. Empty when the artifact doesn't exist. +pub async fn list_iterations_for_artifact( + conn: &sea_orm::DatabaseConnection, + artifact_id: i32, +) -> Result, DbError> { + let Some(artifact) = loop_artifact::Entity::find_by_id(artifact_id).one(conn).await? else { + return Ok(Vec::new()); + }; + let issue_seq = loop_issue::Entity::find_by_id(artifact.issue_id) + .one(conn) + .await? + .map(|i| i.seq_no) + .unwrap_or(0); + let rows = loop_iteration::Entity::find() + .filter(loop_iteration::Column::TargetArtifactId.eq(artifact_id)) + .order_by_asc(loop_iteration::Column::Attempt) + .order_by_asc(loop_iteration::Column::Id) + .all(conn) + .await?; + let titles = target_titles(conn, &rows).await?; + let agents = agent_types(conn, &rows).await?; + Ok(rows + .iter() + .map(|m| { + let title = m + .target_artifact_id + .and_then(|tid| titles.get(&tid).cloned()); + let agent_type = m + .conversation_id + .and_then(|cid| agents.get(&cid).cloned()); + to_iteration_row(m, issue_seq, title, agent_type) + }) + .collect()) +} + +/// Phase-level (artifact-less) iterations for `(issue_id, stage)` — the triage / +/// finalize sessions that have no target artifact, ascending by `(attempt, id)`, +/// each carrying its producing agent. `target_artifact_id IS NULL` is enforced +/// HERE (server-side), mirroring the model's artifact-less `sessionRefs` guard, so +/// a stray targeted triage/finalize row can never leak into the issue/result +/// drawer history (Codex r2). +pub async fn list_iterations_for_phase( + conn: &sea_orm::DatabaseConnection, + issue_id: i32, + stage: loop_iteration::Stage, +) -> Result, DbError> { + let issue_seq = loop_issue::Entity::find_by_id(issue_id) + .one(conn) + .await? + .map(|i| i.seq_no) + .unwrap_or(0); + let rows = loop_iteration::Entity::find() + .filter(loop_iteration::Column::IssueId.eq(issue_id)) + .filter(loop_iteration::Column::Stage.eq(stage)) + .filter(loop_iteration::Column::TargetArtifactId.is_null()) + .order_by_asc(loop_iteration::Column::Attempt) + .order_by_asc(loop_iteration::Column::Id) + .all(conn) + .await?; + let agents = agent_types(conn, &rows).await?; + Ok(rows + .iter() + .map(|m| { + let agent_type = m + .conversation_id + .and_then(|cid| agents.get(&cid).cloned()); + to_iteration_row(m, issue_seq, None, agent_type) + }) + .collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::entities::loop_artifact::{ArtifactKind, ArtifactStatus}; + use crate::db::entities::loop_artifact_revision::ActorKind; + use crate::db::entities::loop_issue::IssuePriority; + use crate::db::entities::loop_iteration::{IterationStatus, Stage}; + use crate::db::service::loop_service::{artifact, issue, space}; + use crate::db::test_helpers::{fresh_in_memory_db, seed_conversation, seed_folder}; + use crate::models::agent::AgentType; + use crate::loop_engine::transitions::{ + cas_iteration_status, try_claim_iteration, IterationClaim, + }; + use crate::models::loops::IssueConfig; + + /// `list_live_for_issue` returns only `queued`|`running` iterations, carrying + /// stage/target/title — the contract `list_dag.live_iterations` relies on. + #[tokio::test] + async fn list_live_returns_only_queued_and_running() { + let db = fresh_in_memory_db().await; + let folder = seed_folder(&db, "/repo").await; + let sp = space::create_space(&db.conn, "S", folder).await.unwrap(); + let iss = issue::create_issue( + &db.conn, + sp.id, + "I", + "b", + IssuePriority::Medium, + Some(&IssueConfig::default()), + ) + .await + .unwrap(); + let task = artifact::create_artifact( + &db.conn, + sp.id, + iss.row.id, + ArtifactKind::Task, + "T", + ArtifactStatus::Pending, + ActorKind::Agent, + None, + ) + .await + .unwrap(); + + // A running design iteration → live (carries its target title). + let running = try_claim_iteration( + &db.conn, + IterationClaim { + space_id: sp.id, + issue_id: iss.row.id, + stage: Stage::Design, + target_artifact_id: Some(task.id), + slot_no: None, + capability_token: "t1".into(), + attempt: 0, + }, + ) + .await + .unwrap() + .unwrap(); + assert!(cas_iteration_status( + &db.conn, + running.id, + IterationStatus::Queued, + IterationStatus::Running, + ) + .await + .unwrap()); + + // A succeeded refine iteration (different stage avoids the active-uniq + // index) → NOT live. + let done = try_claim_iteration( + &db.conn, + IterationClaim { + space_id: sp.id, + issue_id: iss.row.id, + stage: Stage::Refine, + target_artifact_id: None, + slot_no: None, + capability_token: "t2".into(), + attempt: 0, + }, + ) + .await + .unwrap() + .unwrap(); + assert!(cas_iteration_status( + &db.conn, + done.id, + IterationStatus::Queued, + IterationStatus::Running, + ) + .await + .unwrap()); + assert!(cas_iteration_status( + &db.conn, + done.id, + IterationStatus::Running, + IterationStatus::Succeeded, + ) + .await + .unwrap()); + + let live = list_live_for_issue(&db.conn, iss.row.id).await.unwrap(); + assert_eq!(live.len(), 1, "only queued|running iterations are live"); + assert_eq!(live[0].id, running.id); + assert_eq!(live[0].stage, Stage::Design); + assert_eq!(live[0].target_artifact_id, Some(task.id)); + assert_eq!(live[0].target_title.as_deref(), Some("T")); + assert_eq!(live[0].status, IterationStatus::Running); + } + + /// Claim an iteration, optionally attach a conversation, then settle it to + /// `Succeeded` (terminal — frees the active-uniq slot so the next claim in the + /// same stage doesn't conflict). Returns the iteration id. + #[allow(clippy::too_many_arguments)] + async fn claim_settled( + conn: &sea_orm::DatabaseConnection, + space_id: i32, + issue_id: i32, + stage: Stage, + target: Option, + attempt: i32, + token: &str, + conversation_id: Option, + ) -> i32 { + let it = try_claim_iteration( + conn, + IterationClaim { + space_id, + issue_id, + stage, + target_artifact_id: target, + slot_no: None, + capability_token: token.into(), + attempt, + }, + ) + .await + .unwrap() + .unwrap(); + if let Some(cid) = conversation_id { + loop_iteration::Entity::update_many() + .col_expr(loop_iteration::Column::ConversationId, Expr::value(cid)) + .filter(loop_iteration::Column::Id.eq(it.id)) + .exec(conn) + .await + .unwrap(); + } + cas_iteration_status(conn, it.id, IterationStatus::Queued, IterationStatus::Running) + .await + .unwrap(); + cas_iteration_status(conn, it.id, IterationStatus::Running, IterationStatus::Succeeded) + .await + .unwrap(); + it.id + } + + async fn set_produced_by(conn: &sea_orm::DatabaseConnection, artifact_id: i32, iteration_id: i32) { + loop_artifact::Entity::update_many() + .col_expr( + loop_artifact::Column::ProducedByIterationId, + Expr::value(iteration_id), + ) + .filter(loop_artifact::Column::Id.eq(artifact_id)) + .exec(conn) + .await + .unwrap(); + } + + /// The phase command enforces `target_artifact_id IS NULL` server-side (Codex + /// r2): a stray TARGETED finalize/triage row must never leak into the issue / + /// result drawer history, mirroring the model's artifact-less sessionRefs guard. + #[tokio::test] + async fn phase_iterations_enforce_target_is_null() { + let db = fresh_in_memory_db().await; + let folder = seed_folder(&db, "/repo").await; + let sp = space::create_space(&db.conn, "S", folder).await.unwrap(); + let iss = issue::create_issue( + &db.conn, + sp.id, + "I", + "b", + IssuePriority::Medium, + Some(&IssueConfig::default()), + ) + .await + .unwrap(); + let task = artifact::create_artifact( + &db.conn, + sp.id, + iss.row.id, + ArtifactKind::Task, + "T", + ArtifactStatus::Pending, + ActorKind::Agent, + None, + ) + .await + .unwrap(); + + // The legitimate artifact-less finalize (the Result node's history). + let phase_final = + claim_settled(&db.conn, sp.id, iss.row.id, Stage::Finalize, None, 0, "f0", None).await; + // A targeted finalize — must be excluded by the server-side NULL filter. + let _targeted = claim_settled( + &db.conn, + sp.id, + iss.row.id, + Stage::Finalize, + Some(task.id), + 0, + "f1", + None, + ) + .await; + // A different-stage (triage) session — also excluded. + let _triage = + claim_settled(&db.conn, sp.id, iss.row.id, Stage::Triage, None, 0, "t0", None).await; + + let rows = list_iterations_for_phase(&db.conn, iss.row.id, Stage::Finalize) + .await + .unwrap(); + assert_eq!(rows.len(), 1, "only the artifact-less finalize iteration"); + assert_eq!(rows[0].id, phase_final); + assert_eq!(rows[0].target_artifact_id, None); + assert_eq!(rows[0].stage, Stage::Finalize); + } + + /// The artifact command returns only iterations targeting that artifact, ordered + /// by `(attempt, id)` (id breaks ties within an equal attempt). + #[tokio::test] + async fn artifact_iterations_filter_by_target() { + let db = fresh_in_memory_db().await; + let folder = seed_folder(&db, "/repo").await; + let sp = space::create_space(&db.conn, "S", folder).await.unwrap(); + let iss = issue::create_issue( + &db.conn, + sp.id, + "I", + "b", + IssuePriority::Medium, + Some(&IssueConfig::default()), + ) + .await + .unwrap(); + let mk = |title: &'static str| { + let conn = db.conn.clone(); + let sid = sp.id; + let iid = iss.row.id; + async move { + artifact::create_artifact( + &conn, + sid, + iid, + ArtifactKind::Task, + title, + ArtifactStatus::Pending, + ActorKind::Agent, + None, + ) + .await + .unwrap() + } + }; + let t1 = mk("T1").await; + let t2 = mk("T2").await; + + // Two implement attempts (att 0,1) + one review (att 0) on T1; one on T2. + let i0 = + claim_settled(&db.conn, sp.id, iss.row.id, Stage::Implement, Some(t1.id), 0, "a", None) + .await; + let i1 = + claim_settled(&db.conn, sp.id, iss.row.id, Stage::Implement, Some(t1.id), 1, "b", None) + .await; + let r0 = + claim_settled(&db.conn, sp.id, iss.row.id, Stage::Review, Some(t1.id), 0, "c", None) + .await; + let _other = + claim_settled(&db.conn, sp.id, iss.row.id, Stage::Implement, Some(t2.id), 0, "d", None) + .await; + + let rows = list_iterations_for_artifact(&db.conn, t1.id).await.unwrap(); + let ids: Vec = rows.iter().map(|r| r.id).collect(); + // attempt 0 group (i0, r0 — by id) then attempt 1 (i1). + assert_eq!(ids, vec![i0, r0, i1]); + assert!(rows.iter().all(|r| r.target_artifact_id == Some(t1.id))); + } + + /// `list_dag` emits a ref ONLY for artifacts whose producer resolves within the + /// issue (orphan / human omitted), with per-kind attempt counts and the joined + /// agent_type (a producer without a conversation resolves to `None`). + #[tokio::test] + async fn dag_refs_resolved_only_with_per_kind_counts_and_agent() { + let db = fresh_in_memory_db().await; + let folder = seed_folder(&db, "/repo").await; + let sp = space::create_space(&db.conn, "S", folder).await.unwrap(); + let iss = issue::create_issue( + &db.conn, + sp.id, + "I", + "b", + IssuePriority::Medium, + Some(&IssueConfig::default()), + ) + .await + .unwrap(); + let conv = seed_conversation(&db, folder, AgentType::Codex).await; + let new_art = |kind: ArtifactKind, title: &'static str, origin: ActorKind| { + let conn = db.conn.clone(); + let sid = sp.id; + let iid = iss.row.id; + async move { + artifact::create_artifact( + &conn, + sid, + iid, + kind, + title, + ArtifactStatus::Done, + origin, + None, + ) + .await + .unwrap() + } + }; + + // Task T: 2 implement + 1 review target it → attempt_count 3; producer carries + // the conversation → agent_type joins to "codex". + let t = new_art(ArtifactKind::Task, "T", ActorKind::Agent).await; + let ti0 = claim_settled( + &db.conn, + sp.id, + iss.row.id, + Stage::Implement, + Some(t.id), + 0, + "ti0", + Some(conv), + ) + .await; + claim_settled(&db.conn, sp.id, iss.row.id, Stage::Implement, Some(t.id), 1, "ti1", None) + .await; + claim_settled(&db.conn, sp.id, iss.row.id, Stage::Review, Some(t.id), 0, "tr", None).await; + set_produced_by(&db.conn, t.id, ti0).await; + + // Result R: produced by 1 of 2 finalize iterations → attempt_count 2 (by stage). + let r = new_art(ArtifactKind::Result, "R", ActorKind::Agent).await; + let rf0 = + claim_settled(&db.conn, sp.id, iss.row.id, Stage::Finalize, None, 0, "rf0", None).await; + claim_settled(&db.conn, sp.id, iss.row.id, Stage::Finalize, None, 1, "rf1", None).await; + set_produced_by(&db.conn, r.id, rf0).await; + + // Review RV: produced by its single review iteration → attempt_count 1. + let rv = new_art(ArtifactKind::Review, "RV", ActorKind::Agent).await; + let rvi = + claim_settled(&db.conn, sp.id, iss.row.id, Stage::Review, Some(rv.id), 0, "rvi", None) + .await; + set_produced_by(&db.conn, rv.id, rvi).await; + + // Orphan: producer not in this issue → NO ref. Human: produced_by NULL → NO ref. + let orphan = new_art(ArtifactKind::Requirement, "ORPH", ActorKind::Agent).await; + set_produced_by(&db.conn, orphan.id, 9_999_999).await; + new_art(ArtifactKind::Issue, "H", ActorKind::Human).await; + + let dag = artifact::list_dag(&db.conn, iss.row.id).await.unwrap(); + let refs = &dag.artifact_iteration_refs; + assert_eq!(refs.len(), 3, "T, R, RV resolve; orphan + human excluded"); + assert!(refs.len() <= dag.artifacts.len(), "bounded by artifact count"); + + let by = |aid: i32| refs.iter().find(|x| x.artifact_id == aid).unwrap(); + assert_eq!(by(t.id).iteration_id, ti0); + assert_eq!(by(t.id).attempt_count, 3, "impl + impl + review target the task"); + assert_eq!(by(t.id).agent_type.as_deref(), Some("codex"), "joined agent_type"); + assert_eq!(by(r.id).attempt_count, 2, "finalize iterations, by stage"); + assert_eq!(by(r.id).agent_type, None, "finalize producer had no conversation"); + assert_eq!(by(rv.id).attempt_count, 1, "review is always its single producer"); + } + + #[tokio::test] + async fn declared_completion_reason_round_trip_and_clear() { + let db = fresh_in_memory_db().await; + let folder = seed_folder(&db, "/repo").await; + let sp = space::create_space(&db.conn, "S", folder).await.unwrap(); + let iss = issue::create_issue( + &db.conn, + sp.id, + "I", + "b", + IssuePriority::Medium, + Some(&IssueConfig::default()), + ) + .await + .unwrap(); + let task = artifact::create_artifact( + &db.conn, + sp.id, + iss.row.id, + ArtifactKind::Task, + "T", + ArtifactStatus::InProgress, + ActorKind::Agent, + None, + ) + .await + .unwrap(); + + // No declaration yet. + assert_eq!( + latest_declared_completion_reason(&db.conn, iss.row.id, task.id) + .await + .unwrap(), + None + ); + + // An implement iteration declares completion. The declared no-op + // settlement path (gates::finish_implement) records BOTH the reason and + // `outcome = declared_complete` — mirror that here so the surfacing query + // (which gates on the outcome, Codex r1) matches production. + let it = try_claim_iteration( + &db.conn, + IterationClaim { + space_id: sp.id, + issue_id: iss.row.id, + stage: Stage::Implement, + target_artifact_id: Some(task.id), + slot_no: None, + capability_token: "tok".into(), + attempt: 0, + }, + ) + .await + .unwrap() + .unwrap(); + loop_iteration::Entity::update_many() + .col_expr( + loop_iteration::Column::AgentCompletionReason, + Expr::value("already satisfied"), + ) + .filter(loop_iteration::Column::Id.eq(it.id)) + .exec(&db.conn) + .await + .unwrap(); + assert!(set_iteration_outcome(&db.conn, it.id, IterationOutcome::DeclaredComplete) + .await + .unwrap()); + + assert_eq!( + latest_declared_completion_reason(&db.conn, iss.row.id, task.id) + .await + .unwrap() + .as_deref(), + Some("already satisfied") + ); + + // Review rejection clears it → a future empty attempt is genuine no-progress. + clear_declared_completion(&db.conn, iss.row.id, task.id) + .await + .unwrap(); + assert_eq!( + latest_declared_completion_reason(&db.conn, iss.row.id, task.id) + .await + .unwrap(), + None + ); + } + + /// Codex r1 regression: an agent that calls `loop_task_complete` but ALSO + /// makes a real diff settles with `outcome = succeeded` (the non-empty + /// checkpoint path), not `declared_complete`. Its stale reason must NOT be + /// surfaced — otherwise the review briefing would wrongly tell the reviewer + /// "no checkpoint commit to inspect" for an iteration that did produce one. + #[tokio::test] + async fn declared_reason_not_surfaced_after_real_diff() { + let db = fresh_in_memory_db().await; + let folder = seed_folder(&db, "/repo").await; + let sp = space::create_space(&db.conn, "S", folder).await.unwrap(); + let iss = issue::create_issue( + &db.conn, + sp.id, + "I", + "b", + IssuePriority::Medium, + Some(&IssueConfig::default()), + ) + .await + .unwrap(); + let task = artifact::create_artifact( + &db.conn, + sp.id, + iss.row.id, + ArtifactKind::Task, + "T", + ArtifactStatus::InProgress, + ActorKind::Agent, + None, + ) + .await + .unwrap(); + + let it = try_claim_iteration( + &db.conn, + IterationClaim { + space_id: sp.id, + issue_id: iss.row.id, + stage: Stage::Implement, + target_artifact_id: Some(task.id), + slot_no: None, + capability_token: "tok".into(), + attempt: 0, + }, + ) + .await + .unwrap() + .unwrap(); + // Reason recorded (the agent called loop_task_complete) ... + loop_iteration::Entity::update_many() + .col_expr( + loop_iteration::Column::AgentCompletionReason, + Expr::value("thought it was done"), + ) + .filter(loop_iteration::Column::Id.eq(it.id)) + .exec(&db.conn) + .await + .unwrap(); + // ... but the checkpoint found a real diff, so it settled `succeeded`. + assert!(set_iteration_outcome(&db.conn, it.id, IterationOutcome::Succeeded) + .await + .unwrap()); + + assert_eq!( + latest_declared_completion_reason(&db.conn, iss.row.id, task.id) + .await + .unwrap(), + None, + "a real-diff iteration's reason must not surface as a declared no-op" + ); + } +} diff --git a/src-tauri/src/db/service/loop_service/link.rs b/src-tauri/src/db/service/loop_service/link.rs new file mode 100644 index 0000000000..415177087b --- /dev/null +++ b/src-tauri/src/db/service/loop_service/link.rs @@ -0,0 +1,53 @@ +use chrono::Utc; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; + +use crate::db::entities::loop_link::{self, LinkKind}; +use crate::db::error::DbError; +use crate::models::loops::LoopLinkRow; + +pub fn to_link_row(m: loop_link::Model) -> LoopLinkRow { + LoopLinkRow { + id: m.id, + from_artifact_id: m.from_artifact_id, + to_artifact_id: m.to_artifact_id, + kind: m.kind, + source_revision_id: m.source_revision_id, + } +} + +/// Idempotent: a repeated `(from, to, kind)` triple returns the existing edge +/// instead of inserting a duplicate (also guarded by `uniq_loop_link`). +/// +/// `source_revision_id` snapshots the lineage content for design→requirement +/// `derives_from` edges (the requirement revision the design derived from); +/// `None` for edges that don't bind a source. On an idempotent hit the existing +/// edge is returned unchanged — the first write fixes the bound revision. +pub async fn create_link( + conn: &impl sea_orm::ConnectionTrait, + space_id: i32, + from_artifact_id: i32, + to_artifact_id: i32, + kind: LinkKind, + source_revision_id: Option, +) -> Result { + if let Some(existing) = loop_link::Entity::find() + .filter(loop_link::Column::FromArtifactId.eq(from_artifact_id)) + .filter(loop_link::Column::ToArtifactId.eq(to_artifact_id)) + .filter(loop_link::Column::Kind.eq(kind)) + .one(conn) + .await? + { + return Ok(existing); + } + Ok(loop_link::ActiveModel { + space_id: Set(space_id), + from_artifact_id: Set(from_artifact_id), + to_artifact_id: Set(to_artifact_id), + kind: Set(kind), + source_revision_id: Set(source_revision_id), + created_at: Set(Utc::now()), + ..Default::default() + } + .insert(conn) + .await?) +} diff --git a/src-tauri/src/db/service/loop_service/memory.rs b/src-tauri/src/db/service/loop_service/memory.rs new file mode 100644 index 0000000000..c1499953b2 --- /dev/null +++ b/src-tauri/src/db/service/loop_service/memory.rs @@ -0,0 +1,328 @@ +use chrono::Utc; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, QueryOrder, Set, +}; + +use crate::db::entities::loop_artifact_revision::ActorKind; +use crate::db::entities::loop_memory::{self, MemoryKind, MemoryStatus, TrustTier}; +use crate::db::error::DbError; +use crate::models::loops::LoopMemoryRow; + +pub fn to_row(m: loop_memory::Model) -> LoopMemoryRow { + LoopMemoryRow { + id: m.id, + kind: m.kind, + source: m.source, + title: m.title, + summary: m.summary, + content: m.content, + trust_tier: m.trust_tier, + status: m.status, + superseded_by: m.superseded_by, + source_issue_id: m.source_issue_id, + source_artifact_id: m.source_artifact_id, + produced_by_iteration_id: m.produced_by_iteration_id, + created_at: m.created_at, + updated_at: m.updated_at, + } +} + +/// Where an agent-/reflect-produced memory came from. Default = empty (human/UI). +#[derive(Default, Clone, Copy)] +pub struct MemoryProvenance { + pub source_issue_id: Option, + pub source_artifact_id: Option, + pub produced_by_iteration_id: Option, +} + +#[allow(clippy::too_many_arguments)] +pub async fn create_memory( + // `&impl ConnectionTrait` so reflect can create a memory inside the same + // transaction as its reflection artifact (`&db.conn` still satisfies it). + conn: &impl sea_orm::ConnectionTrait, + space_id: i32, + kind: MemoryKind, + source: ActorKind, + title: &str, + summary: Option<&str>, + content: &str, + trust_tier: TrustTier, + provenance: MemoryProvenance, +) -> Result { + let now = Utc::now(); + Ok(loop_memory::ActiveModel { + space_id: Set(space_id), + kind: Set(kind), + source: Set(source), + title: Set(title.to_string()), + summary: Set(summary.map(str::to_string)), + content: Set(content.to_string()), + trust_tier: Set(trust_tier), + status: Set(MemoryStatus::Active), + superseded_by: Set(None), + source_issue_id: Set(provenance.source_issue_id), + source_artifact_id: Set(provenance.source_artifact_id), + produced_by_iteration_id: Set(provenance.produced_by_iteration_id), + created_at: Set(now), + updated_at: Set(now), + ..Default::default() + } + .insert(conn) + .await?) +} + +pub async fn update_memory( + conn: &sea_orm::DatabaseConnection, + id: i32, + title: &str, + content: &str, + status: MemoryStatus, +) -> Result<(), DbError> { + let row = loop_memory::Entity::find_by_id(id) + .one(conn) + .await? + .ok_or_else(|| { + DbError::Database(sea_orm::DbErr::RecordNotFound(format!("loop_memory {id}"))) + })?; + let mut active = row.into_active_model(); + active.title = Set(title.to_string()); + active.content = Set(content.to_string()); + active.status = Set(status); + active.updated_at = Set(Utc::now()); + active.update(conn).await?; + Ok(()) +} + +pub async fn delete_memory(conn: &sea_orm::DatabaseConnection, id: i32) -> Result<(), DbError> { + loop_memory::Entity::delete_by_id(id).exec(conn).await?; + Ok(()) +} + +/// Supersede a memory: mark it `superseded` and point `superseded_by` at the +/// memory that replaces it — CAS-guarded on `status = active` so a replay (or a +/// concurrent supersede) is idempotent. Returns whether it applied (a miss means +/// the memory was no longer active). The audit pointer is immutable: a miss never +/// overwrites it. Reflect resolves the `[M{n}]` handle to `old_id` against the +/// iteration's manifest before calling this (§4.6). Takes `&impl ConnectionTrait` +/// so it runs inside the reflect distill transaction. +pub async fn supersede_memory( + conn: &impl sea_orm::ConnectionTrait, + old_id: i32, + new_id: i32, +) -> Result { + use sea_orm::sea_query::Expr; + use sea_orm::ActiveEnum; + let res = loop_memory::Entity::update_many() + .col_expr( + loop_memory::Column::Status, + Expr::value(MemoryStatus::Superseded.to_value()), + ) + .col_expr(loop_memory::Column::SupersededBy, Expr::value(new_id)) + .col_expr(loop_memory::Column::UpdatedAt, Expr::value(Utc::now())) + .filter(loop_memory::Column::Id.eq(old_id)) + .filter(loop_memory::Column::Status.eq(MemoryStatus::Active)) + .exec(conn) + .await?; + Ok(res.rows_affected == 1) +} + +pub async fn list_memory( + conn: &sea_orm::DatabaseConnection, + space_id: i32, +) -> Result, DbError> { + Ok(loop_memory::Entity::find() + .filter(loop_memory::Column::SpaceId.eq(space_id)) + .order_by_desc(loop_memory::Column::Id) + .all(conn) + .await? + .into_iter() + .map(to_row) + .collect()) +} + +/// The space constitution memories (always injected first by the briefing). +pub async fn list_constitution( + conn: &sea_orm::DatabaseConnection, + space_id: i32, +) -> Result, DbError> { + Ok(loop_memory::Entity::find() + .filter(loop_memory::Column::SpaceId.eq(space_id)) + .filter(loop_memory::Column::Status.eq(MemoryStatus::Active)) + .filter(loop_memory::Column::Kind.eq(MemoryKind::Constitution)) + .order_by_asc(loop_memory::Column::Id) + .all(conn) + .await?) +} + +/// The full memory index for a space's briefing: EVERY active memory except the +/// constitution (injected as full text separately), ordered by id ascending. No +/// stage filter, no relevance reorder, no scoring, no budget, no truncation — the +/// agent decides what to read via `loop_read_memory`. This is the de-engineered +/// recall path (§4.2). Index-size governance is by validity (superseded/archived +/// leave `active`), never by engine truncation. +pub async fn build_index( + conn: &sea_orm::DatabaseConnection, + space_id: i32, +) -> Result, DbError> { + Ok(loop_memory::Entity::find() + .filter(loop_memory::Column::SpaceId.eq(space_id)) + .filter(loop_memory::Column::Status.eq(MemoryStatus::Active)) + .filter(loop_memory::Column::Kind.ne(MemoryKind::Constitution)) + .order_by_asc(loop_memory::Column::Id) + .all(conn) + .await?) +} + +/// Fetch the memories named by `ids` that are still in the **active recall path**: +/// re-scoped to `space_id` (defense-in-depth — the manifest only holds this space's +/// ids, but re-scoping means a tampered manifest still cannot cross spaces), AND +/// `status = active` (a memory archived/superseded between dispatch and read leaves +/// the recall path, §4.6), AND `kind != constitution` (constitution is never in the +/// index). Anything filtered out returns no row, so the caller reports its handle as +/// `not_found`. Ordered by id ascending. Reads only — no usage write. +pub async fn get_for_read( + conn: &sea_orm::DatabaseConnection, + space_id: i32, + ids: &[i32], +) -> Result, DbError> { + if ids.is_empty() { + return Ok(Vec::new()); + } + Ok(loop_memory::Entity::find() + .filter(loop_memory::Column::SpaceId.eq(space_id)) + .filter(loop_memory::Column::Id.is_in(ids.to_vec())) + .filter(loop_memory::Column::Status.eq(MemoryStatus::Active)) + .filter(loop_memory::Column::Kind.ne(MemoryKind::Constitution)) + .order_by_asc(loop_memory::Column::Id) + .all(conn) + .await?) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::service::loop_service::space; + use crate::db::test_helpers::{fresh_in_memory_db, seed_folder}; + + async fn mem( + conn: &sea_orm::DatabaseConnection, + space_id: i32, + kind: MemoryKind, + title: &str, + ) -> loop_memory::Model { + create_memory( + conn, + space_id, + kind, + ActorKind::Agent, + title, + None, + "body", + TrustTier::Proposed, + MemoryProvenance::default(), + ) + .await + .unwrap() + } + + #[tokio::test] + async fn build_index_is_all_active_non_constitution_by_id() { + let db = fresh_in_memory_db().await; + let folder = seed_folder(&db, "/tmp/repo-idx").await; + let space = space::create_space(&db.conn, "S", folder).await.unwrap(); + + // Seeded out of "kind order"; build_index must order by id, not kind. A + // constitution, an archived, and a superseded memory are all excluded. + let m1 = mem(&db.conn, space.id, MemoryKind::Pitfall, "p").await; + let m2 = mem(&db.conn, space.id, MemoryKind::Decision, "d").await; + let m3 = mem(&db.conn, space.id, MemoryKind::Constraint, "c").await; + mem(&db.conn, space.id, MemoryKind::Constitution, "charter").await; + let archived = mem(&db.conn, space.id, MemoryKind::Preference, "old-pref").await; + let superseded = mem(&db.conn, space.id, MemoryKind::Decision, "old-dec").await; + update_memory(&db.conn, archived.id, "old-pref", "body", MemoryStatus::Archived) + .await + .unwrap(); + update_memory(&db.conn, superseded.id, "old-dec", "body", MemoryStatus::Superseded) + .await + .unwrap(); + + let index = build_index(&db.conn, space.id).await.unwrap(); + let ids: Vec = index.iter().map(|m| m.id).collect(); + assert_eq!(ids, vec![m1.id, m2.id, m3.id], "id-ascending, excludes the rest"); + assert!(index.iter().all(|m| m.kind != MemoryKind::Constitution)); + } + + #[tokio::test] + async fn get_for_read_is_space_scoped() { + let db = fresh_in_memory_db().await; + let folder_a = seed_folder(&db, "/tmp/repo-a").await; + let folder_b = seed_folder(&db, "/tmp/repo-b").await; + let a = space::create_space(&db.conn, "A", folder_a).await.unwrap(); + let b = space::create_space(&db.conn, "B", folder_b).await.unwrap(); + let in_a = mem(&db.conn, a.id, MemoryKind::Decision, "a-mem").await; + let in_b = mem(&db.conn, b.id, MemoryKind::Decision, "b-mem").await; + + // Space A asked for an A id + a B id: only the A row comes back. + let rows = get_for_read(&db.conn, a.id, &[in_a.id, in_b.id]).await.unwrap(); + assert_eq!(rows.iter().map(|m| m.id).collect::>(), vec![in_a.id]); + assert!(get_for_read(&db.conn, a.id, &[]).await.unwrap().is_empty()); + } + + #[tokio::test] + async fn create_memory_persists_summary_trust_and_provenance() { + let db = fresh_in_memory_db().await; + let folder = seed_folder(&db, "/tmp/repo-prov").await; + let space = space::create_space(&db.conn, "S", folder).await.unwrap(); + let m = create_memory( + &db.conn, + space.id, + MemoryKind::Pitfall, + ActorKind::Agent, + "title", + Some("one-line summary"), + "full body", + TrustTier::Proposed, + MemoryProvenance { + source_issue_id: Some(7), + source_artifact_id: None, + produced_by_iteration_id: Some(42), + }, + ) + .await + .unwrap(); + assert_eq!(m.summary.as_deref(), Some("one-line summary")); + assert_eq!(m.trust_tier, TrustTier::Proposed); + assert_eq!(m.source_issue_id, Some(7)); + assert_eq!(m.produced_by_iteration_id, Some(42)); + assert_eq!(m.source_artifact_id, None); + assert_eq!(m.superseded_by, None); + } + + #[tokio::test] + async fn supersede_memory_cas_is_idempotent_and_drops_from_index() { + let db = fresh_in_memory_db().await; + let folder = seed_folder(&db, "/tmp/repo-sup").await; + let space = space::create_space(&db.conn, "S", folder).await.unwrap(); + let old = mem(&db.conn, space.id, MemoryKind::Decision, "old").await; + let new = mem(&db.conn, space.id, MemoryKind::Decision, "new").await; + assert!(supersede_memory(&db.conn, old.id, new.id).await.unwrap()); + let row = loop_memory::Entity::find_by_id(old.id) + .one(&db.conn) + .await + .unwrap() + .unwrap(); + assert_eq!(row.status, MemoryStatus::Superseded); + assert_eq!(row.superseded_by, Some(new.id)); + assert!(build_index(&db.conn, space.id) + .await + .unwrap() + .iter() + .all(|m| m.id != old.id)); + assert!(get_for_read(&db.conn, space.id, &[old.id]) + .await + .unwrap() + .is_empty()); + // Idempotent miss: a second supersede does not apply (already inactive). + assert!(!supersede_memory(&db.conn, old.id, new.id).await.unwrap()); + } +} diff --git a/src-tauri/src/db/service/loop_service/mod.rs b/src-tauri/src/db/service/loop_service/mod.rs new file mode 100644 index 0000000000..5896d174ef --- /dev/null +++ b/src-tauri/src/db/service/loop_service/mod.rs @@ -0,0 +1,651 @@ +//! DB layer for the loop engineering subsystem. CRUD + read models; the +//! compare-and-swap transitions and dispatch leases live in +//! `loop_engine::transitions`. + +pub mod artifact; +pub mod coverage; +pub mod criterion_check; +pub mod criterion_ordinals; +pub mod gate_decision; +pub mod inbox; +pub mod issue; +pub mod iteration; +pub mod link; +pub mod memory; +pub mod space; +pub mod validation; + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::entities::loop_artifact::{ArtifactKind, ArtifactStatus}; + use crate::db::entities::loop_artifact_revision::ActorKind; + use crate::db::entities::loop_inbox_item::{InboxKind, InboxStatus}; + use crate::db::entities::loop_issue::{IssuePriority, IssueStatus}; + use crate::db::entities::loop_criterion::CriterionKind; + use crate::db::entities::loop_link::LinkKind; + use crate::db::entities::loop_memory::{MemoryKind, TrustTier}; + use crate::db::test_helpers::{fresh_in_memory_db, seed_folder}; + use crate::models::loops::IssueConfig; + + #[tokio::test] + async fn create_issue_seeds_root_artifact() { + let db = fresh_in_memory_db().await; + let folder_id = seed_folder(&db, "/tmp/repo-a").await; + let space = space::create_space(&db.conn, "Pay", folder_id).await.unwrap(); + let detail = issue::create_issue( + &db.conn, + space.id, + "Fix webhook", + "the body", + IssuePriority::High, + Some(&IssueConfig::default()), + ) + .await + .unwrap(); + + assert_eq!(detail.row.seq_no, 1); + assert_eq!(detail.row.status, IssueStatus::Pending); + + let dag = artifact::list_dag(&db.conn, detail.row.id).await.unwrap(); + assert_eq!(dag.artifacts.len(), 1, "root artifact created"); + assert_eq!(dag.artifacts[0].kind, ArtifactKind::Issue); + assert_eq!(dag.artifacts[0].status, ArtifactStatus::Done); + + let det = artifact::get_artifact_detail(&db.conn, dag.artifacts[0].id) + .await + .unwrap() + .unwrap(); + assert_eq!(det.revisions.len(), 1, "description seeded as revision 1"); + assert_eq!(det.revisions[0].content, "the body"); + } + + #[tokio::test] + async fn artifacts_links_idempotent_and_dag() { + let db = fresh_in_memory_db().await; + let folder_id = seed_folder(&db, "/tmp/repo-b").await; + let space = space::create_space(&db.conn, "S", folder_id).await.unwrap(); + let issue = issue::create_issue( + &db.conn, + space.id, + "I", + "d", + IssuePriority::Medium, + Some(&IssueConfig::default()), + ) + .await + .unwrap(); + let issue_id = issue.row.id; + let root_id = artifact::list_dag(&db.conn, issue_id).await.unwrap().artifacts[0].id; + + let req = artifact::create_artifact( + &db.conn, + space.id, + issue_id, + ArtifactKind::Requirement, + "R1", + ArtifactStatus::Done, + ActorKind::Agent, + None, + ) + .await + .unwrap(); + artifact::add_revision(&db.conn, req.id, "req body", ActorKind::Agent, None) + .await + .unwrap(); + let crit = artifact::add_criterion(&db.conn, req.id, CriterionKind::Acceptance, "must do x") + .await + .unwrap(); + assert_eq!(crit.label, "AC-1"); + assert_eq!(crit.kind, CriterionKind::Acceptance); + + // `requirement derives_from issue` — repeated, must dedupe. + let l1 = + link::create_link(&db.conn, space.id, req.id, root_id, LinkKind::DerivesFrom, None) + .await + .unwrap(); + let l2 = + link::create_link(&db.conn, space.id, req.id, root_id, LinkKind::DerivesFrom, None) + .await + .unwrap(); + assert_eq!(l1.id, l2.id, "link is idempotent"); + + let dag = artifact::list_dag(&db.conn, issue_id).await.unwrap(); + assert_eq!(dag.artifacts.len(), 2); + assert_eq!(dag.links.len(), 1); + + let det = artifact::get_artifact_detail(&db.conn, req.id) + .await + .unwrap() + .unwrap(); + assert_eq!(det.revisions.len(), 1); + assert_eq!(det.criteria.len(), 1); + assert_eq!(det.links.len(), 1); + } + + #[tokio::test] + async fn coverage_idempotent_and_typed_criteria() { + let db = fresh_in_memory_db().await; + let folder_id = seed_folder(&db, "/tmp/repo-cov").await; + let space = space::create_space(&db.conn, "S", folder_id).await.unwrap(); + let issue = issue::create_issue( + &db.conn, + space.id, + "I", + "d", + IssuePriority::Medium, + Some(&IssueConfig::default()), + ) + .await + .unwrap(); + let issue_id = issue.row.id; + + // A requirement with one acceptance criterion + one constraint. + let req = artifact::create_artifact( + &db.conn, + space.id, + issue_id, + ArtifactKind::Requirement, + "R1", + ArtifactStatus::Done, + ActorKind::Agent, + None, + ) + .await + .unwrap(); + let ac = artifact::add_criterion(&db.conn, req.id, CriterionKind::Acceptance, "do x") + .await + .unwrap(); + artifact::add_criterion(&db.conn, req.id, CriterionKind::Constraint, "no panics") + .await + .unwrap(); + + // Kinds round-trip through the detail read. + let det = artifact::get_artifact_detail(&db.conn, req.id).await.unwrap().unwrap(); + assert_eq!(det.criteria.len(), 2); + assert_eq!(det.criteria[0].kind, CriterionKind::Acceptance); + assert_eq!(det.criteria[1].kind, CriterionKind::Constraint); + + // A task covers the acceptance criterion; coverage is idempotent. + let task = artifact::create_artifact( + &db.conn, + space.id, + issue_id, + ArtifactKind::Task, + "T1", + ArtifactStatus::Pending, + ActorKind::Agent, + None, + ) + .await + .unwrap(); + let c1 = coverage::create_coverage(&db.conn, space.id, task.id, ac.id) + .await + .unwrap(); + let c2 = coverage::create_coverage(&db.conn, space.id, task.id, ac.id) + .await + .unwrap(); + assert_eq!(c1.id, c2.id, "coverage is idempotent"); + + // Surfaced both by list_for_issue and inside the DAG view. + let cov = coverage::list_for_issue(&db.conn, issue_id).await.unwrap(); + assert_eq!(cov.len(), 1); + assert_eq!(cov[0].task_artifact_id, task.id); + assert_eq!(cov[0].criterion_id, ac.id); + let dag = artifact::list_dag(&db.conn, issue_id).await.unwrap(); + assert_eq!(dag.coverage.len(), 1); + assert_eq!(dag.coverage[0].criterion_id, ac.id); + } + + #[tokio::test] + async fn inbox_upsert_tristate_merge_preserve_and_idempotent_handle() { + use crate::db::service::loop_service::inbox::InboxUpsert; + let db = fresh_in_memory_db().await; + let folder_id = seed_folder(&db, "/tmp/repo-c").await; + let space = space::create_space(&db.conn, "S", folder_id).await.unwrap(); + let issue = issue::create_issue( + &db.conn, + space.id, + "I", + "d", + IssuePriority::Low, + Some(&IssueConfig::default()), + ) + .await + .unwrap(); + let iid = issue.row.id; + + // First occurrence → Created, carrying a rich diagnostic payload. + let first = inbox::upsert_inbox( + &db.conn, + space.id, + iid, + None, + InboxKind::Blocked, + "no_progress:1", + serde_json::json!({ "failure_sig": "abc", "stage": "implement", "attempt": 1 }), + ) + .await + .unwrap(); + assert!(matches!(first, InboxUpsert::Created(_))); + assert!(first.changed()); + let card_id = first.model().id; + + // Thinner recurrence (only `attempt`) → merge-preserve: failure_sig/stage + // are kept, attempt updated → Updated, same pending row (Codex r2 N1). + let second = inbox::upsert_inbox( + &db.conn, + space.id, + iid, + None, + InboxKind::Blocked, + "no_progress:1", + serde_json::json!({ "attempt": 2 }), + ) + .await + .unwrap(); + assert!(matches!(second, InboxUpsert::Updated(_))); + assert!(second.changed()); + assert_eq!(second.model().id, card_id, "same pending row, not a new card"); + let merged: serde_json::Value = serde_json::from_str(&second.model().payload).unwrap(); + assert_eq!(merged["failure_sig"], "abc", "diagnostic field preserved (N1)"); + assert_eq!(merged["stage"], "implement", "diagnostic field preserved (N1)"); + assert_eq!(merged["attempt"], 2, "new key wins"); + + // Identical recurrence → Unchanged, no event (no per-tick spam). + let third = inbox::upsert_inbox( + &db.conn, + space.id, + iid, + None, + InboxKind::Blocked, + "no_progress:1", + serde_json::json!({ "attempt": 2 }), + ) + .await + .unwrap(); + assert!(matches!(third, InboxUpsert::Unchanged(_))); + assert!(!third.changed(), "no-op recurrence must not emit"); + + // Still exactly one pending card across all three upserts. + let pending = inbox::list_inbox(&db.conn, space.id, Some(InboxStatus::Pending)) + .await + .unwrap(); + assert_eq!(pending.len(), 1); + + // handle_inbox is idempotent: true on the real transition, false after. + assert!(inbox::handle_inbox(&db.conn, card_id, serde_json::json!({ "ok": true })) + .await + .unwrap()); + assert!(!inbox::handle_inbox(&db.conn, card_id, serde_json::json!({ "ok": true })) + .await + .unwrap()); + let still_pending = inbox::list_inbox(&db.conn, space.id, Some(InboxStatus::Pending)) + .await + .unwrap(); + assert_eq!(still_pending.len(), 0); + + // After the card is handled it no longer occupies the pending slot, so the + // same key recurs as a fresh Created (no separate "reopened" state). + let reopened = inbox::upsert_inbox( + &db.conn, + space.id, + iid, + None, + InboxKind::Blocked, + "no_progress:1", + serde_json::json!({ "attempt": 3 }), + ) + .await + .unwrap(); + assert!(matches!(reopened, InboxUpsert::Created(_))); + } + + #[tokio::test] + async fn attention_aggregation_buckets_pending_by_class() { + let db = fresh_in_memory_db().await; + let folder_id = seed_folder(&db, "/tmp/repo-attn").await; + let space = space::create_space(&db.conn, "S", folder_id).await.unwrap(); + let i1 = issue::create_issue( + &db.conn, + space.id, + "I1", + "d", + IssuePriority::Medium, + Some(&IssueConfig::default()), + ) + .await + .unwrap(); + let i2 = issue::create_issue( + &db.conn, + space.id, + "I2", + "d", + IssuePriority::Medium, + Some(&IssueConfig::default()), + ) + .await + .unwrap(); + + // issue 1: 2 blocking (approval, question) + 1 notice (reflection_failed). + for (kind, key) in [ + (InboxKind::Approval, "design:1"), + (InboxKind::Question, "question:1"), + (InboxKind::ReflectionFailed, "reflect_failed:1"), + ] { + inbox::upsert_inbox(&db.conn, space.id, i1.row.id, None, kind, key, serde_json::json!({})) + .await + .unwrap(); + } + // issue 2: 2 blocking (blocked, budget) + 1 approval we then mark handled. + for (kind, key) in [ + (InboxKind::Blocked, "no_progress:9"), + (InboxKind::BudgetExhausted, "budget:2"), + (InboxKind::Approval, "merge:2"), + ] { + inbox::upsert_inbox(&db.conn, space.id, i2.row.id, None, kind, key, serde_json::json!({})) + .await + .unwrap(); + } + let merge_id = inbox::list_inbox(&db.conn, space.id, Some(InboxStatus::Pending)) + .await + .unwrap() + .into_iter() + .find(|c| c.subject_key == "merge:2") + .unwrap() + .id; + inbox::handle_inbox(&db.conn, merge_id, serde_json::json!({"ok": true})) + .await + .unwrap(); + + // Per-space: blocking = approval+question+blocked+budget = 4; notice = 1; + // the handled approval is excluded. + let (blocking, notice) = inbox::aggregate_for_space(&db.conn, space.id).await.unwrap(); + assert_eq!((blocking, notice), (4, 1)); + + // Per-issue buckets. + let per_issue = inbox::aggregate_for_issues(&db.conn, &[i1.row.id, i2.row.id]) + .await + .unwrap(); + assert_eq!(per_issue.get(&i1.row.id).copied(), Some((2, 1))); + assert_eq!(per_issue.get(&i2.row.id).copied(), Some((2, 0))); + + // Cross-space rollup. + let all = inbox::aggregate_all(&db.conn).await.unwrap(); + assert_eq!(all.len(), 1); + assert_eq!(all[0].space_id, space.id); + assert_eq!((all[0].blocking, all[0].notice), (4, 1)); + + // The space summary carries the same counts. + let summaries = space::list_spaces(&db.conn).await.unwrap(); + assert_eq!(summaries[0].blocking_count, 4); + assert_eq!(summaries[0].notice_count, 1); + + // The issue list carries per-issue counts. + let issues = issue::list_issues(&db.conn, space.id, None).await.unwrap(); + let r1 = issues.iter().find(|r| r.id == i1.row.id).unwrap(); + assert_eq!((r1.blocking_count, r1.notice_count), (2, 1)); + } + + #[tokio::test] + async fn inbox_subject_resolution_by_family() { + use crate::db::entities::loop_iteration::{self, IterationStatus, LaunchedBy, Stage}; + use chrono::Utc; + use sea_orm::{ActiveModelTrait, Set}; + + let db = fresh_in_memory_db().await; + let folder_id = seed_folder(&db, "/tmp/repo-subj").await; + let space = space::create_space(&db.conn, "S", folder_id).await.unwrap(); + let issue = issue::create_issue( + &db.conn, + space.id, + "I", + "d", + IssuePriority::Medium, + Some(&IssueConfig::default()), + ) + .await + .unwrap(); + let iid = issue.row.id; + + let design = artifact::create_artifact( + &db.conn, + space.id, + iid, + ArtifactKind::Design, + "Design A", + ArtifactStatus::AwaitingApproval, + ActorKind::Agent, + None, + ) + .await + .unwrap(); + let result = artifact::create_artifact( + &db.conn, + space.id, + iid, + ArtifactKind::Result, + "Result A", + ArtifactStatus::Done, + ActorKind::Agent, + None, + ) + .await + .unwrap(); + let task = artifact::create_artifact( + &db.conn, + space.id, + iid, + ArtifactKind::Task, + "Task A", + ArtifactStatus::Pending, + ActorKind::Agent, + None, + ) + .await + .unwrap(); + assert_ne!(design.id, iid, "design artifact id differs from issue id (I4 guard)"); + + let iter = loop_iteration::ActiveModel { + space_id: Set(space.id), + issue_id: Set(iid), + stage: Set(Stage::Implement), + target_artifact_id: Set(Some(task.id)), + capability_token: Set("tok-subj".to_string()), + status: Set(IterationStatus::Running), + launched_by: Set(LaunchedBy::Engine), + created_at: Set(Utc::now()), + ..Default::default() + } + .insert(&db.conn) + .await + .unwrap(); + + // One card per subject family. + let cards = [ + // task-level: payload.task_artifact_id wins over the (deliberately wrong) suffix. + ( + InboxKind::Blocked, + "no_progress:99999".to_string(), + None, + serde_json::json!({ "task_artifact_id": task.id }), + ), + // design-level: suffix is the ISSUE id; resolves to the design artifact. + ( + InboxKind::Approval, + format!("design:{iid}"), + None, + serde_json::json!({ "gate": "design" }), + ), + // result-level: suffix is the ISSUE id; resolves to the result artifact. + ( + InboxKind::Approval, + format!("merge:{iid}"), + None, + serde_json::json!({ "gate": "merge" }), + ), + // iteration-level: resolves to the iteration's target (the task). + ( + InboxKind::Blocked, + format!("dispatch_failed:{}", iter.id), + Some(iter.id), + serde_json::json!({}), + ), + // issue-level: no backing artifact. + ( + InboxKind::BudgetExhausted, + format!("budget:{iid}"), + None, + serde_json::json!({}), + ), + // unknown prefix: no backing artifact. + (InboxKind::Blocked, "mystery:7".to_string(), None, serde_json::json!({})), + ]; + for (kind, key, it, payload) in cards { + inbox::upsert_inbox(&db.conn, space.id, iid, it, kind, &key, payload) + .await + .unwrap(); + } + + let rows = inbox::list_inbox(&db.conn, space.id, Some(InboxStatus::Pending)) + .await + .unwrap(); + let by_key = |k: &str| rows.iter().find(|r| r.subject_key == k).unwrap(); + + let np = by_key("no_progress:99999"); + assert_eq!(np.subject_artifact_id, Some(task.id), "payload id wins over suffix"); + assert_eq!(np.subject_title.as_deref(), Some("Task A")); + + let d = by_key(&format!("design:{iid}")); + assert_eq!(d.subject_artifact_id, Some(design.id), "design id, NOT issue id (I4)"); + assert_eq!(d.subject_title.as_deref(), Some("Design A")); + + let m = by_key(&format!("merge:{iid}")); + assert_eq!(m.subject_artifact_id, Some(result.id)); + assert_eq!(m.subject_title.as_deref(), Some("Result A")); + + let df = by_key(&format!("dispatch_failed:{}", iter.id)); + assert_eq!(df.subject_artifact_id, Some(task.id), "iteration target"); + + assert_eq!(by_key(&format!("budget:{iid}")).subject_artifact_id, None); + assert_eq!(by_key("mystery:7").subject_artifact_id, None); + } + + #[tokio::test] + async fn set_iteration_outcome_is_write_once() { + use crate::db::entities::loop_iteration::{ + self, IterationOutcome, IterationStatus, LaunchedBy, Stage, + }; + use chrono::Utc; + use sea_orm::{ActiveModelTrait, EntityTrait, Set}; + + let db = fresh_in_memory_db().await; + let folder_id = seed_folder(&db, "/tmp/repo-wo").await; + let space = space::create_space(&db.conn, "S", folder_id).await.unwrap(); + let issue = issue::create_issue( + &db.conn, + space.id, + "I", + "d", + IssuePriority::Medium, + Some(&IssueConfig::default()), + ) + .await + .unwrap(); + let iter = loop_iteration::ActiveModel { + space_id: Set(space.id), + issue_id: Set(issue.row.id), + stage: Set(Stage::Refine), + capability_token: Set("tok-wo".to_string()), + status: Set(IterationStatus::Running), + launched_by: Set(LaunchedBy::Engine), + created_at: Set(Utc::now()), + ..Default::default() + } + .insert(&db.conn) + .await + .unwrap(); + + // First write succeeds (outcome was NULL). + assert!(iteration::set_iteration_outcome(&db.conn, iter.id, IterationOutcome::Succeeded) + .await + .unwrap()); + // A later/stale write is a no-op and must NOT clobber the real outcome (C2). + assert!(!iteration::set_iteration_outcome(&db.conn, iter.id, IterationOutcome::Abandoned) + .await + .unwrap()); + let after = loop_iteration::Entity::find_by_id(iter.id) + .one(&db.conn) + .await + .unwrap() + .unwrap(); + assert_eq!( + after.outcome, + Some(IterationOutcome::Succeeded), + "write-once preserves the real outcome" + ); + } + + #[tokio::test] + async fn space_summary_and_cascade_delete() { + let db = fresh_in_memory_db().await; + let folder_id = seed_folder(&db, "/tmp/repo-d").await; + let space = space::create_space(&db.conn, "S", folder_id).await.unwrap(); + let issue = issue::create_issue( + &db.conn, + space.id, + "I", + "d", + IssuePriority::Medium, + Some(&IssueConfig::default()), + ) + .await + .unwrap(); + + let summaries = space::list_spaces(&db.conn).await.unwrap(); + assert_eq!(summaries.len(), 1); + assert_eq!(summaries[0].issue_count, 1); + assert!(!summaries[0].detached, "live folder is not detached"); + + space::delete_space(&db.conn, space.id).await.unwrap(); + // FK cascade removed the issue and its root artifact. + let dag = artifact::list_dag(&db.conn, issue.row.id).await.unwrap(); + assert_eq!(dag.artifacts.len(), 0, "cascade removed artifacts"); + assert!(space::list_spaces(&db.conn).await.unwrap().is_empty()); + } + + #[tokio::test] + async fn memory_crud() { + let db = fresh_in_memory_db().await; + let folder_id = seed_folder(&db, "/tmp/repo-e").await; + let space = space::create_space(&db.conn, "S", folder_id).await.unwrap(); + + memory::create_memory( + &db.conn, + space.id, + MemoryKind::Pitfall, + ActorKind::Agent, + "p", + None, + "b", + TrustTier::Proposed, + memory::MemoryProvenance::default(), + ) + .await + .unwrap(); + memory::create_memory( + &db.conn, + space.id, + MemoryKind::Decision, + ActorKind::Human, + "d", + None, + "b", + TrustTier::Human, + memory::MemoryProvenance::default(), + ) + .await + .unwrap(); + assert_eq!(memory::list_memory(&db.conn, space.id).await.unwrap().len(), 2); + } +} diff --git a/src-tauri/src/db/service/loop_service/space.rs b/src-tauri/src/db/service/loop_service/space.rs new file mode 100644 index 0000000000..8473ad7f2b --- /dev/null +++ b/src-tauri/src/db/service/loop_service/space.rs @@ -0,0 +1,161 @@ +use std::collections::HashMap; + +use chrono::Utc; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, QueryOrder, Set, +}; + +use crate::db::entities::loop_issue::IssueStatus; +use crate::db::entities::{folder, loop_issue, loop_space}; +use crate::db::error::DbError; +use crate::models::loops::{IssueConfig, LoopSpaceSummary}; + +fn not_found(id: i32) -> DbError { + DbError::Database(sea_orm::DbErr::RecordNotFound(format!("loop_space {id}"))) +} + +fn config_err(e: serde_json::Error) -> DbError { + DbError::Database(sea_orm::DbErr::Custom(format!("loop space config: {e}"))) +} + +pub async fn create_space( + conn: &sea_orm::DatabaseConnection, + name: &str, + folder_id: i32, +) -> Result { + let now = Utc::now(); + // `default_config` is NOT NULL — every space stores a concrete config. + let default_config = serde_json::to_string(&IssueConfig::default()).map_err(config_err)?; + let active = loop_space::ActiveModel { + name: Set(name.to_string()), + folder_id: Set(folder_id), + default_config: Set(default_config), + created_at: Set(now), + updated_at: Set(now), + ..Default::default() + }; + Ok(active.insert(conn).await?) +} + +pub async fn update_space( + conn: &sea_orm::DatabaseConnection, + id: i32, + name: &str, +) -> Result { + let row = loop_space::Entity::find_by_id(id) + .one(conn) + .await? + .ok_or_else(|| not_found(id))?; + let mut active = row.into_active_model(); + active.name = Set(name.to_string()); + active.updated_at = Set(Utc::now()); + Ok(active.update(conn).await?) +} + +/// Set the space's default issue config (stored JSON, NOT NULL). Issues whose own +/// config is NULL resolve from this at read time. "Reset to engine default" is +/// just the caller passing `&IssueConfig::default()`. +pub async fn set_default_config( + conn: &sea_orm::DatabaseConnection, + id: i32, + config: &IssueConfig, +) -> Result<(), DbError> { + let json = serde_json::to_string(config).map_err(config_err)?; + let row = loop_space::Entity::find_by_id(id) + .one(conn) + .await? + .ok_or_else(|| not_found(id))?; + let mut active = row.into_active_model(); + active.default_config = Set(json); + active.updated_at = Set(Utc::now()); + active.update(conn).await?; + Ok(()) +} + +pub async fn get_space( + conn: &sea_orm::DatabaseConnection, + id: i32, +) -> Result, DbError> { + Ok(loop_space::Entity::find_by_id(id).one(conn).await?) +} + +/// Hard-delete a space; loop-table FKs (`ON DELETE CASCADE`) remove every issue, +/// artifact, revision, criterion, link, iteration, validation run, inbox item +/// and memory underneath. Engine worktree cleanup happens at the command layer +/// before this is called. +pub async fn delete_space(conn: &sea_orm::DatabaseConnection, id: i32) -> Result<(), DbError> { + loop_space::Entity::delete_by_id(id).exec(conn).await?; + Ok(()) +} + +pub async fn list_spaces( + conn: &sea_orm::DatabaseConnection, +) -> Result, DbError> { + let spaces = loop_space::Entity::find() + .order_by_desc(loop_space::Column::CreatedAt) + .all(conn) + .await?; + if spaces.is_empty() { + return Ok(Vec::new()); + } + + let space_ids: Vec = spaces.iter().map(|s| s.id).collect(); + let folder_ids: Vec = spaces.iter().map(|s| s.folder_id).collect(); + + let folders: HashMap = folder::Entity::find() + .filter(folder::Column::Id.is_in(folder_ids)) + .all(conn) + .await? + .into_iter() + .map(|f| (f.id, f)) + .collect(); + + let issues = loop_issue::Entity::find() + .filter(loop_issue::Column::SpaceId.is_in(space_ids)) + .all(conn) + .await?; + + // D6: roll pending-inbox attention into each summary (one batched query). + let attention: HashMap = super::inbox::aggregate_all(conn) + .await? + .into_iter() + .map(|a| (a.space_id, (a.blocking, a.notice))) + .collect(); + + let summaries = spaces + .into_iter() + .map(|s| -> Result { + let folder = folders.get(&s.folder_id); + // Folder join does NOT filter deleted_at — a soft-deleted/missing + // folder still yields the space (read-only) and flips `detached`. + let detached = folder.map(|f| f.deleted_at.is_some()).unwrap_or(true); + let folder_path = folder.map(|f| f.path.clone()); + let mine: Vec<&loop_issue::Model> = + issues.iter().filter(|i| i.space_id == s.id).collect(); + let issue_count = mine.len() as i64; + let running_count = mine + .iter() + .filter(|i| i.status == IssueStatus::Running) + .count() as i64; + let last_activity_at = mine.iter().map(|i| i.updated_at).max(); + let default_config = serde_json::from_str(&s.default_config).map_err(config_err)?; + let (blocking_count, notice_count) = attention.get(&s.id).copied().unwrap_or((0, 0)); + Ok(LoopSpaceSummary { + id: s.id, + name: s.name, + folder_id: s.folder_id, + folder_path, + detached, + issue_count, + running_count, + blocking_count, + notice_count, + last_activity_at, + created_at: s.created_at, + default_config, + }) + }) + .collect::, _>>()?; + + Ok(summaries) +} diff --git a/src-tauri/src/db/service/loop_service/validation.rs b/src-tauri/src/db/service/loop_service/validation.rs new file mode 100644 index 0000000000..ca1c2535f3 --- /dev/null +++ b/src-tauri/src/db/service/loop_service/validation.rs @@ -0,0 +1,90 @@ +use chrono::Utc; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set}; + +use crate::db::entities::loop_validation_run; +use crate::db::error::DbError; +use crate::models::loops::LoopValidationRunRow; + +fn to_row(m: loop_validation_run::Model) -> LoopValidationRunRow { + LoopValidationRunRow { + id: m.id, + task_artifact_id: m.task_artifact_id, + iteration_id: m.iteration_id, + commands: serde_json::from_str(&m.commands).unwrap_or_default(), + exit_codes: serde_json::from_str(&m.exit_codes).unwrap_or_default(), + passed: m.passed, + created_at: m.created_at, + } +} + +#[allow(clippy::too_many_arguments)] +pub async fn record_validation_run( + conn: &sea_orm::DatabaseConnection, + space_id: i32, + issue_id: i32, + task_artifact_id: i32, + iteration_id: Option, + commands: &[String], + exit_codes: &[i32], + output: &str, + passed: bool, +) -> Result { + Ok(loop_validation_run::ActiveModel { + space_id: Set(space_id), + issue_id: Set(issue_id), + task_artifact_id: Set(task_artifact_id), + iteration_id: Set(iteration_id), + commands: Set(serde_json::to_string(commands).unwrap_or_else(|_| "[]".to_string())), + exit_codes: Set(serde_json::to_string(exit_codes).unwrap_or_else(|_| "[]".to_string())), + output: Set(output.to_string()), + passed: Set(passed), + created_at: Set(Utc::now()), + ..Default::default() + } + .insert(conn) + .await?) +} + +pub async fn list_for_task( + conn: &sea_orm::DatabaseConnection, + task_artifact_id: i32, +) -> Result, DbError> { + Ok(loop_validation_run::Entity::find() + .filter(loop_validation_run::Column::TaskArtifactId.eq(task_artifact_id)) + .order_by_desc(loop_validation_run::Column::Id) + .all(conn) + .await? + .into_iter() + .map(to_row) + .collect()) +} + +/// Every validation run in a space, newest first. Drives the iteration list's +/// expansion, which groups them client-side by `iteration_id`. +pub async fn list_for_space( + conn: &sea_orm::DatabaseConnection, + space_id: i32, +) -> Result, DbError> { + Ok(loop_validation_run::Entity::find() + .filter(loop_validation_run::Column::SpaceId.eq(space_id)) + .order_by_desc(loop_validation_run::Column::Id) + .all(conn) + .await? + .into_iter() + .map(to_row) + .collect()) +} + +/// The most recent run for a task, as the full entity (the `LoopValidationRunRow` +/// DTO omits `output`, which the implement briefing needs to feed a failure +/// back to the next attempt). +pub async fn latest_for_task( + conn: &sea_orm::DatabaseConnection, + task_artifact_id: i32, +) -> Result, DbError> { + Ok(loop_validation_run::Entity::find() + .filter(loop_validation_run::Column::TaskArtifactId.eq(task_artifact_id)) + .order_by_desc(loop_validation_run::Column::Id) + .one(conn) + .await?) +} diff --git a/src-tauri/src/db/service/mod.rs b/src-tauri/src/db/service/mod.rs index dc70f1b7d1..f625248f4f 100644 --- a/src-tauri/src/db/service/mod.rs +++ b/src-tauri/src/db/service/mod.rs @@ -6,6 +6,7 @@ pub mod conversation_service; pub mod folder_command_service; pub mod folder_service; pub mod import_service; +pub mod loop_service; pub mod model_provider_service; pub mod quick_message_service; pub mod remote_workspace_connection_service; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 60877cb437..5a4113152d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -11,8 +11,10 @@ pub mod db; pub mod git_credential; pub mod git_repo; pub mod keyring_store; +pub mod loop_engine; pub mod models; mod network; +pub mod observability; pub mod parsers; pub mod paths; pub mod pet_sessions; @@ -46,7 +48,7 @@ mod tauri_app { acp as acp_commands, app_update as app_update_commands, backup, chat_channel as chat_channel_commands, conversations, delegation as delegation_commands, experts as experts_commands, feedback as feedback_commands, file_io, folder_commands, - folders, mcp as mcp_commands, + folders, loops as loops_commands, mcp as mcp_commands, model_provider as model_provider_commands, notification, pet as pet_commands, project_boot, question as question_commands, quick_messages as quick_messages_commands, remote_proxy as remote_proxy_commands, @@ -196,6 +198,7 @@ mod tauri_app { // same download progress; lets the upgrade UI survive navigation. .manage(crate::update::new_update_state_handle()) .setup(|app| { + crate::observability::init_tracing(); let app_data_dir = app.path().app_data_dir()?; // Unify the data root across every consumer: @@ -493,6 +496,9 @@ mod tauri_app { manager: std::sync::Arc::new(cm_state.clone_ref()), }, ), + std::sync::Arc::new(crate::loop_engine::ingest::DbLoopIngest { + conn: db_conn.clone(), + }), ); tauri::async_runtime::spawn(async move { if let Err(e) = listener.run(socket_path).await { @@ -502,6 +508,39 @@ mod tauri_app { broker }; + // Build the loop engine and manage it so Tauri commands and the + // (optional) embedded web server share one instance, then kick + // off crash recovery. Mirrors the delegation-broker lifecycle. + { + let db_conn = app.state::().conn.clone(); + let cm = app.state::().clone_ref(); + let loop_engine = crate::loop_engine::LoopEngine::new( + db::AppDatabase { conn: db_conn }, + cm, + effective_data_dir.clone(), + web::event_bridge::EventEmitter::Tauri(app.handle().clone()), + ); + app.manage(loop_engine.clone()); + // React to loop iteration turn-completions via the in-process + // event bus (additive subscriber; never touches the delegation + // lifecycle path). + let loop_bus = app + .state::>() + .inner() + .clone(); + // The setup callback runs outside any tokio runtime, so the + // engine returns the watcher future and we spawn it here. + tauri::async_runtime::spawn(loop_engine.completion_watcher_task(loop_bus)); + tauri::async_runtime::spawn(async move { + // Recover interrupted work first (release stale leases, + // restart drivers), then run the supervisor forever so a + // driver that later dies is respawned and its issue never + // silently stalls. + loop_engine.recover_on_boot().await; + loop_engine.supervisor_task().await; + }); + } + // Spawn the LifecycleSubscriber: persists cross-connection DB state // (currently `external_id` on conversation rows when SessionStarted fires) // off the emit hot path. `subscribe()` runs synchronously inside @@ -997,6 +1036,43 @@ mod tauri_app { quick_messages_commands::quick_messages_update, quick_messages_commands::quick_messages_delete, quick_messages_commands::quick_messages_reorder, + loops_commands::list_loop_spaces, + loops_commands::create_loop_space, + loops_commands::update_loop_space, + loops_commands::set_loop_space_default_config, + loops_commands::delete_loop_space, + loops_commands::list_loop_issues, + loops_commands::get_loop_issue, + loops_commands::create_loop_issue, + loops_commands::delete_loop_issue, + loops_commands::update_loop_issue_config, + loops_commands::trigger_loop_issue, + loops_commands::pause_loop_issue, + loops_commands::resume_loop_issue, + loops_commands::cancel_loop_issue, + loops_commands::retry_loop_issue, + loops_commands::force_complete_loop_task, + loops_commands::override_loop_oscillation, + loops_commands::add_loop_issue_budget, + loops_commands::approve_loop_merge, + loops_commands::reject_loop_merge, + loops_commands::approve_loop_design, + loops_commands::reject_loop_design, + loops_commands::get_loop_dag, + loops_commands::get_loop_engine_health, + loops_commands::list_loop_artifacts, + loops_commands::get_loop_artifact, + loops_commands::list_loop_iterations, + loops_commands::get_loop_artifact_iterations, + loops_commands::get_loop_phase_iterations, + loops_commands::list_loop_validations, + loops_commands::list_loop_inbox, + loops_commands::get_loop_attention, + loops_commands::dismiss_loop_inbox, + loops_commands::list_loop_memory, + loops_commands::create_loop_memory, + loops_commands::update_loop_memory, + loops_commands::delete_loop_memory, terminal_commands::terminal_spawn, terminal_commands::terminal_write, terminal_commands::terminal_resize, diff --git a/src-tauri/src/loop_engine/actions.rs b/src-tauri/src/loop_engine/actions.rs new file mode 100644 index 0000000000..98d06d379b --- /dev/null +++ b/src-tauri/src/loop_engine/actions.rs @@ -0,0 +1,2247 @@ +//! Human-driven engine actions (§4.6): trigger / pause / resume / cancel. +//! +//! These are the only points where a person steers a loop; everything else is +//! engine-autonomous. Each is a small, DB-authoritative state transition layered +//! on the driver registry: +//! - **trigger**: pending → running; create the issue worktree; start a driver. +//! - **pause**: running → paused(manual); stop the driver. In-flight agents are +//! left alive — a pause halts *new* dispatch, it does not kill running work. +//! - **resume**: paused → running; start a fresh driver. +//! - **cancel**: → cancelled; stop the driver, kill every in-flight iteration's +//! agent subprocess, invalidate its capability token (so the host rejects late +//! submissions), and remove the worktree. +//! +//! The **merge gate** (§4.10) also lives here: [`LoopEngine::merge_issue`] lands +//! a finalized issue's loop branch onto its base branch under a per-repo lock, +//! with a stale-base check; a clean landing closes the issue, any fault blocks it +//! with an inbox card. +//! +//! Every transition is guarded: a miss (the issue is not in the expected source +//! state) surfaces as [`LoopError::Conflict`], which the frontend retries. The +//! merge gate is the exception — it is idempotent (already-`done` → `Ok`) and +//! returns the non-retryable [`LoopError::NotMergeable`] for other non-mergeable +//! states; see [`LoopEngine::merge_issue`]. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use chrono::Utc; +use sea_orm::sea_query::Expr; +use sea_orm::{ActiveEnum, ColumnTrait, EntityTrait, QueryFilter, TransactionTrait}; + +use crate::db::entities::loop_artifact::{self, ArtifactKind, ArtifactStatus}; +use crate::db::entities::loop_artifact_revision::ActorKind; +use crate::db::entities::loop_inbox_item::{self, InboxKind, InboxStatus}; +use crate::db::entities::loop_issue::{self, IssueStatus, PauseReason}; +use crate::db::entities::loop_iteration::{self, IterationStatus, Stage}; +use crate::db::service::folder_service; +use crate::db::service::loop_service::{artifact, inbox, issue, space}; +use crate::models::loops::{LoopChanged, LOOP_CHANGED_EVENT}; +use crate::web::event_bridge::emit_event; + +use crate::loop_engine::config_resolver::effective_config; +use crate::loop_engine::transitions::{ + cas_artifact_status, cas_issue_status, cas_task_force_done_no_op, clear_oscillation, +}; +use crate::loop_engine::worktree::{self, MergeOutcome}; +use crate::loop_engine::{LoopEngine, LoopError}; + +impl LoopEngine { + /// Trigger a pending issue: create its worktree, flip it running, and start + /// the driver. The worktree is created *before* the status flip so a non-git + /// repo (or any git failure) leaves the issue cleanly `pending`, retryable. + pub async fn trigger_issue(self: &Arc, issue_id: i32) -> Result<(), LoopError> { + let issue = issue::get_issue(&self.db.conn, issue_id) + .await? + .ok_or_else(|| LoopError::NotFound(format!("issue {issue_id}")))?; + if issue.status != IssueStatus::Pending { + return Err(LoopError::Conflict); + } + // Validates the space repo is git, creates the worktree + branch, and + // records the merge base on the issue (idempotent). + worktree::ensure_worktree(&self.db.conn, &self.data_dir, issue_id).await?; + if !cas_issue_status( + &self.db.conn, + issue_id, + IssueStatus::Pending, + IssueStatus::Running, + ) + .await? + { + return Err(LoopError::Conflict); + } + self.start_issue(issue_id).await; + Ok(()) + } + + /// Pause a running issue: halt new dispatch without killing in-flight agents. + /// `stop_issue` removes the driver from the registry synchronously, so a + /// follow-up resume always spawns a fresh driver (no handoff race). + pub async fn pause_issue(&self, issue_id: i32) -> Result<(), LoopError> { + let res = loop_issue::Entity::update_many() + .col_expr( + loop_issue::Column::Status, + Expr::value(IssueStatus::Paused.to_value()), + ) + .col_expr( + loop_issue::Column::PauseReason, + Expr::value(PauseReason::Manual.to_value()), + ) + .col_expr(loop_issue::Column::UpdatedAt, Expr::value(Utc::now())) + .filter(loop_issue::Column::Id.eq(issue_id)) + .filter(loop_issue::Column::Status.eq(IssueStatus::Running)) + .exec(&self.db.conn) + .await?; + if res.rows_affected != 1 { + return Err(LoopError::Conflict); + } + self.stop_issue(issue_id).await; + Ok(()) + } + + /// Resume a paused issue: clear the pause reason and start a fresh driver, + /// which re-evaluates the frontier (picking up any progress made while the + /// in-flight iteration finished during the pause). + pub async fn resume_issue(self: &Arc, issue_id: i32) -> Result<(), LoopError> { + let res = loop_issue::Entity::update_many() + .col_expr( + loop_issue::Column::Status, + Expr::value(IssueStatus::Running.to_value()), + ) + .col_expr( + loop_issue::Column::PauseReason, + Expr::value(Option::::None), + ) + .col_expr(loop_issue::Column::UpdatedAt, Expr::value(Utc::now())) + .filter(loop_issue::Column::Id.eq(issue_id)) + .filter(loop_issue::Column::Status.eq(IssueStatus::Paused)) + .exec(&self.db.conn) + .await?; + if res.rows_affected != 1 { + return Err(LoopError::Conflict); + } + self.start_issue(issue_id).await; + Ok(()) + } + + /// Cancel an issue from any non-terminal state: close it, stop the driver, + /// invalidate in-flight tokens, and remove the worktree. + pub async fn cancel_issue(&self, issue_id: i32) -> Result<(), LoopError> { + let now = Utc::now(); + let res = loop_issue::Entity::update_many() + .col_expr( + loop_issue::Column::Status, + Expr::value(IssueStatus::Cancelled.to_value()), + ) + .col_expr(loop_issue::Column::EndedAt, Expr::value(now)) + .col_expr(loop_issue::Column::UpdatedAt, Expr::value(now)) + .filter(loop_issue::Column::Id.eq(issue_id)) + .filter(loop_issue::Column::Status.is_in([ + IssueStatus::Pending, + IssueStatus::Running, + IssueStatus::Paused, + IssueStatus::Blocked, + ])) + .exec(&self.db.conn) + .await?; + if res.rows_affected != 1 { + return Err(LoopError::Conflict); + } + // Stop the driver, kill the agent processes, then invalidate every + // in-flight iteration: marking them cancelled releases their leases AND + // makes the host reject any late capability-token submission (ingest + // requires a `running` iteration). Killing precedes the worktree removal + // so no agent is still writing into the tree as it is torn down. + self.stop_issue(issue_id).await; + self.kill_in_flight_agents(issue_id).await; + loop_iteration::Entity::update_many() + .col_expr( + loop_iteration::Column::Status, + Expr::value(IterationStatus::Cancelled.to_value()), + ) + .col_expr(loop_iteration::Column::EndedAt, Expr::value(now)) + // D11: cancelled-before-settling → `abandoned` — COALESCE keeps any + // real outcome already recorded, so the write-once invariant holds at + // the write itself, not merely via the active-status filter (Codex r1). + .col_expr( + loop_iteration::Column::Outcome, + Expr::col(loop_iteration::Column::Outcome) + .if_null(loop_iteration::IterationOutcome::Abandoned.to_value()), + ) + .filter(loop_iteration::Column::IssueId.eq(issue_id)) + .filter( + loop_iteration::Column::Status + .is_in([IterationStatus::Queued, IterationStatus::Running]), + ) + .exec(&self.db.conn) + .await?; + self.remove_issue_worktree(issue_id).await; + Ok(()) + } + + /// Best-effort removal of an issue's git worktree (directory + admin entry). + /// The hidden folder row and its iteration conversations are kept for audit; + /// a cancelled issue's driver never restarts, so a stale `worktree_folder_id` + /// is never read again. Any failure is logged, not fatal — the cancel's DB + /// closure already succeeded. + async fn remove_issue_worktree(&self, issue_id: i32) { + let conn = &self.db.conn; + let Ok(Some(issue)) = issue::get_issue(conn, issue_id).await else { + return; + }; + let Some(folder_id) = issue.worktree_folder_id else { + return; + }; + let Ok(Some(folder)) = folder_service::get_folder_by_id(conn, folder_id).await else { + return; + }; + if !Path::new(&folder.path).exists() { + return; + } + let Ok(Some(space_row)) = space::get_space(conn, issue.space_id).await else { + return; + }; + let Ok(Some(repo)) = folder_service::get_folder_by_id(conn, space_row.folder_id).await + else { + return; + }; + if let Err(e) = + worktree::remove_worktree(Path::new(&repo.path), Path::new(&folder.path)).await + { + tracing::warn!(path = %folder.path, error = %e, "cancel: remove worktree failed"); + } + // Also remove any per-task / integrate worktrees of a parallel issue. Keep + // their branches for audit — cancel is not a permanent delete. + let _ = + worktree::remove_issue_subtree(Path::new(&repo.path), Path::new(&folder.path), false) + .await; + } + + /// Tear down the OS processes of an issue's in-flight iterations. Each live + /// iteration's `conversation_id` resolves to its agent connection; + /// `disconnect` sends the connection its shutdown command, reaping the child. + /// Best-effort: a connection that already exited just isn't found. Reads the + /// iteration rows directly (independent of the subsequent cancel CAS). + async fn kill_in_flight_agents(&self, issue_id: i32) { + let in_flight = match loop_iteration::Entity::find() + .filter(loop_iteration::Column::IssueId.eq(issue_id)) + .filter( + loop_iteration::Column::Status + .is_in([IterationStatus::Queued, IterationStatus::Running]), + ) + .all(&self.db.conn) + .await + { + Ok(rows) => rows, + Err(e) => { + tracing::warn!(error = %e, "cancel: load in-flight iterations failed"); + return; + } + }; + for it in in_flight { + let Some(cid) = it.conversation_id else { + continue; + }; + if let Some(conn_id) = self.manager.find_connection_by_conversation_id(cid).await { + if let Err(e) = self.manager.disconnect(&conn_id).await { + tracing::warn!(conn_id = %conn_id, error = %e, "cancel: disconnect failed"); + } + } + } + } + + /// Land a finalized issue's work onto its base branch — the merge gate + /// (§4.10). Invoked by `approve_merge` (the human gate) or the driver + /// (auto-merge); both take the same per-repo lock and run the same stale-base + /// checks. A clean landing closes the issue (`done`) and removes its + /// worktree; any fault (conflict / dirty base / failed re-validation / missing + /// base) blocks the issue with an inbox card naming the cause AND returns a + /// [`LoopError::MergeFailed`] carrying the reason — never a silent success that + /// would leave the issue stuck "running" with no visible explanation. + /// + /// **Idempotent and race-free.** Preconditions are evaluated *under* the + /// per-repo lock (not before it), so two actors — the human gate and the + /// driver's auto-merge, or two clicks across surfaces — cannot both pass the + /// gate and race the landing. A second call after the issue is already `done` + /// (a concurrent actor merged it) returns `Ok(())` and re-emits `merged`, + /// rather than the misleading `Conflict`/"retry". Any other non-`running` or + /// no-`result` state returns the non-retryable [`LoopError::NotMergeable`] and + /// emits a resync so a stale "running" view refetches the true status. + pub async fn merge_issue(&self, issue_id: i32) -> Result<(), LoopError> { + let conn = &self.db.conn; + + // Resolve the base repo path first — ONLY to choose which per-repo lock to + // take. The authoritative precondition check happens after the lock (below), + // so this pre-lock read cannot cause a TOCTOU. The repo path is immutable + // for a space (folder paths have no mutation path; `space.folder_id` is + // set once), so both actors derive the same lock key. + let issue_probe = issue::get_issue(conn, issue_id) + .await? + .ok_or_else(|| LoopError::NotFound(format!("issue {issue_id}")))?; + let space_row = space::get_space(conn, issue_probe.space_id) + .await? + .ok_or_else(|| LoopError::NotFound(format!("space {}", issue_probe.space_id)))?; + let repo = folder_service::get_folder_by_id(conn, space_row.folder_id) + .await? + .ok_or(LoopError::Detached)?; + let repo_path = PathBuf::from(&repo.path); + + // Serialize merges per base repo, THEN evaluate preconditions under the + // lock: two issues sharing a repo must not race their --no-ff landings, and + // two actors on the same issue must collapse to one effective merge. + let lock = self.repo_merge_lock(&repo_path).await; + let _guard = lock.lock().await; + + // Authoritative re-read under the lock. + let issue = issue::get_issue(conn, issue_id) + .await? + .ok_or_else(|| LoopError::NotFound(format!("issue {issue_id}")))?; + + // Idempotent: a concurrent actor (driver auto-merge or another click) + // already landed and closed this issue. `done` is written ONLY by the + // landing below and is terminal, so it unambiguously means "merged". + // Report success and re-emit so a stale view converges — never "retry". + if issue.status == IssueStatus::Done { + self.emit_changed(issue.space_id, issue_id, "merged"); + return Ok(()); + } + // Not mergeable: any other non-running state (blocked / cancelled / paused + // / pending), or the live result has not passed integration (D6) — finalize + // produced no result, or its whole-issue closure isn't verified + // (`gate_decision(result, finalize) == Pass`). Emit a resync FIRST so a view + // still showing "running" refetches the true status (the original + // transition's event may have been missed), then return the non-retryable + // error. + let dag = artifact::list_dag(conn, issue_id).await?; + let integration_passed = + crate::loop_engine::gates::integration_passed(conn, &dag).await?; + if issue.status != IssueStatus::Running || !integration_passed { + self.emit_changed(issue.space_id, issue_id, "merge_unavailable"); + return Err(LoopError::NotMergeable); + } + + let folder_id = issue + .worktree_folder_id + .ok_or_else(|| LoopError::NotFound(format!("issue {issue_id} worktree")))?; + let folder = folder_service::get_folder_by_id(conn, folder_id) + .await? + .ok_or(LoopError::Detached)?; + let worktree_path = PathBuf::from(&folder.path); + let branch = format!("loop/{}/issue-{}", issue.space_id, issue.seq_no); + let base_branch = issue + .base_branch + .clone() + .ok_or_else(|| LoopError::Git("issue has no recorded base branch".into()))?; + let base_commit = issue + .base_commit + .clone() + .ok_or_else(|| LoopError::Git("issue has no recorded base commit".into()))?; + let config = + crate::loop_engine::config_resolver::effective_config(&self.db.conn, &issue).await?; + + let outcome = worktree::merge_issue( + &repo_path, + &worktree_path, + &branch, + &base_branch, + &base_commit, + &config.validation_commands, + config.iteration_timeout_secs, + ) + .await?; + + // A non-`Merged` outcome means the landing could not happen. Surface the + // concrete reason as an error — NEVER a silent success that leaves the + // issue stuck "running" with no visible cause. Block the issue + file a + // durable card so the fault is visible to BOTH the human gate and the + // driver's auto-merge (which only logs the error) — no silent stall on + // "running". Supersede any pending merge-approval card so the blocked + // issue shows only the retry path, not a now-dead "approve". + if !matches!(outcome, MergeOutcome::Merged { .. }) { + let (reason, message, detail) = merge_fault_report(&outcome); + cas_issue_status(conn, issue_id, IssueStatus::Running, IssueStatus::Blocked).await?; + inbox::upsert_inbox( + conn, + issue.space_id, + issue_id, + None, + InboxKind::Blocked, + &format!("merge_blocked:{issue_id}"), + serde_json::json!({ "reason": reason, "detail": detail }), + ) + .await?; + resolve_approval_card( + conn, + issue_id, + &format!("merge:{issue_id}"), + serde_json::json!({ "action": "merge_failed", "reason": reason }), + ) + .await?; + self.emit_changed(issue.space_id, issue_id, "blocked"); + self.wake(issue_id).await; + return Err(LoopError::MergeFailed(message)); + } + + // Merged. Best-effort teardown; the DB update below is the source of truth — + // a merged issue never restarts, so a stale folder/worktree is inert. + let _ = worktree::remove_worktree(&repo_path, &worktree_path).await; + // Drop any per-task / integrate worktrees + their branches (a parallel + // issue's task work is now in base via the fan-in, so they are merged). + let _ = worktree::remove_issue_subtree(&repo_path, &worktree_path, true).await; + // The loop branch is now in base behind the --no-ff merge commit, so drop + // it. Safe `-d`: git refuses if it is somehow not merged, so this can never + // discard unlanded work. + let _ = worktree::delete_branch(&repo_path, &branch, false).await; + let _ = folder_service::remove_folder(conn, &folder.path).await; + resolve_approval_card( + conn, + issue_id, + &format!("merge:{issue_id}"), + serde_json::json!({ "action": "merged" }), + ) + .await?; + let now = Utc::now(); + let landed = loop_issue::Entity::update_many() + .col_expr( + loop_issue::Column::Status, + Expr::value(IssueStatus::Done.to_value()), + ) + .col_expr(loop_issue::Column::EndedAt, Expr::value(now)) + .col_expr(loop_issue::Column::UpdatedAt, Expr::value(now)) + .filter(loop_issue::Column::Id.eq(issue_id)) + .filter(loop_issue::Column::Status.eq(IssueStatus::Running)) + .exec(conn) + .await?; + if landed.rows_affected != 1 { + // Unreachable under the lock (status was a freshly-confirmed `running` + // re-read); the git work already landed, so warn rather than fail — + // failing would falsely imply nothing merged. + tracing::warn!( + issue_id, + rows = landed.rows_affected, + "merge: status CAS to done affected an unexpected row count after landing" + ); + } + self.emit_changed(issue.space_id, issue_id, "merged"); + // Nudge the driver: it re-ticks, sees the terminal status, and stops. + self.wake(issue_id).await; + // Release the per-repo merge lock BEFORE the best-effort reflect dispatch so + // spawning the reflect agent can never block another issue's merge on this + // repo. Reflect is post-merge memory consolidation (§4.4) — it must never + // affect the merge, so it runs only after `done` is durably committed. + drop(_guard); + if let Ok(Some(done)) = issue::get_issue(conn, issue_id).await { + self.dispatch_reflect_best_effort(&done).await; + } + Ok(()) + } + + /// Best-effort reflect dispatch for a completed (`Done`) issue. NEVER returns + /// an error and NEVER changes issue status — reflect must not touch the merge. + /// The single guard is the durable anchor (D12): if a `reflection` artifact + /// already exists for the issue, do nothing (covers crash-after-commit + a + /// no-op success). Bounded by `max_attempts`; exhaustion files a low-priority + /// inbox card (D11). Runs in the base repo folder with a read-only briefing (D1). + /// Called at the merge hook, on every reflect settle (uptime self-retry), and + /// on boot recovery. + pub(crate) async fn dispatch_reflect_best_effort(&self, issue: &loop_issue::Model) { + use sea_orm::PaginatorTrait; + let conn = &self.db.conn; + // Anchor: already consolidated? + match loop_artifact::Entity::find() + .filter(loop_artifact::Column::IssueId.eq(issue.id)) + .filter(loop_artifact::Column::Kind.eq(ArtifactKind::Reflection)) + .count(conn) + .await + { + Ok(0) => {} + Ok(_) => return, + Err(e) => { + tracing::warn!(issue_id = issue.id, error = %e, "reflect: anchor check failed"); + return; + } + } + let config = match crate::loop_engine::config_resolver::effective_config(conn, issue).await { + Ok(c) => c, + Err(e) => { + tracing::warn!(issue_id = issue.id, error = %e, "reflect: config resolve failed"); + return; + } + }; + // Count only TERMINAL reflect attempts (exclude queued/running): an in-flight + // reflect is not yet a spent attempt, so it can never trigger a premature + // exhaustion card while it might still produce the artifact. A finished + // reflect counts whether it Succeeded-without-artifact, Failed, Interrupted, + // or Cancelled — the artifact (not the iteration status) is the real success + // signal. + let attempts = match loop_iteration::Entity::find() + .filter(loop_iteration::Column::IssueId.eq(issue.id)) + .filter(loop_iteration::Column::Stage.eq(Stage::Reflect)) + .filter(loop_iteration::Column::Status.is_in([ + IterationStatus::Succeeded, + IterationStatus::Failed, + IterationStatus::Interrupted, + IterationStatus::Cancelled, + ])) + .count(conn) + .await + { + Ok(n) => n as u32, + Err(e) => { + tracing::warn!(issue_id = issue.id, error = %e, "reflect: attempt count failed"); + return; + } + }; + if config.max_attempts > 0 && attempts >= config.max_attempts { + // Terminal: a dismissible, informational card (idempotent upsert). NOT + // "blocked" — a `Done` issue is never mislabeled. + let _ = inbox::upsert_inbox( + conn, + issue.space_id, + issue.id, + None, + InboxKind::ReflectionFailed, + &format!("reflect_failed:{}", issue.id), + serde_json::json!({ "reason": "reflect_exhausted", "attempts": attempts }), + ) + .await; + self.emit_changed(issue.space_id, issue.id, "reflect_exhausted"); + return; + } + let folder_id = match space::get_space(conn, issue.space_id).await { + Ok(Some(s)) => s.folder_id, + _ => { + tracing::warn!(issue_id = issue.id, "reflect: space/folder lookup failed"); + return; + } + }; + let spec = crate::loop_engine::driver::resolve_agent_spec(&config, Stage::Reflect); + match self + .dispatch_iteration(crate::loop_engine::dispatch::DispatchInput { + space_id: issue.space_id, + issue_id: issue.id, + stage: Stage::Reflect, + target_artifact_id: None, + slot_no: None, + attempt: attempts as i32, + agent_type: spec.agent, + mode_id: spec.mode_id, + config_values: spec.config_values, + worktree_folder_id: folder_id, + }) + .await + { + Ok(Some(_)) => tracing::debug!(issue_id = issue.id, "reflect: dispatched"), + Ok(None) => tracing::debug!(issue_id = issue.id, "reflect: lease already held"), + Err(e) => { + tracing::warn!(issue_id = issue.id, error = %e, "reflect: dispatch failed (best-effort)") + } + } + } + + /// Approve the design gate (route=full): mark every design that is awaiting + /// approval `done` and wake the driver, which then advances to planning. + /// [`LoopError::Conflict`] when no design is awaiting (already approved / + /// rejected, or none produced). + pub async fn approve_design(&self, issue_id: i32) -> Result<(), LoopError> { + let conn = &self.db.conn; + let issue = issue::get_issue(conn, issue_id) + .await? + .ok_or_else(|| LoopError::NotFound(format!("issue {issue_id}")))?; + let awaiting = awaiting_design_ids(conn, issue_id).await?; + if awaiting.is_empty() { + return Err(LoopError::Conflict); + } + for id in &awaiting { + cas_artifact_status(conn, *id, ArtifactStatus::AwaitingApproval, ArtifactStatus::Done) + .await?; + } + resolve_approval_card( + conn, + issue_id, + &format!("design:{issue_id}"), + serde_json::json!({ "action": "approve" }), + ) + .await?; + self.emit_changed(issue.space_id, issue_id, "design_approved"); + self.wake(issue_id).await; + Ok(()) + } + + /// Reject the design gate: supersede every awaiting design (recording the + /// reviewer's comment as a human revision so the re-dispatched design isn't + /// blind) and wake the driver, which produces a fresh design. Conflict when + /// no design is awaiting. + pub async fn reject_design( + &self, + issue_id: i32, + comment: Option, + ) -> Result<(), LoopError> { + let conn = &self.db.conn; + let issue = issue::get_issue(conn, issue_id) + .await? + .ok_or_else(|| LoopError::NotFound(format!("issue {issue_id}")))?; + let awaiting = awaiting_design_ids(conn, issue_id).await?; + if awaiting.is_empty() { + return Err(LoopError::Conflict); + } + let note = comment.unwrap_or_default(); + for id in &awaiting { + cas_artifact_status( + conn, + *id, + ArtifactStatus::AwaitingApproval, + ArtifactStatus::Superseded, + ) + .await?; + if !note.trim().is_empty() { + artifact::add_revision( + conn, + *id, + &format!("[design rejected] {}", note.trim()), + ActorKind::Human, + None, + ) + .await?; + } + } + resolve_approval_card( + conn, + issue_id, + &format!("design:{issue_id}"), + serde_json::json!({ "action": "reject", "comment": note }), + ) + .await?; + self.emit_changed(issue.space_id, issue_id, "design_rejected"); + self.wake(issue_id).await; + Ok(()) + } + + /// Retry a blocked issue — the inbox "retry" escape hatch. Re-arms every + /// blocked task for a fresh implement run, marks the blocking cards handled, + /// and puts the issue back to `running` under a fresh driver. Conflict when + /// the issue is not `blocked`. + /// + /// Each non-oscillating blocked task is reset `blocked → pending` with its + /// failure signature cleared AND its `attempt` reset to 0 (D13) — a deliberate + /// fresh budget against `max_attempts` per retry. Oscillating tasks (a + /// deterministic repeat) are EXCLUDED — they need an explicit override/force + /// exit, not a plain retry — and their `oscillation_*` columns are preserved as + /// a cross-retry probe. Issue-level blocks (dirty finalize, merge fault/reject) + /// have no blocked task; retry simply re-drives so the engine re-evaluates. + pub async fn retry_issue(self: &Arc, issue_id: i32) -> Result<(), LoopError> { + let conn = &self.db.conn; + let issue = issue::get_issue(conn, issue_id) + .await? + .ok_or_else(|| LoopError::NotFound(format!("issue {issue_id}")))?; + if issue.status != IssueStatus::Blocked { + // Stale action: the issue is no longer blocked (e.g. already terminal + // while a view still shows "running"). Nudge subscribers to refetch the + // true status, then report the conflict. + self.emit_changed(issue.space_id, issue_id, "retry_unavailable"); + return Err(LoopError::Conflict); + } + // D13: the re-arm set excludes OSCILLATING tasks (a deterministic failure + // that plain retry can't fix — those need an explicit override/force exit). + let config = effective_config(conn, &issue).await?; + let limit = config.oscillation_limit as i32; + let blocked: Vec = loop_artifact::Entity::find() + .filter(loop_artifact::Column::IssueId.eq(issue_id)) + .filter(loop_artifact::Column::Kind.eq(ArtifactKind::Task)) + .filter(loop_artifact::Column::Status.eq(ArtifactStatus::Blocked)) + .all(conn) + .await?; + let is_oscillating = |t: &loop_artifact::Model| { + limit > 0 && t.oscillation_count >= limit && t.recent_failure_sig.is_some() + }; + let rearm: Vec = blocked + .iter() + .filter(|t| !is_oscillating(t)) + .map(|t| t.id) + .collect(); + // An issue-level block (triage_no_route / dependency / finalize_dirty / + // merge_*) has NO blocked task — a plain retry must still re-drive it. Reject + // ONLY when blocked tasks exist AND every one is oscillating (use override). + if !blocked.is_empty() && rearm.is_empty() { + self.emit_changed(issue.space_id, issue_id, "retry_unavailable"); + return Err(LoopError::Conflict); + } + // ── ONE transaction: issue anchor + task re-arm + card resolve commit + // together, so the driver's atomic re-park (C9) can never observe a + // half-applied "issue=running + task still blocked" state. SQLite serializes + // this txn against C9's UPDATE, which then sees only pre- or post-mutation + // state. + let txn = conn.begin().await?; + // Serialization gate: CAS issue blocked→running. A concurrent retry's loser + // sees 0 rows → rollback + Conflict, having mutated nothing. + if !cas_issue_status(&txn, issue_id, IssueStatus::Blocked, IssueStatus::Running).await? { + txn.rollback().await?; + return Err(LoopError::Conflict); + } + // Re-arm with a `status = Blocked` CAS filter so a task a concurrent + // force-complete moved Blocked→Done is NOT clobbered back to pending; reset + // the attempt budget (D13) but KEEP the oscillation columns (cross-retry + // probe — only override/force/real-progress clear them). + if !rearm.is_empty() { + loop_artifact::Entity::update_many() + .col_expr( + loop_artifact::Column::Status, + Expr::value(ArtifactStatus::Pending.to_value()), + ) + .col_expr(loop_artifact::Column::Attempt, Expr::value(0)) + .col_expr( + loop_artifact::Column::LastFailureSig, + Expr::value(Option::::None), + ) + .col_expr(loop_artifact::Column::UpdatedAt, Expr::value(Utc::now())) + .filter(loop_artifact::Column::Id.is_in(rearm.clone())) + .filter(loop_artifact::Column::Status.eq(ArtifactStatus::Blocked)) + .exec(&txn) + .await?; + } + // Resolve every pending Blocked card EXCEPT task-level `oscillation:` (those + // clear only via override/force) — issue-level blocks + the re-armed tasks' + // ordinary blockers, in one broad sweep. + inbox::resolve_blocked_cards_except_oscillation( + &txn, + issue_id, + serde_json::json!({ "action": "retry" }), + ) + .await?; + txn.commit().await?; + self.emit_changed(issue.space_id, issue_id, "retried"); + self.start_issue(issue_id).await; + Ok(()) + } + + /// D15: human force-complete of a blocked, empty-diff task — accept it as a + /// no-op so a wedged issue can finish. Cause-guarded to the `empty_diff:implement` + /// family ONLY (a validation/infra/abandoned cause is rejected: its reset tree + /// also looks clean, so accepting it would pass off unimplemented work as done). + /// A parallel task whose branch carries a committed delta is refused (the no-op + /// would discard it). Anchors the issue `running`, marks the task Done(no_op), + /// clears its oscillation epoch, resolves its blocker cards — all in one + /// transaction — then lets the driver re-evaluate (finalize / re-park). + pub async fn force_complete_task(self: &Arc, task_id: i32) -> Result<(), LoopError> { + let conn = &self.db.conn; + let task = loop_artifact::Entity::find_by_id(task_id) + .one(conn) + .await? + .filter(|a| a.kind == ArtifactKind::Task) + .ok_or_else(|| LoopError::NotFound(format!("task {task_id}")))?; + if task.status != ArtifactStatus::Blocked { + self.emit_changed(task.space_id, task.issue_id, "force_complete_unavailable"); + return Err(LoopError::Conflict); + } + // Cause guard (D15): gate on the CURRENT pending blocker card's failure_sig, + // not the artifact's `recent/last_failure_sig` columns (which validation / + // infra block paths leave stale). Only the `empty_diff:implement` family is + // no-op-compatible; a task re-blocked for a real validation/infra failure is + // rejected — and this holds in serial mode too, where the parallel + // branch-at-base defence below does not run. + let blocker_sig = inbox::task_blocker_failure_sig(conn, task.issue_id, task_id).await?; + if !matches!(blocker_sig.as_deref(), Some(s) if s.starts_with("empty_diff:implement")) { + self.emit_changed(task.space_id, task.issue_id, "force_complete_unavailable"); + return Err(LoopError::Conflict); + } + let issue = issue::get_issue(conn, task.issue_id) + .await? + .ok_or_else(|| LoopError::NotFound(format!("issue {}", task.issue_id)))?; + // Defence-in-depth (reads only, BEFORE the txn): never no-op away a real + // committed delta. If the parallel task branch advanced past its base, refuse. + if issue.execution_mode.as_deref() == Some("parallel") { + if let Some(false) = worktree::task_branch_at_base(conn, &issue, task_id).await? { + return Err(LoopError::Conflict); + } + } + // ── ONE transaction: anchor + done CAS + clear + resolve, so C9's re-park + // can never observe "issue=running + task still blocked" mid-action. + let txn = conn.begin().await?; + if let Err(e) = ensure_running_for_exit(&txn, &issue).await { + txn.rollback().await?; + return Err(e); + } + // Re-validate the cause INSIDE the serialized window (Codex r2). The pre-txn + // guard above is only a fast-fail: a concurrent retry could have re-armed and + // re-blocked this task for a DIFFERENT cause (validation/infra), or with a real + // branch delta, between that read and the CAS — which only checks `status = + // blocked`. Re-read the pending blocker card's failure_sig now that the write + // lock is held; only the empty_diff family stays no-op-eligible. A retry that + // re-armed the task resolves its old card, so a vanished card → None → reject. + let live_sig = inbox::task_blocker_failure_sig(&txn, task.issue_id, task_id).await?; + if !matches!(live_sig.as_deref(), Some(s) if s.starts_with("empty_diff:implement")) { + txn.rollback().await?; + self.emit_changed(task.space_id, task.issue_id, "force_complete_unavailable"); + return Err(LoopError::Conflict); + } + if !cas_task_force_done_no_op(&txn, task_id).await? { + txn.rollback().await?; + return Err(LoopError::Conflict); + } + clear_oscillation(&txn, task_id).await?; + inbox::resolve_task_blocker_cards( + &txn, + issue.id, + task_id, + &["no_progress", "validation_blocked", "infra_failure", "oscillation"], + serde_json::json!({ "action": "force_complete", "actor": "human" }), + ) + .await?; + txn.commit().await?; + self.emit_changed(issue.space_id, issue.id, "force_completed"); + // Issue is running → its driver re-evaluates (finalize / re-park). Never + // self-finalize here — the driver owns frontier / fan-in / re-park. + self.start_issue(issue.id).await; + Ok(()) + } + + /// D17: human override of an oscillation breaker — clear the epoch and re-arm + /// the task for a fresh attempt budget (distinct from a plain retry, which + /// deliberately EXCLUDES oscillating tasks). Anchors the issue `running`, + /// re-arms the task (pending, attempt 0, oscillation cleared), resolves ALL its + /// blocker cards (including `oscillation:`) — one transaction — then re-drives. + pub async fn override_oscillation(self: &Arc, task_id: i32) -> Result<(), LoopError> { + let conn = &self.db.conn; + let task = loop_artifact::Entity::find_by_id(task_id) + .one(conn) + .await? + .filter(|a| a.kind == ArtifactKind::Task) + .ok_or_else(|| LoopError::NotFound(format!("task {task_id}")))?; + if task.status != ArtifactStatus::Blocked { + self.emit_changed(task.space_id, task.issue_id, "override_unavailable"); + return Err(LoopError::Conflict); + } + // D17 precondition (Codex r2): override is for breaker-promoted tasks ONLY — + // require a pending `oscillation:` card, so this endpoint can't be used as a + // generic blocked-task reset (which is exactly what `retry` deliberately + // EXCLUDES). The UI only offers it on oscillation cards; enforce it server-side. + if !inbox::has_pending_oscillation_card(conn, task.issue_id, task_id).await? { + self.emit_changed(task.space_id, task.issue_id, "override_unavailable"); + return Err(LoopError::Conflict); + } + let issue = issue::get_issue(conn, task.issue_id) + .await? + .ok_or_else(|| LoopError::NotFound(format!("issue {}", task.issue_id)))?; + let txn = conn.begin().await?; + if let Err(e) = ensure_running_for_exit(&txn, &issue).await { + txn.rollback().await?; + return Err(e); + } + // Gate = re-arm CAS filtered on status='blocked' (double-click's 2nd call + // affects 0 rows → rollback + Conflict). Reset epoch + attempt + sigs. + let res = loop_artifact::Entity::update_many() + .col_expr( + loop_artifact::Column::Status, + Expr::value(ArtifactStatus::Pending.to_value()), + ) + .col_expr(loop_artifact::Column::Attempt, Expr::value(0)) + .col_expr( + loop_artifact::Column::LastFailureSig, + Expr::value(Option::::None), + ) + .col_expr(loop_artifact::Column::OscillationCount, Expr::value(0)) + .col_expr( + loop_artifact::Column::RecentFailureSig, + Expr::value(Option::::None), + ) + .col_expr(loop_artifact::Column::UpdatedAt, Expr::value(Utc::now())) + .filter(loop_artifact::Column::Id.eq(task_id)) + .filter(loop_artifact::Column::Status.eq(ArtifactStatus::Blocked)) + .exec(&txn) + .await?; + if res.rows_affected != 1 { + txn.rollback().await?; + return Err(LoopError::Conflict); + } + inbox::resolve_task_blocker_cards( + &txn, + issue.id, + task_id, + &["no_progress", "validation_blocked", "infra_failure", "oscillation"], + serde_json::json!({ "action": "override_oscillation", "actor": "human" }), + ) + .await?; + txn.commit().await?; + self.emit_changed(issue.space_id, issue.id, "oscillation_override"); + self.start_issue(issue.id).await; + Ok(()) + } + + /// Top up a budget-paused issue's token budget and resume it — the inbox + /// "add budget" escape hatch. `additional` (clamped to ≥ 0) is added to the + /// current `token_budget`; the budget card is marked handled and the issue + /// resumes under a fresh driver. Conflict when the issue is not `paused`. + pub async fn add_budget(self: &Arc, issue_id: i32, additional: i64) -> Result<(), LoopError> { + let conn = &self.db.conn; + let issue = issue::get_issue(conn, issue_id) + .await? + .ok_or_else(|| LoopError::NotFound(format!("issue {issue_id}")))?; + if issue.status != IssueStatus::Paused { + return Err(LoopError::Conflict); + } + let new_budget = issue.token_budget.unwrap_or(0).saturating_add(additional.max(0)); + loop_issue::Entity::update_many() + .col_expr(loop_issue::Column::TokenBudget, Expr::value(new_budget)) + .col_expr(loop_issue::Column::UpdatedAt, Expr::value(Utc::now())) + .filter(loop_issue::Column::Id.eq(issue_id)) + .exec(conn) + .await?; + resolve_cards_of_kind( + conn, + issue_id, + InboxKind::BudgetExhausted, + serde_json::json!({ "action": "add_budget", "additional": additional }), + ) + .await?; + self.emit_changed(issue.space_id, issue_id, "budget_added"); + // Flip paused → running, clear the pause reason, and start a fresh driver. + self.resume_issue(issue_id).await + } + + /// Emit the coarse `loop://changed` refetch signal for an issue. + pub(crate) fn emit_changed(&self, space_id: i32, issue_id: i32, kind: &str) { + emit_event( + &self.emitter, + LOOP_CHANGED_EVENT, + LoopChanged { + v: 1, + space_id, + issue_id: Some(issue_id), + subject_kind: "issue".to_string(), + subject_id: issue_id, + kind: kind.to_string(), + }, + ); + } +} + +/// The issue's design artifacts currently `awaiting_approval`. +async fn awaiting_design_ids( + conn: &sea_orm::DatabaseConnection, + issue_id: i32, +) -> Result, LoopError> { + let dag = artifact::list_dag(conn, issue_id).await?; + Ok(dag + .artifacts + .iter() + .filter(|a| { + a.kind == ArtifactKind::Design && a.status == ArtifactStatus::AwaitingApproval + }) + .map(|a| a.id) + .collect()) +} + +/// Mark the pending approval inbox card (`kind=approval`, `subject_key=subject`) +/// for an issue handled. No-op when none exists — auto paths and direct calls +/// run fine without a card. +async fn resolve_approval_card( + conn: &sea_orm::DatabaseConnection, + issue_id: i32, + subject: &str, + resolution: serde_json::Value, +) -> Result<(), LoopError> { + if let Some(card) = loop_inbox_item::Entity::find() + .filter(loop_inbox_item::Column::IssueId.eq(issue_id)) + .filter(loop_inbox_item::Column::Kind.eq(InboxKind::Approval)) + .filter(loop_inbox_item::Column::SubjectKey.eq(subject.to_string())) + .filter(loop_inbox_item::Column::Status.eq(InboxStatus::Pending)) + .one(conn) + .await? + { + inbox::handle_inbox(conn, card.id, resolution).await?; + } + Ok(()) +} + +/// Mark every pending inbox card of `kind` for an issue handled. A blocked issue +/// may carry more than one card (e.g. several `no_progress:{task}` keys), so the +/// retry / add-budget escape hatches clear them all in one resolution. +/// Anchor an issue `running` before a human exit action mutates its tasks (D15/D17), +/// so a post-commit crash is boot-restartable and the driver re-evaluates. Takes +/// `&impl ConnectionTrait` to run inside the exit-action transaction. +/// +/// Authoritative re-anchor (Codex r3): it does NOT trust the caller's (possibly +/// stale) `issue.status`. One guarded write sets `running` for any issue currently +/// `running` OR `blocked`. This both (a) re-anchors an issue a concurrent driver +/// re-parked `running → blocked` after the caller read it, and (b) acquires the +/// issue-row write lock here — BEFORE the live blocker-card read and task CAS that +/// follow in the same transaction — so a re-park can no longer flip the issue +/// between those reads and the commit (which would otherwise strand an all-done +/// issue `blocked` with no actionable card). `updated_at` always changes, so a +/// still-`running` issue still counts as one affected row; a terminal / paused issue +/// matches zero rows → `Conflict`. +async fn ensure_running_for_exit( + conn: &impl sea_orm::ConnectionTrait, + issue: &loop_issue::Model, +) -> Result<(), LoopError> { + use IssueStatus::*; + let res = loop_issue::Entity::update_many() + .col_expr(loop_issue::Column::Status, Expr::value(Running.to_value())) + .col_expr(loop_issue::Column::UpdatedAt, Expr::value(Utc::now())) + .filter(loop_issue::Column::Id.eq(issue.id)) + .filter(loop_issue::Column::Status.is_in([Running, Blocked])) + .exec(conn) + .await?; + if res.rows_affected == 1 { + Ok(()) + } else { + Err(LoopError::Conflict) + } +} + +async fn resolve_cards_of_kind( + conn: &sea_orm::DatabaseConnection, + issue_id: i32, + kind: InboxKind, + resolution: serde_json::Value, +) -> Result<(), LoopError> { + let cards = loop_inbox_item::Entity::find() + .filter(loop_inbox_item::Column::IssueId.eq(issue_id)) + .filter(loop_inbox_item::Column::Kind.eq(kind)) + .filter(loop_inbox_item::Column::Status.eq(InboxStatus::Pending)) + .all(conn) + .await?; + for card in cards { + inbox::handle_inbox(conn, card.id, resolution.clone()).await?; + } + Ok(()) +} + +/// Map a non-`Merged` outcome to `(inbox reason code, user-facing message, +/// diagnostic detail)`. The reason code keys the inbox card; the message is the +/// error toast the user sees; the detail carries the git/validation output. +fn merge_fault_report(outcome: &MergeOutcome) -> (&'static str, String, String) { + match outcome { + MergeOutcome::BaseGone => ( + "base_gone", + "The base branch no longer exists.".to_string(), + "base branch no longer exists".to_string(), + ), + MergeOutcome::BaseDirty => ( + "base_dirty", + "The base repository has uncommitted changes to tracked files. Commit or stash them, then merge again." + .to_string(), + "base repo working tree has uncommitted tracked changes".to_string(), + ), + MergeOutcome::Conflict { stage, detail } => { + let (reason, message) = if *stage == "integrate" { + ( + "merge_conflict_integrate", + "Merge conflict while integrating the latest base into the issue branch.", + ) + } else { + ( + "merge_conflict", + "Merge conflict while landing the issue branch onto the base branch.", + ) + }; + (reason, message.to_string(), detail.clone()) + } + MergeOutcome::RevalidationFailed { output } => ( + "revalidation_failed", + "Re-validation failed on the merged result.".to_string(), + output.clone(), + ), + // Not reached: the success arm is handled before this is called. + MergeOutcome::Merged { .. } => ("merged", "Merge failed.".to_string(), String::new()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::acp::manager::ConnectionManager; + use crate::db::entities::loop_artifact::{self, ArtifactStatus}; + use crate::db::entities::loop_artifact_revision::ActorKind; + use crate::db::entities::loop_issue::IssuePriority; + use crate::db::entities::loop_iteration::Stage; + use crate::db::test_helpers::{fresh_disk_db, fresh_in_memory_db, seed_folder}; + use crate::loop_engine::transitions::{cas_iteration_status, try_claim_iteration, IterationClaim}; + use crate::models::agent::AgentType; + use crate::models::loops::IssueConfig; + use crate::web::event_bridge::EventEmitter; + use std::process::Command as StdCommand; + + /// Build an engine + a single issue already marked `running` (without going + /// through trigger, so no worktree or driver is created — the pause/cancel + /// paths under test never need one). + async fn setup() -> (Arc, sea_orm::DatabaseConnection, i32, i32) { + let db = fresh_in_memory_db().await; + let conn = db.conn.clone(); + let folder_id = seed_folder(&db, "/tmp/loop-actions").await; + let space = space::create_space(&conn, "S", folder_id).await.unwrap(); + let issue = issue::create_issue( + &conn, + space.id, + "I", + "b", + IssuePriority::Medium, + Some(&IssueConfig::default()), + ) + .await + .unwrap(); + cas_issue_status(&conn, issue.row.id, IssueStatus::Pending, IssueStatus::Running) + .await + .unwrap(); + let engine = LoopEngine::new( + db, + ConnectionManager::new(), + std::path::PathBuf::from("/tmp/loop-actions-data"), + EventEmitter::Noop, + ); + (engine, conn, space.id, issue.row.id) + } + + #[tokio::test] + async fn pause_sets_manual_reason_then_conflicts() { + let (engine, conn, _space, issue_id) = setup().await; + engine.pause_issue(issue_id).await.unwrap(); + + let issue = issue::get_issue(&conn, issue_id).await.unwrap().unwrap(); + assert_eq!(issue.status, IssueStatus::Paused); + assert_eq!(issue.pause_reason, Some(PauseReason::Manual)); + + // A second pause (no longer running) is a conflict, not a silent no-op. + assert!(matches!( + engine.pause_issue(issue_id).await, + Err(LoopError::Conflict) + )); + } + + #[tokio::test] + async fn cancel_closes_issue_and_invalidates_in_flight_token() { + let (engine, conn, space_id, issue_id) = setup().await; + // An in-flight iteration holding a lease + a live capability token. + let iter = try_claim_iteration( + &conn, + IterationClaim { + space_id, + issue_id, + stage: Stage::Triage, + target_artifact_id: None, + slot_no: None, + capability_token: "live-token".into(), + attempt: 0, + }, + ) + .await + .unwrap() + .unwrap(); + cas_iteration_status(&conn, iter.id, IterationStatus::Queued, IterationStatus::Running) + .await + .unwrap(); + + // A sibling that already SUCCEEDED with a real outcome — cancel must not + // clobber it (C2). Its terminal status excludes it from the abandon bulk. + let done = try_claim_iteration( + &conn, + IterationClaim { + space_id, + issue_id, + stage: Stage::Refine, + target_artifact_id: None, + slot_no: None, + capability_token: "done-token".into(), + attempt: 0, + }, + ) + .await + .unwrap() + .unwrap(); + cas_iteration_status(&conn, done.id, IterationStatus::Queued, IterationStatus::Running) + .await + .unwrap(); + cas_iteration_status(&conn, done.id, IterationStatus::Running, IterationStatus::Succeeded) + .await + .unwrap(); + crate::db::service::loop_service::iteration::set_iteration_outcome( + &conn, + done.id, + loop_iteration::IterationOutcome::Succeeded, + ) + .await + .unwrap(); + + engine.cancel_issue(issue_id).await.unwrap(); + + let issue = issue::get_issue(&conn, issue_id).await.unwrap().unwrap(); + assert_eq!(issue.status, IssueStatus::Cancelled); + assert!(issue.ended_at.is_some()); + let it = loop_iteration::Entity::find_by_id(iter.id) + .one(&conn) + .await + .unwrap() + .unwrap(); + assert_eq!( + it.status, + IterationStatus::Cancelled, + "the in-flight token is invalidated so the host rejects late writes" + ); + // D11: the cancelled in-flight iteration is recorded `abandoned`. + assert_eq!(it.outcome, Some(loop_iteration::IterationOutcome::Abandoned)); + // C2: the succeeded sibling's real outcome is preserved, never overwritten. + let done_after = loop_iteration::Entity::find_by_id(done.id) + .one(&conn) + .await + .unwrap() + .unwrap(); + assert_eq!( + done_after.outcome, + Some(loop_iteration::IterationOutcome::Succeeded), + "cancel must not clobber a real outcome (C2)" + ); + } + + #[tokio::test] + async fn cancel_works_from_paused_then_conflicts_when_terminal() { + let (engine, conn, _space, issue_id) = setup().await; + engine.pause_issue(issue_id).await.unwrap(); + engine.cancel_issue(issue_id).await.unwrap(); + assert_eq!( + issue::get_issue(&conn, issue_id).await.unwrap().unwrap().status, + IssueStatus::Cancelled + ); + // Cancelling an already-terminal issue is a conflict. + assert!(matches!( + engine.cancel_issue(issue_id).await, + Err(LoopError::Conflict) + )); + } + + #[tokio::test] + async fn cancel_disconnects_live_agent() { + let (engine, conn, space_id, issue_id) = setup().await; + // An in-flight running iteration bound to a conversation. + let iter = try_claim_iteration( + &conn, + IterationClaim { + space_id, + issue_id, + stage: Stage::Triage, + target_artifact_id: None, + slot_no: None, + capability_token: "tok".into(), + attempt: 0, + }, + ) + .await + .unwrap() + .unwrap(); + cas_iteration_status(&conn, iter.id, IterationStatus::Queued, IterationStatus::Running) + .await + .unwrap(); + let convo = 4242; + loop_iteration::Entity::update_many() + .col_expr(loop_iteration::Column::ConversationId, Expr::value(convo)) + .filter(loop_iteration::Column::Id.eq(iter.id)) + .exec(&conn) + .await + .unwrap(); + // A live agent connection whose session is bound to that conversation. + engine + .manager + .insert_test_connection("agent-conn", AgentType::ClaudeCode, None, EventEmitter::Noop) + .await; + engine + .manager + .get_state("agent-conn") + .await + .unwrap() + .write() + .await + .conversation_id = Some(convo); + assert!(engine + .manager + .find_connection_by_conversation_id(convo) + .await + .is_some()); + + engine.cancel_issue(issue_id).await.unwrap(); + + assert!( + engine + .manager + .find_connection_by_conversation_id(convo) + .await + .is_none(), + "the agent process connection is killed on cancel" + ); + } + + // ── Blocked / budget escape hatches ───────────────────────────────────── + + #[tokio::test] + async fn retry_rearms_blocked_task_and_resolves_cards() { + let (engine, conn, space_id, issue_id) = setup().await; + // A blocked task carrying a failure signature + its no-progress card. + let task = artifact::create_artifact( + &conn, + space_id, + issue_id, + ArtifactKind::Task, + "T", + ArtifactStatus::Blocked, + ActorKind::Agent, + None, + ) + .await + .unwrap(); + loop_artifact::Entity::update_many() + .col_expr( + loop_artifact::Column::LastFailureSig, + Expr::value("validation_failed:abc".to_string()), + ) + .filter(loop_artifact::Column::Id.eq(task.id)) + .exec(&conn) + .await + .unwrap(); + cas_issue_status(&conn, issue_id, IssueStatus::Running, IssueStatus::Blocked) + .await + .unwrap(); + inbox::upsert_inbox( + &conn, + space_id, + issue_id, + None, + InboxKind::Blocked, + &format!("no_progress:{}", task.id), + serde_json::json!({ "reason": "max_attempts" }), + ) + .await + .unwrap(); + + engine.retry_issue(issue_id).await.unwrap(); + + let t = loop_artifact::Entity::find_by_id(task.id) + .one(&conn) + .await + .unwrap() + .unwrap(); + assert_eq!(t.status, ArtifactStatus::Pending, "blocked task re-armed"); + assert!(t.last_failure_sig.is_none(), "failure signature cleared"); + assert_eq!( + issue::get_issue(&conn, issue_id).await.unwrap().unwrap().status, + IssueStatus::Running + ); + assert!( + inbox::list_inbox(&conn, space_id, Some(InboxStatus::Pending)) + .await + .unwrap() + .is_empty(), + "blocking card resolved" + ); + engine.stop_issue(issue_id).await; + + // Retrying an issue that is no longer blocked is a conflict. + assert!(matches!( + engine.retry_issue(issue_id).await, + Err(LoopError::Conflict) + )); + } + + /// Helpers for the D13 oscillation-aware retry tests. + async fn mk_blocked_task( + conn: &sea_orm::DatabaseConnection, + space_id: i32, + issue_id: i32, + title: &str, + ) -> i32 { + artifact::create_artifact( + conn, space_id, issue_id, ArtifactKind::Task, title, + ArtifactStatus::Blocked, ActorKind::Agent, None, + ) + .await + .unwrap() + .id + } + async fn set_oscillating(conn: &sea_orm::DatabaseConnection, task: i32, count: i32) { + loop_artifact::Entity::update_many() + .col_expr(loop_artifact::Column::OscillationCount, Expr::value(count)) + .col_expr( + loop_artifact::Column::RecentFailureSig, + Expr::value("validation_failed:zzz".to_string()), + ) + .filter(loop_artifact::Column::Id.eq(task)) + .exec(conn) + .await + .unwrap(); + } + async fn card_pending(conn: &sea_orm::DatabaseConnection, issue_id: i32, subject: &str) -> bool { + loop_inbox_item::Entity::find() + .filter(loop_inbox_item::Column::IssueId.eq(issue_id)) + .filter(loop_inbox_item::Column::SubjectKey.eq(subject.to_string())) + .filter(loop_inbox_item::Column::Status.eq(InboxStatus::Pending)) + .one(conn) + .await + .unwrap() + .is_some() + } + + #[tokio::test] + async fn retry_excludes_oscillating_tasks_and_keeps_their_card() { + let (engine, conn, space_id, issue_id) = setup().await; + // One ordinary blocked task + one oscillating (count >= default limit 2). + let ordinary = mk_blocked_task(&conn, space_id, issue_id, "ord").await; + let osc = mk_blocked_task(&conn, space_id, issue_id, "osc").await; + set_oscillating(&conn, osc, 2).await; + for (t, prefix) in [(ordinary, "no_progress"), (osc, "oscillation")] { + inbox::upsert_inbox( + &conn, space_id, issue_id, None, InboxKind::Blocked, + &format!("{prefix}:{t}"), serde_json::json!({ "reason": prefix }), + ) + .await + .unwrap(); + } + cas_issue_status(&conn, issue_id, IssueStatus::Running, IssueStatus::Blocked) + .await + .unwrap(); + + engine.retry_issue(issue_id).await.unwrap(); + engine.stop_issue(issue_id).await; + + let ord = loop_artifact::Entity::find_by_id(ordinary).one(&conn).await.unwrap().unwrap(); + assert_eq!(ord.status, ArtifactStatus::Pending, "ordinary task re-armed"); + assert_eq!(ord.attempt, 0, "attempt budget reset"); + let x = loop_artifact::Entity::find_by_id(osc).one(&conn).await.unwrap().unwrap(); + assert_eq!(x.status, ArtifactStatus::Blocked, "oscillating task NOT re-armed"); + assert_eq!(x.oscillation_count, 2, "oscillation columns preserved across retry"); + assert!(!card_pending(&conn, issue_id, &format!("no_progress:{ordinary}")).await); + assert!( + card_pending(&conn, issue_id, &format!("oscillation:{osc}")).await, + "oscillation card survives a plain retry" + ); + } + + #[tokio::test] + async fn retry_all_oscillating_is_conflict() { + let (engine, conn, space_id, issue_id) = setup().await; + let osc = mk_blocked_task(&conn, space_id, issue_id, "osc").await; + set_oscillating(&conn, osc, 2).await; + cas_issue_status(&conn, issue_id, IssueStatus::Running, IssueStatus::Blocked) + .await + .unwrap(); + assert!(matches!( + engine.retry_issue(issue_id).await, + Err(LoopError::Conflict) + )); + let x = loop_artifact::Entity::find_by_id(osc).one(&conn).await.unwrap().unwrap(); + assert_eq!(x.status, ArtifactStatus::Blocked, "issue untouched on conflict"); + } + + #[tokio::test] + async fn retry_issue_level_block_redrives() { + let (engine, conn, space_id, issue_id) = setup().await; + // No blocked task — an issue-level block (e.g. finalize dirty). + inbox::upsert_inbox( + &conn, space_id, issue_id, None, InboxKind::Blocked, + "finalize_dirty:issue", serde_json::json!({ "reason": "finalize_dirty" }), + ) + .await + .unwrap(); + cas_issue_status(&conn, issue_id, IssueStatus::Running, IssueStatus::Blocked) + .await + .unwrap(); + + engine.retry_issue(issue_id).await.unwrap(); + engine.stop_issue(issue_id).await; + + assert_eq!( + issue::get_issue(&conn, issue_id).await.unwrap().unwrap().status, + IssueStatus::Running, + "issue-level block still re-drives on retry" + ); + assert!(!card_pending(&conn, issue_id, "finalize_dirty:issue").await); + } + + #[tokio::test] + async fn force_complete_only_for_empty_diff_cause() { + let (engine, conn, space_id, issue_id) = setup().await; + let task = mk_blocked_task(&conn, space_id, issue_id, "t").await; + cas_issue_status(&conn, issue_id, IssueStatus::Running, IssueStatus::Blocked) + .await + .unwrap(); + + // Wrong cause (validation failure) → rejected even though the tree looks + // clean. The guard reads the pending blocker CARD's `failure_sig` (mirroring + // how the engine files blocks), NOT the artifact column. + inbox::upsert_inbox( + &conn, space_id, issue_id, None, InboxKind::Blocked, + &format!("validation_blocked:{task}"), + serde_json::json!({ "failure_sig": "validation_failed:abc" }), + ) + .await + .unwrap(); + assert!(matches!( + engine.force_complete_task(task).await, + Err(LoopError::Conflict) + )); + assert_eq!( + loop_artifact::Entity::find_by_id(task).one(&conn).await.unwrap().unwrap().status, + ArtifactStatus::Blocked + ); + + // Re-blocked for a genuine empty diff: resolve the stale validation card and + // file the `no_progress` card the empty-diff path files (carrying + // `failure_sig`). Now force-complete accepts it as a no-op. + inbox::resolve_task_blocker_cards( + &conn, issue_id, task, &["validation_blocked"], serde_json::json!({}), + ) + .await + .unwrap(); + inbox::upsert_inbox( + &conn, space_id, issue_id, None, InboxKind::Blocked, + &format!("no_progress:{task}"), + serde_json::json!({ "failure_sig": "empty_diff:implement", "reason": "max_attempts" }), + ) + .await + .unwrap(); + engine.force_complete_task(task).await.unwrap(); + engine.stop_issue(issue_id).await; + + let t = loop_artifact::Entity::find_by_id(task).one(&conn).await.unwrap().unwrap(); + assert_eq!(t.status, ArtifactStatus::Done); + assert_eq!( + t.contribution_kind, + crate::db::entities::loop_artifact::ContributionKind::NoOp + ); + assert!(t.fan_in_commit.is_none(), "force-complete records a no-op (NULL commit)"); + assert_eq!( + issue::get_issue(&conn, issue_id).await.unwrap().unwrap().status, + IssueStatus::Running + ); + assert!(!card_pending(&conn, issue_id, &format!("no_progress:{task}")).await); + } + + #[tokio::test] + async fn force_complete_rejects_terminal_issue() { + let (engine, conn, space_id, issue_id) = setup().await; + let task = mk_blocked_task(&conn, space_id, issue_id, "t").await; + // A valid empty-diff blocker card so the cause guard passes — the rejection + // must come from the terminal-issue check, not the cause guard. + inbox::upsert_inbox( + &conn, space_id, issue_id, None, InboxKind::Blocked, + &format!("no_progress:{task}"), + serde_json::json!({ "failure_sig": "empty_diff:implement" }), + ) + .await + .unwrap(); + // Issue is cancelled → ensure_running_for_exit refuses. + loop_issue::Entity::update_many() + .col_expr( + loop_issue::Column::Status, + Expr::value(IssueStatus::Cancelled.to_value()), + ) + .filter(loop_issue::Column::Id.eq(issue_id)) + .exec(&conn) + .await + .unwrap(); + assert!(matches!( + engine.force_complete_task(task).await, + Err(LoopError::Conflict) + )); + assert_eq!( + loop_artifact::Entity::find_by_id(task).one(&conn).await.unwrap().unwrap().status, + ArtifactStatus::Blocked, + "task untouched when the issue can't be anchored" + ); + } + + #[tokio::test] + async fn override_oscillation_rearms_and_clears_cards() { + let (engine, conn, space_id, issue_id) = setup().await; + let task = mk_blocked_task(&conn, space_id, issue_id, "t").await; + set_oscillating(&conn, task, 2).await; + inbox::upsert_inbox( + &conn, space_id, issue_id, None, InboxKind::Blocked, + &format!("oscillation:{task}"), serde_json::json!({ "reason": "oscillation" }), + ) + .await + .unwrap(); + cas_issue_status(&conn, issue_id, IssueStatus::Running, IssueStatus::Blocked) + .await + .unwrap(); + + engine.override_oscillation(task).await.unwrap(); + engine.stop_issue(issue_id).await; + + let t = loop_artifact::Entity::find_by_id(task).one(&conn).await.unwrap().unwrap(); + assert_eq!(t.status, ArtifactStatus::Pending, "task re-armed"); + assert_eq!(t.attempt, 0); + assert_eq!(t.oscillation_count, 0, "oscillation epoch cleared"); + assert!(t.recent_failure_sig.is_none()); + assert_eq!( + issue::get_issue(&conn, issue_id).await.unwrap().unwrap().status, + IssueStatus::Running + ); + assert!( + !card_pending(&conn, issue_id, &format!("oscillation:{task}")).await, + "oscillation card resolved by the override" + ); + } + + /// D17 precondition (Codex r2): override is for breaker-promoted tasks ONLY. A + /// blocked task with no pending `oscillation:` card (here an ordinary no_progress + /// block) is rejected, so the endpoint can never be a generic blocked-task reset. + #[tokio::test] + async fn override_rejects_non_oscillating_blocked_task() { + let (engine, conn, space_id, issue_id) = setup().await; + let task = mk_blocked_task(&conn, space_id, issue_id, "t").await; + inbox::upsert_inbox( + &conn, space_id, issue_id, None, InboxKind::Blocked, + &format!("no_progress:{task}"), + serde_json::json!({ "failure_sig": "empty_diff:implement" }), + ) + .await + .unwrap(); + cas_issue_status(&conn, issue_id, IssueStatus::Running, IssueStatus::Blocked) + .await + .unwrap(); + + assert!(matches!( + engine.override_oscillation(task).await, + Err(LoopError::Conflict) + )); + assert_eq!( + loop_artifact::Entity::find_by_id(task).one(&conn).await.unwrap().unwrap().status, + ArtifactStatus::Blocked, + "task untouched without a pending oscillation card" + ); + } + + /// Codex r2: the force-complete cause guard is re-validated inside the txn. If a + /// concurrent retry resolved the blocker card (re-arming the task), the pending + /// card vanishes — force-complete must reject rather than no-op a task whose block + /// is no longer a live empty diff. (A true mid-txn race isn't deterministically + /// injectable in this harness; this exercises the resolved-card guarded state the + /// in-txn re-read enforces.) + #[tokio::test] + async fn force_complete_rejects_when_blocker_card_resolved() { + let (engine, conn, space_id, issue_id) = setup().await; + let task = mk_blocked_task(&conn, space_id, issue_id, "t").await; + cas_issue_status(&conn, issue_id, IssueStatus::Running, IssueStatus::Blocked) + .await + .unwrap(); + inbox::upsert_inbox( + &conn, space_id, issue_id, None, InboxKind::Blocked, + &format!("no_progress:{task}"), + serde_json::json!({ "failure_sig": "empty_diff:implement" }), + ) + .await + .unwrap(); + // A concurrent retry would resolve the blocker card while re-arming the task. + inbox::resolve_task_blocker_cards( + &conn, issue_id, task, &["no_progress"], serde_json::json!({}), + ) + .await + .unwrap(); + + assert!(matches!( + engine.force_complete_task(task).await, + Err(LoopError::Conflict) + )); + assert_eq!( + loop_artifact::Entity::find_by_id(task).one(&conn).await.unwrap().unwrap().status, + ArtifactStatus::Blocked, + "a vanished blocker card blocks force-complete" + ); + } + + /// Codex r3: an exit action entered with a STALE `running` issue model must still + /// re-anchor from the LIVE DB state. A concurrent driver re-park could have flipped + /// the row `running → blocked` after the caller read it; the old helper trusted the + /// model and returned Ok without writing, which would strand an all-done issue + /// `blocked` with no actionable card. The helper now re-anchors authoritatively. + #[tokio::test] + async fn ensure_running_for_exit_reanchors_stale_running_model() { + let (_engine, conn, _space_id, issue_id) = setup().await; + // A model captured while the issue was running ... + let stale = issue::get_issue(&conn, issue_id).await.unwrap().unwrap(); + assert_eq!(stale.status, IssueStatus::Running); + // ... while the live row was re-parked to blocked by a driver. + cas_issue_status(&conn, issue_id, IssueStatus::Running, IssueStatus::Blocked) + .await + .unwrap(); + + ensure_running_for_exit(&conn, &stale).await.unwrap(); + assert_eq!( + issue::get_issue(&conn, issue_id).await.unwrap().unwrap().status, + IssueStatus::Running, + "re-anchored from the live blocked row, not the stale running model" + ); + + // A terminal issue is refused even when the stale model still says running. + loop_issue::Entity::update_many() + .col_expr( + loop_issue::Column::Status, + Expr::value(IssueStatus::Cancelled.to_value()), + ) + .filter(loop_issue::Column::Id.eq(issue_id)) + .exec(&conn) + .await + .unwrap(); + assert!(matches!( + ensure_running_for_exit(&conn, &stale).await, + Err(LoopError::Conflict) + )); + } + + #[tokio::test] + async fn add_budget_tops_up_and_resumes() { + let (engine, conn, space_id, issue_id) = setup().await; + // A budget-paused issue: budget set, paused(budget), card filed. + loop_issue::Entity::update_many() + .col_expr(loop_issue::Column::TokenBudget, Expr::value(1000_i64)) + .filter(loop_issue::Column::Id.eq(issue_id)) + .exec(&conn) + .await + .unwrap(); + cas_issue_status(&conn, issue_id, IssueStatus::Running, IssueStatus::Paused) + .await + .unwrap(); + loop_issue::Entity::update_many() + .col_expr( + loop_issue::Column::PauseReason, + Expr::value(PauseReason::Budget.to_value()), + ) + .filter(loop_issue::Column::Id.eq(issue_id)) + .exec(&conn) + .await + .unwrap(); + inbox::upsert_inbox( + &conn, + space_id, + issue_id, + None, + InboxKind::BudgetExhausted, + &format!("budget:{issue_id}"), + serde_json::json!({ "token_used": 1200, "token_budget": 1000 }), + ) + .await + .unwrap(); + + engine.add_budget(issue_id, 5000).await.unwrap(); + + let issue = issue::get_issue(&conn, issue_id).await.unwrap().unwrap(); + assert_eq!(issue.token_budget, Some(6000), "budget topped up"); + assert_eq!(issue.status, IssueStatus::Running, "issue resumed"); + assert_eq!(issue.pause_reason, None, "pause reason cleared"); + assert!( + inbox::list_inbox(&conn, space_id, Some(InboxStatus::Pending)) + .await + .unwrap() + .is_empty(), + "budget card resolved" + ); + engine.stop_issue(issue_id).await; + + // Adding budget to a running (non-paused) issue is a conflict. + assert!(matches!( + engine.add_budget(issue_id, 1000).await, + Err(LoopError::Conflict) + )); + } + + // ── Merge gate (real git repo) ────────────────────────────────────────── + + fn git(dir: &Path, args: &[&str]) { + let st = StdCommand::new("git") + .args(args) + .current_dir(dir) + .status() + .expect("spawn git"); + assert!(st.success(), "git {args:?} failed"); + } + + fn init_repo(dir: &Path) { + git(dir, &["init", "-q"]); + git(dir, &["config", "user.email", "t@example.com"]); + git(dir, &["config", "user.name", "tester"]); + std::fs::write(dir.join("README.md"), "hello\n").unwrap(); + git(dir, &["add", "-A"]); + git(dir, &["commit", "-q", "-m", "init"]); + } + + /// Engine + real git repo + an issue triggered (worktree created, running) + /// carrying one loop commit and a produced `result` artifact — i.e. a fully + /// finalized issue sitting at the merge gate. + async fn setup_repo() -> ( + Arc, + sea_orm::DatabaseConnection, + tempfile::TempDir, + tempfile::TempDir, + i32, + i32, + ) { + let repo = tempfile::tempdir().unwrap(); + init_repo(repo.path()); + let data = tempfile::tempdir().unwrap(); + let db = fresh_disk_db(data.path()).await; + let conn = db.conn.clone(); + let folder_id = seed_folder(&db, &repo.path().to_string_lossy()).await; + let space = space::create_space(&conn, "S", folder_id).await.unwrap(); + let issue = issue::create_issue( + &conn, + space.id, + "I", + "b", + IssuePriority::Medium, + Some(&IssueConfig::default()), + ) + .await + .unwrap(); + let engine = LoopEngine::new( + db, + ConnectionManager::new(), + data.path().to_path_buf(), + EventEmitter::Noop, + ); + // Trigger: create the worktree (records the base), flip running. + let ctx = worktree::ensure_worktree(&conn, data.path(), issue.row.id) + .await + .unwrap(); + cas_issue_status(&conn, issue.row.id, IssueStatus::Pending, IssueStatus::Running) + .await + .unwrap(); + // One loop commit so the landing has content. + std::fs::write(ctx.worktree_path.join("feature.txt"), "work\n").unwrap(); + worktree::checkpoint(&ctx.worktree_path, "loop: feature") + .await + .unwrap() + .expect("committed"); + // Finalize produced the result artifact. + let result = artifact::create_artifact( + &conn, + space.id, + issue.row.id, + ArtifactKind::Result, + "result", + ArtifactStatus::Done, + ActorKind::Agent, + None, + ) + .await + .unwrap(); + // Integration verified: the merge gate (D6) requires a recorded + // `gate_decision(result, finalize) == Pass`, so a finalized issue at the + // merge gate must carry it. + crate::db::service::loop_service::gate_decision::record_decision( + &conn, + space.id, + issue.row.id, + result.id, + crate::loop_engine::gates::FINALIZE_GATE_STAGE, + result.attempt, + &[], + &[], + "{}", + crate::db::entities::loop_gate_decision::GateOutcome::Pass, + ) + .await + .unwrap(); + (engine, conn, repo, data, issue.row.id, ctx.worktree_folder_id) + } + + #[tokio::test] + async fn merge_issue_success_closes_issue_and_removes_worktree() { + let (engine, conn, repo, _data, issue_id, folder_id) = setup_repo().await; + let worktree_path = PathBuf::from( + folder_service::get_folder_by_id(&conn, folder_id) + .await + .unwrap() + .unwrap() + .path, + ); + + engine.merge_issue(issue_id).await.unwrap(); + + let issue = issue::get_issue(&conn, issue_id).await.unwrap().unwrap(); + assert_eq!(issue.status, IssueStatus::Done); + assert!(issue.ended_at.is_some()); + // Worktree folder soft-deleted + directory gone. + assert!(folder_service::get_folder_by_id(&conn, folder_id) + .await + .unwrap() + .is_none()); + assert!(!worktree_path.exists()); + // The loop work landed on the base branch. + assert!(repo.path().join("feature.txt").exists()); + } + + #[tokio::test] + async fn merge_issue_dirty_base_blocks_and_errors() { + let (engine, conn, repo, _data, issue_id, _folder_id) = setup_repo().await; + // Modify a TRACKED file in the base repo (untracked files no longer block). + std::fs::write(repo.path().join("README.md"), "locally modified\n").unwrap(); + + // The fault surfaces as an error — not a silent "Ok" success. + let err = engine.merge_issue(issue_id).await.unwrap_err(); + assert!(matches!(err, LoopError::MergeFailed(_))); + + // The issue is blocked + carries a durable card so the fault is visible + // (also covers the auto-merge path, which only logs the error). + let issue = issue::get_issue(&conn, issue_id).await.unwrap().unwrap(); + assert_eq!(issue.status, IssueStatus::Blocked); + let cards = inbox::list_inbox(&conn, issue.space_id, Some(InboxStatus::Pending)) + .await + .unwrap(); + assert!(cards.iter().any(|c| c.kind == InboxKind::Blocked + && c.subject_key == format!("merge_blocked:{issue_id}"))); + assert!(!repo.path().join("feature.txt").exists(), "nothing landed"); + } + + #[tokio::test] + async fn merge_issue_conflict_blocks_with_inbox_and_errors() { + let (engine, conn, repo, _data, issue_id, _folder_id) = setup_repo().await; + // Advance the base branch with a CONFLICTING change to feature.txt (the + // loop branch added feature.txt too), so integrating the base conflicts. + std::fs::write(repo.path().join("feature.txt"), "base conflicting\n").unwrap(); + git(repo.path(), &["add", "-A"]); + git(repo.path(), &["commit", "-q", "-m", "base feature"]); + + // The fault surfaces as an error (never a silent success)... + let err = engine.merge_issue(issue_id).await.unwrap_err(); + assert!(matches!(err, LoopError::MergeFailed(_))); + + // ...AND a branch/integration fault blocks the issue + files a card so it + // is visible (also covers the auto-merge path, which only logs the error). + let issue = issue::get_issue(&conn, issue_id).await.unwrap().unwrap(); + assert_eq!(issue.status, IssueStatus::Blocked); + let cards = inbox::list_inbox(&conn, issue.space_id, Some(InboxStatus::Pending)) + .await + .unwrap(); + assert!(cards.iter().any(|c| c.kind == InboxKind::Blocked + && c.subject_key == format!("merge_blocked:{issue_id}"))); + // The loop's work never landed: the base still holds its own version. + assert_eq!( + std::fs::read_to_string(repo.path().join("feature.txt")).unwrap(), + "base conflicting\n" + ); + } + + #[tokio::test] + async fn merge_issue_without_result_not_mergeable() { + let (engine, conn, _repo, _data, issue_id, _folder_id) = setup_repo().await; + // Drop the result artifact to simulate "finalize not done". + loop_artifact::Entity::delete_many() + .filter(loop_artifact::Column::IssueId.eq(issue_id)) + .filter(loop_artifact::Column::Kind.eq(ArtifactKind::Result)) + .exec(&conn) + .await + .unwrap(); + assert!(matches!( + engine.merge_issue(issue_id).await, + Err(LoopError::NotMergeable) + )); + } + + #[tokio::test] + async fn merge_issue_second_call_after_cleanup_is_idempotent_ok() { + let (engine, conn, _repo, _data, issue_id, folder_id) = setup_repo().await; + let worktree_path = PathBuf::from( + folder_service::get_folder_by_id(&conn, folder_id) + .await + .unwrap() + .unwrap() + .path, + ); + + engine.merge_issue(issue_id).await.unwrap(); + // First merge landed and tore the worktree down. + assert_eq!( + issue::get_issue(&conn, issue_id).await.unwrap().unwrap().status, + IssueStatus::Done + ); + assert!(!worktree_path.exists(), "first merge removed the worktree"); + assert!(folder_service::get_folder_by_id(&conn, folder_id) + .await + .unwrap() + .is_none()); + + // Second merge, with the worktree already removed, is a no-op SUCCESS — + // not LoopError::Conflict ("state changed concurrently; retry"). The + // idempotent branch returns at the post-lock `done` re-read before it ever + // touches the absent worktree. + engine.merge_issue(issue_id).await.unwrap(); + assert_eq!( + issue::get_issue(&conn, issue_id).await.unwrap().unwrap().status, + IssueStatus::Done + ); + } + + #[tokio::test] + async fn merge_issue_blocked_is_not_mergeable() { + let (engine, conn, _repo, _data, issue_id, _folder_id) = setup_repo().await; + cas_issue_status(&conn, issue_id, IssueStatus::Running, IssueStatus::Blocked) + .await + .unwrap(); + assert!(matches!( + engine.merge_issue(issue_id).await, + Err(LoopError::NotMergeable) + )); + } + + // ── Design approval gate ──────────────────────────────────────────────── + + /// Mint an `awaiting_approval` design + its inbox card on a running issue. + async fn seed_awaiting_design(conn: &sea_orm::DatabaseConnection, space_id: i32, issue_id: i32) -> i32 { + let d = artifact::create_artifact( + conn, + space_id, + issue_id, + ArtifactKind::Design, + "D1", + ArtifactStatus::AwaitingApproval, + ActorKind::Agent, + None, + ) + .await + .unwrap(); + artifact::add_revision(conn, d.id, "design body", ActorKind::Agent, None) + .await + .unwrap(); + inbox::upsert_inbox( + conn, + space_id, + issue_id, + None, + InboxKind::Approval, + &format!("design:{issue_id}"), + serde_json::json!({ "gate": "design" }), + ) + .await + .unwrap(); + d.id + } + + #[tokio::test] + async fn approve_design_marks_done_and_resolves_card() { + let (engine, conn, space_id, issue_id) = setup().await; + let design_id = seed_awaiting_design(&conn, space_id, issue_id).await; + + engine.approve_design(issue_id).await.unwrap(); + + let detail = artifact::get_artifact_detail(&conn, design_id) + .await + .unwrap() + .unwrap(); + assert_eq!(detail.row.status, ArtifactStatus::Done); + let pending = inbox::list_inbox(&conn, space_id, Some(InboxStatus::Pending)) + .await + .unwrap(); + assert!(!pending + .iter() + .any(|c| c.subject_key == format!("design:{issue_id}"))); + // Nothing awaiting now → a second approve conflicts. + assert!(matches!( + engine.approve_design(issue_id).await, + Err(LoopError::Conflict) + )); + } + + #[tokio::test] + async fn reject_design_supersedes_and_records_comment() { + let (engine, conn, space_id, issue_id) = setup().await; + let design_id = seed_awaiting_design(&conn, space_id, issue_id).await; + + engine + .reject_design(issue_id, Some("needs more detail".into())) + .await + .unwrap(); + + let detail = artifact::get_artifact_detail(&conn, design_id) + .await + .unwrap() + .unwrap(); + assert_eq!(detail.row.status, ArtifactStatus::Superseded); + assert!(detail.revisions.iter().any(|r| r.actor_kind == ActorKind::Human + && r.content.contains("needs more detail"))); + let pending = inbox::list_inbox(&conn, space_id, Some(InboxStatus::Pending)) + .await + .unwrap(); + assert!(!pending + .iter() + .any(|c| c.subject_key == format!("design:{issue_id}"))); + } + + // ---- reflect orchestration (P4.4) ---- + + /// Claim a reflect iteration and drive it to terminal `Failed` — a spent + /// attempt with no artifact (what the exhaustion counter sees). + async fn fail_reflect( + conn: &sea_orm::DatabaseConnection, + space_id: i32, + issue_id: i32, + token: &str, + ) { + let it = try_claim_iteration( + conn, + IterationClaim { + space_id, + issue_id, + stage: Stage::Reflect, + target_artifact_id: None, + slot_no: None, + capability_token: token.into(), + attempt: 0, + }, + ) + .await + .unwrap() + .unwrap(); + cas_iteration_status(conn, it.id, IterationStatus::Queued, IterationStatus::Running) + .await + .unwrap(); + cas_iteration_status(conn, it.id, IterationStatus::Running, IterationStatus::Failed) + .await + .unwrap(); + } + + async fn count_reflect_iters(conn: &sea_orm::DatabaseConnection, issue_id: i32) -> usize { + loop_iteration::Entity::find() + .filter(loop_iteration::Column::IssueId.eq(issue_id)) + .filter(loop_iteration::Column::Stage.eq(Stage::Reflect)) + .all(conn) + .await + .unwrap() + .len() + } + + #[tokio::test] + async fn reflect_dispatch_is_noop_when_reflection_artifact_exists() { + let (engine, conn, space_id, issue_id) = setup().await; + cas_issue_status(&conn, issue_id, IssueStatus::Running, IssueStatus::Done) + .await + .unwrap(); + // The durable anchor (D12): a reflection already exists for the issue. + artifact::create_artifact( + &conn, + space_id, + issue_id, + ArtifactKind::Reflection, + "Retro", + ArtifactStatus::Done, + ActorKind::Agent, + None, + ) + .await + .unwrap(); + let issue = issue::get_issue(&conn, issue_id).await.unwrap().unwrap(); + + engine.dispatch_reflect_best_effort(&issue).await; + + assert_eq!( + count_reflect_iters(&conn, issue_id).await, + 0, + "anchor present → no dispatch" + ); + } + + #[tokio::test] + async fn reflect_dispatch_files_card_at_max_attempts() { + let db = fresh_in_memory_db().await; + let conn = db.conn.clone(); + let folder_id = seed_folder(&db, "/tmp/loop-reflect-exhaust").await; + let space = space::create_space(&conn, "S", folder_id).await.unwrap(); + let cfg = IssueConfig { + max_attempts: 1, + ..IssueConfig::default() + }; + let issue = issue::create_issue(&conn, space.id, "I", "b", IssuePriority::Medium, Some(&cfg)) + .await + .unwrap(); + let issue_id = issue.row.id; + cas_issue_status(&conn, issue_id, IssueStatus::Pending, IssueStatus::Running) + .await + .unwrap(); + cas_issue_status(&conn, issue_id, IssueStatus::Running, IssueStatus::Done) + .await + .unwrap(); + // One terminal (failed) reflect attempt — at max_attempts (1), no artifact. + fail_reflect(&conn, space.id, issue_id, "reflect-fail-1").await; + let engine = LoopEngine::new( + db, + ConnectionManager::new(), + std::path::PathBuf::from("/tmp/loop-reflect-exhaust-data"), + EventEmitter::Noop, + ); + let model = issue::get_issue(&conn, issue_id).await.unwrap().unwrap(); + + engine.dispatch_reflect_best_effort(&model).await; + + // A ReflectionFailed card was filed; no new reflect iteration claimed. + let pending = inbox::list_inbox(&conn, space.id, Some(InboxStatus::Pending)) + .await + .unwrap(); + assert!(pending.iter().any(|c| c.kind == InboxKind::ReflectionFailed)); + assert_eq!( + count_reflect_iters(&conn, issue_id).await, + 1, + "exhausted → no further dispatch" + ); + } + + #[tokio::test] + async fn reflect_settle_is_done_safe_with_exhausted_budget() { + let db = fresh_in_memory_db().await; + let conn = db.conn.clone(); + let folder_id = seed_folder(&db, "/tmp/loop-reflect-budget").await; + let space = space::create_space(&conn, "S", folder_id).await.unwrap(); + let issue = issue::create_issue( + &conn, + space.id, + "I", + "b", + IssuePriority::Medium, + Some(&IssueConfig::default()), + ) + .await + .unwrap(); + let issue_id = issue.row.id; + cas_issue_status(&conn, issue_id, IssueStatus::Pending, IssueStatus::Running) + .await + .unwrap(); + cas_issue_status(&conn, issue_id, IssueStatus::Running, IssueStatus::Done) + .await + .unwrap(); + // An already-exceeded token budget on the Done issue. + loop_issue::Entity::update_many() + .col_expr(loop_issue::Column::TokenBudget, Expr::value(100i64)) + .col_expr(loop_issue::Column::TokenUsed, Expr::value(200i64)) + .filter(loop_issue::Column::Id.eq(issue_id)) + .exec(&conn) + .await + .unwrap(); + // A running reflect iteration (target = None). + let it = try_claim_iteration( + &conn, + IterationClaim { + space_id: space.id, + issue_id, + stage: Stage::Reflect, + target_artifact_id: None, + slot_no: None, + capability_token: "reflect-budget-tok".into(), + attempt: 0, + }, + ) + .await + .unwrap() + .unwrap(); + cas_iteration_status(&conn, it.id, IterationStatus::Queued, IterationStatus::Running) + .await + .unwrap(); + + // Settle: no-progress breaker skipped (target None); budget CAS + // Running→Paused misses on a Done issue — Ok, no status change. + crate::loop_engine::dispatch::settle_iteration(&db, &EventEmitter::Noop, it.id) + .await + .unwrap(); + + let after = issue::get_issue(&conn, issue_id).await.unwrap().unwrap(); + assert_eq!( + after.status, + IssueStatus::Done, + "reflect settle never disturbs a Done issue" + ); + } +} diff --git a/src-tauri/src/loop_engine/briefing.rs b/src-tauri/src/loop_engine/briefing.rs new file mode 100644 index 0000000000..0e7ede3f0e --- /dev/null +++ b/src-tauri/src/loop_engine/briefing.rs @@ -0,0 +1,1509 @@ +//! Briefing assembler — builds the deterministic prompt the engine hands a loop +//! iteration agent, plus an audit manifest of exactly what went into it. +//! +//! Fixed §4.8 ordering, every section optional-but-positioned: +//! ① space constitution (human-authored rules, always first) +//! ② stage memory matrix (the memory kinds relevant to this stage) +//! ③ issue full text (the human-written objective) +//! ④ lineage — the target node verbatim ("direct parent" of what the agent +//! produces) plus farther ancestors as title + first-paragraph summaries, +//! cycle-protected so a malformed DAG can't loop forever +//! ⑤ acceptance criteria — the target's and its parent's criteria, verbatim +//! ⑥ stage instruction — exhaustive over all seven stages +//! ⑦ tool contract — which `loop_submit_*` tool the stage calls +//! +//! Section ⑧ (implement: worktree path + validation commands; review: checkpoint +//! diff + validation output) is appended by the dispatcher when those stages run +//! (M2.2) — the read stages this milestone drives carry sections ①–⑦. +//! +//! The returned [`BriefingOutput::manifest`] mirrors the sections that were +//! actually emitted (`{ v, template, components }`) so a run is auditable: you +//! can see which context the agent was and wasn't given. + +use std::collections::{HashMap, HashSet}; + +use sea_orm::DatabaseConnection; +use serde_json::{json, Value}; + +use crate::db::entities::loop_artifact::{ArtifactKind, ArtifactStatus, ReviewVerdict}; +use crate::db::entities::loop_artifact_revision::ActorKind; +use crate::db::entities::loop_criterion::CriterionKind; +use crate::db::entities::loop_iteration::Stage; +use crate::db::entities::loop_link::LinkKind; +use crate::db::entities::loop_memory::{self, MemoryKind, TrustTier}; +use crate::db::entities::loop_issue; +use crate::db::service::loop_service; +use crate::loop_engine::LoopError; +use crate::models::loops::{LoopArtifactDetail, LoopArtifactRow, LoopDagView}; + +/// Assembled briefing text plus the manifest auditing which components it carried. +#[derive(Debug, Clone)] +pub struct BriefingOutput { + pub text: String, + pub manifest: Value, +} + +/// Stable lowercase token for a stage — used in the manifest template, section +/// headers, and memory-matrix labeling. +pub fn stage_label(stage: Stage) -> &'static str { + match stage { + Stage::Triage => "triage", + Stage::Refine => "refine", + Stage::Design => "design", + Stage::Plan => "plan", + Stage::Implement => "implement", + Stage::Review => "review", + Stage::Finalize => "finalize", + Stage::Reflect => "reflect", + } +} + +fn memory_kind_label(kind: MemoryKind) -> &'static str { + match kind { + MemoryKind::Constitution => "constitution", + MemoryKind::Constraint => "constraint", + MemoryKind::Decision => "decision", + MemoryKind::Preference => "preference", + MemoryKind::Pitfall => "pitfall", + MemoryKind::Episodic => "episodic", + MemoryKind::Procedural => "procedural", + } +} + +fn trust_tier_label(t: TrustTier) -> &'static str { + match t { + TrustTier::Human => "human", + TrustTier::Distilled => "distilled", + TrustTier::Proposed => "proposed", + } +} + +/// Lowercase label for an artifact kind — used in the reflect retrospective. +fn artifact_kind_label(kind: ArtifactKind) -> &'static str { + match kind { + ArtifactKind::Issue => "issue", + ArtifactKind::Requirement => "requirement", + ArtifactKind::Design => "design", + ArtifactKind::Task => "task", + ArtifactKind::Review => "review", + ArtifactKind::Result => "result", + ArtifactKind::Reflection => "reflection", + } +} + +fn review_verdict_label(v: ReviewVerdict) -> &'static str { + match v { + ReviewVerdict::Pass => "pass", + ReviewVerdict::Fail => "fail", + } +} + +/// The agent-facing working instruction for a stage. Exhaustive over all eight +/// stages so a newly added stage can never silently fall through to a generic +/// prompt. +fn stage_instruction(stage: Stage) -> &'static str { + match stage { + Stage::Triage => { + "Triage this issue. Decide how it should flow: `full` (requirements → \ + design → tasks) for non-trivial or design-bearing work, `skip_design` \ + (requirements → tasks) when scope is clear and no design is needed, or \ + `direct` (a single task) for a small, obvious change. Optionally adjust \ + the issue priority based on what you find." + } + Stage::Refine => { + "Turn this issue into a set of concrete, independently-verifiable \ + requirements. Each requirement is one capability or behavior the \ + solution must have. Attach acceptance criteria to each so later stages \ + can prove they are met." + } + Stage::Design => { + "Produce ONE design that satisfies ALL the requirements listed below. \ + Describe the approach, the components and their responsibilities, the \ + data flow, and the trade-offs you weighed. Stay within the issue's \ + scope; do not invent new requirements. Capture any cross-cutting \ + property the implementation must uphold — a constraint, an invariant, \ + or an obligation — as a typed design criterion so later stages gate \ + on it (these are NOT acceptance criteria; those live on requirements)." + } + Stage::Plan => { + "Break the work into a set of small, self-contained implementation \ + tasks. Each task must be doable and verifiable on its own and carry \ + enough detail (files, approach, acceptance criteria) for an implementer \ + with no other context to execute it. Tasks that touch disjoint files \ + can run in parallel, so prefer non-overlapping file domains; when two \ + tasks must be ordered (one builds on another's output, or they would \ + edit the same files), make the later one declare the earlier as its \ + dependency. A task may declare at most one predecessor." + } + Stage::Implement => { + "Implement the task in the provided worktree. Make the change, keep it \ + scoped to this task, and ensure the acceptance criteria are met. The \ + engine commits your work — you do not need to commit. If, after \ + inspecting the worktree, you find EVERY acceptance criterion is ALREADY \ + satisfied and no change is needed (for example a dependency task already \ + delivered it), do NOT end your turn with an empty result — call \ + `loop_task_complete` with a concrete reason so the engine sends the task \ + to review instead of treating the empty diff as a stuck failure." + } + Stage::Review => { + "Review the implementation against the acceptance-criteria checklist \ + below. Go through the handles ONE BY ONE: for EACH handle, decide \ + pass or fail and cite the specific evidence (the code, behavior, or \ + test that proves it) — a fail MUST name the concrete defect. The \ + design obligations are listed for context only; do not score them \ + here (the assembled result is gated on them at integration). Also \ + surface any defects, omissions, or regressions in your overall \ + findings. The engine derives the gate decision from your per-criterion \ + checks, so submit exactly one check per listed handle." + } + Stage::Finalize => { + "Summarize the completed work for this issue: what was built, how it \ + satisfies the requirements, and anything a reviewer or maintainer \ + should know before it merges." + } + Stage::Reflect => { + "This issue is complete and merged. Reflect on how it went and distill \ + durable lessons for this space's future work — review what was built, the \ + key decisions and trade-offs, what went smoothly, and what caused rework. \ + This is a READ-ONLY task: do NOT modify, create, or delete any files; your \ + only side effect is the one tool call below. Propose memories worth keeping: \ + episodic notes (what happened on this issue) and procedural notes (a reusable \ + recipe), plus any constraint / decision / preference / pitfall worth promoting. \ + Be selective — record only what will genuinely help future issues. If a new \ + memory makes an existing one obsolete, supersede it by its [M{n}] handle. \ + Recording nothing is a valid outcome." + } + } +} + +/// The submission contract for a stage — which MCP tool the agent must call and +/// what it produces. Exhaustive over all eight stages. +fn tool_contract(stage: Stage) -> &'static str { + match stage { + Stage::Triage => { + "Call `loop_submit_route` exactly once with your chosen route \ + (full / skip_design / direct)." + } + Stage::Refine => { + "Call `loop_submit_artifacts` exactly once with the full set of \ + requirements (the kind is inferred as `requirement`). Put acceptance \ + criteria in each artifact's `criteria`." + } + Stage::Design => { + "Call `loop_submit_artifacts` exactly once with your single design (the \ + kind is inferred as `design`). A design carries NO acceptance criteria; \ + put any cross-cutting properties in `criteria` as typed objects \ + `{\"text\": \"...\", \"kind\": \"constraint\"|\"invariant\"|\"obligation\"}`." + } + Stage::Plan => { + "Call `loop_submit_artifacts` exactly once with the task breakdown (the \ + kind is inferred as `task`). List tasks in dependency order and put \ + each task's own acceptance criteria in its `criteria`. EVERY task MUST \ + include a `covers` array naming the requirement acceptance ordinals it \ + delivers (from the Requirements / Coverage contract sections), e.g. \ + `\"covers\": [\"R1.AC1\", \"R2.AC1\"]`. Across all tasks, every \ + acceptance ordinal listed in the Coverage contract MUST be covered by \ + at least one task — a submission that leaves any uncovered is REJECTED \ + and you must resubmit the complete task list. To make a task depend on \ + an earlier one, set its `depends_on` to a one-element array holding \ + that earlier task's 0-based index in this same submission (e.g. a task \ + waiting on the first → `\"depends_on\": [0]`). A reference may only \ + point to an earlier task, and a task may declare at most one." + } + Stage::Implement => { + "Do not call a submit tool — the engine detects and commits your \ + worktree changes. If the task is already fully satisfied and needs no \ + change, call `loop_task_complete` with a `reason` (do not just end \ + empty). If you are blocked, call `loop_report_blocked`." + } + Stage::Review => { + "Call `loop_submit_review` exactly once. Put one entry in `checks` for \ + EACH acceptance-criterion handle in the checklist above: \ + `{\"criterion\": \"R1.AC1\", \"verdict\": \"pass\"|\"fail\", \ + \"evidence\": \"...\"}`. Submit exactly one check per listed handle — \ + no more, no fewer — and a `fail` check MUST cite specific evidence. Do \ + NOT submit checks for the design obligations (they are context only). \ + Add overall `findings` so a failed criterion guides the next attempt." + } + Stage::Finalize => { + "Call `loop_submit_artifacts` exactly once with the result summary." + } + Stage::Reflect => { + "Call `loop_submit_reflection` exactly once (and call no other write tool). \ + Pass a `reflection` object `{\"title\": \"...\", \"content\": \"...\"}` (your \ + retrospective) and a `memories` array — each entry \ + `{\"kind\": \"episodic\"|\"procedural\"|\"constraint\"|\"decision\"|\ + \"preference\"|\"pitfall\", \"title\": \"...\", \"summary\": \"one line\", \ + \"content\": \"...\", \"supersedes\": [\"M2\"]}`. `summary` is the one line \ + shown in future briefings' Memory index; `supersedes` is optional and names \ + existing memories by their [M{n}] handle from the Memory index above (each \ + handle may be superseded by at most one memory). You may NOT record or \ + supersede the space constitution. The `memories` array may be empty." + } + } +} + +/// Parallel-mode finalize is NOT a result submission — the engine integrates the +/// per-task branches and synthesizes the result itself. A finalize agent is only +/// dispatched to resolve a fan-in MERGE CONFLICT in the integrate worktree. +const PARALLEL_FINALIZE_INSTRUCTION: &str = + "The engine is integrating this issue's parallel task branches and hit a merge \ + conflict in THIS worktree. Resolve every conflict so the combined work is \ + correct and consistent, preserving each task's intent. Do not start new \ + feature work — only finish the in-progress merge."; + +const PARALLEL_FINALIZE_TOOL_CONTRACT: &str = + "Do NOT call any submit tool. Resolve the conflicted files in the worktree, \ + stage them (`git add`), and COMPLETE the in-progress merge with a plain \ + `git commit` (keep the default merge message — preserve both parents). The \ + engine detects the completed merge and continues the fan-in. If you cannot \ + resolve it, call `loop_report_blocked`."; + +/// First non-empty paragraph (up to the first blank line) of `s`, trimmed. Used +/// to summarize farther ancestors without dumping their entire body. +fn first_paragraph(s: &str) -> String { + let mut out = String::new(); + for line in s.lines() { + if line.trim().is_empty() { + if !out.is_empty() { + break; + } + continue; + } + if !out.is_empty() { + out.push('\n'); + } + out.push_str(line.trim_end()); + } + out.trim().to_string() +} + +/// Keep the last `max_chars` characters of `s` (failures surface at the end of a +/// transcript), prefixing an ellipsis when truncated. Char-boundary safe. +fn tail(s: &str, max_chars: usize) -> String { + let chars: Vec = s.chars().collect(); + if chars.len() <= max_chars { + return s.to_string(); + } + let kept: String = chars[chars.len() - max_chars..].iter().collect(); + format!("…{kept}") +} + +fn render_memories(mems: &[loop_memory::Model]) -> String { + let mut s = String::new(); + for m in mems { + s.push_str(&format!( + "- ({}) {}: {}\n", + memory_kind_label(m.kind), + m.title, + m.content + )); + } + s.trim_end().to_string() +} + +/// Walk the lineage of `target` toward the issue root by following the inbound +/// edge direction (`from` = derived, `to` = source) for `derives_from` / +/// `skips_to` links. Returns the chain starting AT `target`, parent next, and so +/// on. Cycle-protected: a node already seen ends the walk, so a malformed DAG +/// can't loop forever. +fn build_lineage(links: &[crate::models::loops::LoopLinkRow], target: i32) -> Vec { + let mut parent: HashMap = HashMap::new(); + for link in links { + if matches!(link.kind, LinkKind::DerivesFrom | LinkKind::SkipsTo) { + // First parent wins (a well-formed DAG has at most one source edge + // per derived node); ignore extras defensively. + parent.entry(link.from_artifact_id).or_insert(link.to_artifact_id); + } + } + let mut chain = Vec::new(); + let mut seen = HashSet::new(); + let mut cur = Some(target); + while let Some(node) = cur { + if !seen.insert(node) { + break; + } + chain.push(node); + cur = parent.get(&node).copied(); + } + chain +} + +/// Lowercase token for a criterion kind (obligation rendering + manifest). +fn criterion_kind_label(kind: CriterionKind) -> &'static str { + match kind { + CriterionKind::Acceptance => "acceptance", + CriterionKind::Constraint => "constraint", + CriterionKind::Invariant => "invariant", + CriterionKind::Obligation => "obligation", + } +} + +/// The issue's done requirements with full details (criteria), ordered by +/// `(sort, id)` — the same order ingest and the driver use for the `R{i}.AC{j}` +/// coverage ordinals, so an ordinal printed in a briefing matches what was stored +/// and gated on. +async fn done_requirement_details( + conn: &DatabaseConnection, + dag: &LoopDagView, +) -> Result, LoopError> { + let mut reqs: Vec<&LoopArtifactRow> = dag + .artifacts + .iter() + .filter(|a| a.kind == ArtifactKind::Requirement && a.status == ArtifactStatus::Done) + .collect(); + reqs.sort_by_key(|a| (a.sort, a.id)); + let mut out = Vec::with_capacity(reqs.len()); + for r in reqs { + if let Some(d) = loop_service::artifact::get_artifact_detail(conn, r.id).await? { + out.push(d); + } + } + Ok(out) +} + +/// `criterion_id → "R{i}.AC{j}"` for every acceptance criterion of the issue, +/// built from the SINGLE shared ordinal source so the ordinals shown in a +/// briefing are byte-identical to what ingest stored on `covers` and what the +/// driver's coverage gate reasons about (spec invariant). Requirements ordered +/// by (sort,id) here match `done_requirement_details`, so the `## R{i}` headers +/// stay aligned with these criterion ordinals. +async fn ordinal_map( + conn: &DatabaseConnection, + issue_id: i32, +) -> Result, LoopError> { + let ordered = loop_service::coverage::acceptance_ordinals_for_issue(conn, issue_id).await?; + let mut map = HashMap::new(); + for (ri, (_req, crits)) in ordered.iter().enumerate() { + for (ci, cid) in crits.iter().enumerate() { + map.insert(*cid, format!("R{}.AC{}", ri + 1, ci + 1)); + } + } + Ok(map) +} + +/// Render all requirements + their acceptance criteria for the design/plan +/// briefing. Plan annotates each criterion with its `R{i}.AC{j}` ordinal (from +/// the shared `ordinals` map, so the planner's `covers` matches); design omits +/// ordinals (it satisfies them all). +fn render_requirements( + reqs: &[LoopArtifactDetail], + ordinals: &HashMap, + with_ordinals: bool, +) -> String { + let mut body = String::new(); + for (ri, r) in reqs.iter().enumerate() { + let rbody = r.revisions.last().map(|x| x.content.trim()).unwrap_or(""); + body.push_str(&format!("## R{}: {}\n{}\n", ri + 1, r.row.title, rbody)); + for c in &r.criteria { + if c.kind == CriterionKind::Acceptance { + if with_ordinals { + let ord = ordinals.get(&c.id).cloned().unwrap_or_default(); + body.push_str(&format!("- [{}] {}\n", ord, c.text)); + } else { + body.push_str(&format!("- {}\n", c.text)); + } + } + } + body.push('\n'); + } + body.trim_end().to_string() +} + +/// The acceptance closure for a task (implement/review): the acceptance criteria +/// the task covers (by ordinal), plus the design's cross-cutting obligations +/// (constraint/invariant/obligation), plus — when the task declared no coverage — +/// a fallback to every requirement acceptance criterion, so the agent is never +/// blind to what its work must satisfy. Returns `None` only when the issue has no +/// criteria of any kind. +async fn acceptance_closure( + conn: &DatabaseConnection, + issue_id: i32, + task_id: i32, + dag: &LoopDagView, +) -> Result, LoopError> { + let reqs = done_requirement_details(conn, dag).await?; + let ordinals = ordinal_map(conn, issue_id).await?; + // criterion id -> (ordinal, text) over acceptance criteria, ordinal from the + // shared source. + let mut by_id: HashMap = HashMap::new(); + for r in &reqs { + for c in &r.criteria { + if c.kind == CriterionKind::Acceptance { + if let Some(ord) = ordinals.get(&c.id) { + by_id.insert(c.id, (ord.clone(), c.text.clone())); + } + } + } + } + let covered: Vec = dag + .coverage + .iter() + .filter(|cv| cv.task_artifact_id == task_id) + .map(|cv| cv.criterion_id) + .collect(); + + let mut body = String::new(); + if !covered.is_empty() { + body.push_str("This task is responsible for these acceptance criteria:\n"); + for cid in &covered { + if let Some((ord, text)) = by_id.get(cid) { + body.push_str(&format!("- [{ord}] {text}\n")); + } + } + } else if !by_id.is_empty() { + body.push_str( + "This task declared no specific coverage, so it must respect ALL of the \ + issue's acceptance criteria:\n", + ); + body.push_str(&render_requirements(&reqs, &ordinals, true)); + body.push('\n'); + } + + // Design obligations apply to the whole solution, on every task. + let mut obligations = String::new(); + for a in dag + .artifacts + .iter() + .filter(|a| a.kind == ArtifactKind::Design && a.status == ArtifactStatus::Done) + { + if let Some(d) = loop_service::artifact::get_artifact_detail(conn, a.id).await? { + for c in &d.criteria { + obligations.push_str(&format!("- ({}) {}\n", criterion_kind_label(c.kind), c.text)); + } + } + } + if !obligations.is_empty() { + body.push_str("\nDesign obligations (must hold across the whole solution):\n"); + body.push_str(&obligations); + } + + let body = body.trim_end().to_string(); + Ok(if body.is_empty() { None } else { Some(body) }) +} + +/// The per-criterion review checklist for a TASK review (§3.4, D9). Returns the +/// "# Acceptance criteria" section body (the exact handles the reviewer must +/// submit one check each for, plus design obligations shown as awareness-only +/// context) AND the `{ handle: criterion_id }` manifest the gate resolves +/// submitted checks against. The two are built from the SAME ordinal source, so +/// what the reviewer is shown is exactly what ingest accepts. +async fn review_checklist_section( + conn: &DatabaseConnection, + issue_id: i32, + task_id: i32, +) -> Result<(Option, Value), LoopError> { + let entries = + loop_service::criterion_ordinals::task_review_ordinals(conn, issue_id, task_id).await?; + let obligations = + loop_service::criterion_ordinals::obligation_ordinals(conn, issue_id).await?; + + let mut manifest = serde_json::Map::new(); + for e in &entries { + manifest.insert(e.handle.clone(), json!(e.criterion_id)); + } + + if entries.is_empty() { + // No task-verifiable criteria (a degenerate direct task). Emit no + // checklist; the empty manifest tells the gate there is nothing to check. + return Ok((None, Value::Object(manifest))); + } + + let mut body = String::from( + "Submit one check per handle below (use the EXACT handle in brackets), each \ + with a pass/fail and concrete evidence:\n", + ); + for e in &entries { + body.push_str(&format!("- [{}] {}\n", e.handle, e.text)); + } + if !obligations.is_empty() { + body.push_str( + "\nDesign obligations — context only (the assembled result is gated on these at \ + integration, NOT in this task review; do not submit checks for them):\n", + ); + for o in &obligations { + body.push_str(&format!("- ({}) {}\n", criterion_kind_label(o.kind), o.text)); + } + } + Ok(( + Some(format!("# Acceptance criteria\n{}", body.trim_end())), + Value::Object(manifest), + )) +} + +/// The per-criterion checklist for an INTEGRATION review (target = the assembled +/// `result`, §3.6, D9): the whole-issue closure — every requirement acceptance +/// (`R{i}.AC{j}`) plus every design obligation (`D{k}`), or the tasks' own +/// acceptance on the `direct` route. ALL of these are checks here (unlike a task +/// review, where obligations are awareness-only). Returns the section body and the +/// `{ handle: criterion_id }` manifest the gate resolves against. +async fn integration_checklist_section( + conn: &DatabaseConnection, + issue_id: i32, +) -> Result<(Option, Value), LoopError> { + let entries = + loop_service::criterion_ordinals::integration_ordinals(conn, issue_id).await?; + let mut manifest = serde_json::Map::new(); + for e in &entries { + manifest.insert(e.handle.clone(), json!(e.criterion_id)); + } + if entries.is_empty() { + return Ok((None, Value::Object(manifest))); + } + let mut body = String::from( + "Verify the ASSEMBLED RESULT (the full combined change) against the whole-issue \ + closure. Submit one check per handle below (use the EXACT handle in brackets), each \ + with a pass/fail and concrete evidence drawn from the integrated result — a `fail` \ + names the specific gap or cross-task conflict:\n", + ); + for e in &entries { + body.push_str(&format!("- [{}] {}\n", e.handle, e.text)); + } + Ok(( + Some(format!("# Integration criteria\n{}", body.trim_end())), + Value::Object(manifest), + )) +} + +/// Assemble the briefing for one iteration. `issue` is the loaded issue row; +/// `target_artifact_id` is the node the iteration derives its output from (the +/// issue root for triage/refine, a requirement for design, etc.) — `None` only +/// for a target-less stage. +pub async fn assemble_briefing( + conn: &DatabaseConnection, + issue: &loop_issue::Model, + stage: Stage, + target_artifact_id: Option, +) -> Result { + let mut sections: Vec = Vec::new(); + let mut components: Vec = Vec::new(); + // For a review iteration: the `{ handle: criterion_id }` map the gate resolves + // submitted checks against, persisted into the iteration's `context_manifest` + // at dispatch (D10). `None` for non-review stages. + let mut criteria_manifest: Option = None; + // The `{ "M{n}": memory_id }` map for the full memory index injected below — + // stashed into the iteration's `context_manifest` so `loop_read_memory` + // resolves read handles against exactly what this briefing showed. + let mut memory_index_manifest: Option = None; + + // ① Space constitution — human-authored, always first. + let constitution = loop_service::memory::list_constitution(conn, issue.space_id).await?; + if !constitution.is_empty() { + sections.push(format!( + "# Space constitution\n{}", + render_memories(&constitution) + )); + components.push(json!({ "section": "constitution", "count": constitution.len() })); + } + + // ② Memory index — EVERY active memory (except the constitution), one line + // each with a stable [M{n}] handle. No ranking/scoring/filter (§4.2): the + // agent reads what it judges relevant via loop_read_memory. The { "M{n}": id } + // map is stashed in the manifest so ingest resolves read handles against + // exactly what was shown here. + let index = loop_service::memory::build_index(conn, issue.space_id).await?; + if !index.is_empty() { + let mut body = String::new(); + let mut map = serde_json::Map::new(); + for (i, m) in index.iter().enumerate() { + let handle = format!("M{}", i + 1); + let mut tail = String::new(); + // One compact line per memory: collapse any whitespace/newlines in the + // summary so a multiline summary can't break the index layout. + if let Some(s) = m.summary.as_deref().map(str::trim).filter(|s| !s.is_empty()) { + tail.push_str(" — "); + tail.push_str(&s.split_whitespace().collect::>().join(" ")); + } + // Provenance shown for judgment (§4.5) — present-only, never used to + // rank/filter/score. (source_artifact_id is set by reflect in P4.) + if let Some(id) = m.source_issue_id { + tail.push_str(&format!(" · issue {id}")); + } + if let Some(id) = m.source_artifact_id { + tail.push_str(&format!(" · artifact {id}")); + } + if let Some(id) = m.produced_by_iteration_id { + tail.push_str(&format!(" · iter {id}")); + } + body.push_str(&format!( + "- [{}] ({} · {}) {}{}\n", + handle, + memory_kind_label(m.kind), + trust_tier_label(m.trust_tier), + m.title, + tail, + )); + map.insert(handle, json!(m.id)); + } + sections.push(format!( + "# Memory index\nRead any of these in full with `loop_read_memory` — pass as many \ + of the bracketed [M{{n}}] handles as you want in ONE call (batch read; you decide \ + how many):\n{}", + body.trim_end() + )); + components.push(json!({ "section": "memory_index", "count": index.len() })); + memory_index_manifest = Some(Value::Object(map)); + } + + // ③ Issue full text — the human-written objective. + sections.push(format!( + "# Issue #{}: {}\n\n{}", + issue.seq_no, + issue.title, + issue.description.trim() + )); + components.push(json!({ "section": "issue", "issue_seq": issue.seq_no })); + + // ④/⑤ Stage-shaped requirement context: + // • design & plan see ALL requirements (design satisfies them all; plan + // declares `covers` against their ordinals, and on a replan sees the gap); + // • implement & review get their task's acceptance closure (covered criteria + // + design obligations + fallback); + // • other staged targets get single-target lineage + its criteria verbatim. + if matches!(stage, Stage::Design | Stage::Plan) { + let dag = loop_service::artifact::list_dag(conn, issue.id).await?; + let reqs = done_requirement_details(conn, &dag).await?; + let ordinals = ordinal_map(conn, issue.id).await?; + if !reqs.is_empty() { + sections.push(format!( + "# Requirements\n{}", + render_requirements(&reqs, &ordinals, stage == Stage::Plan) + )); + components.push(json!({ "section": "requirements", "count": reqs.len() })); + } + // Plan only: an explicit flat checklist of EVERY acceptance ordinal the + // plan must cover. The per-requirement ordinals above are easy to miss when + // scattered; restating the full target set as one list makes the planner's + // `covers` complete on the first submission (an incomplete plan is rejected + // at submit time and must be resubmitted — see the tool contract). + if stage == Stage::Plan && !reqs.is_empty() { + let all_ords: Vec = reqs + .iter() + .flat_map(|r| { + r.criteria + .iter() + .filter(|c| c.kind == CriterionKind::Acceptance) + .filter_map(|c| ordinals.get(&c.id).cloned()) + }) + .collect(); + if !all_ords.is_empty() { + let list = all_ords + .iter() + .map(|o| format!("- {o}")) + .collect::>() + .join("\n"); + sections.push(format!( + "# Coverage contract\nEvery task MUST declare a `covers` array. Together your \ + tasks MUST cover ALL {} acceptance ordinals below — a submission that leaves \ + any uncovered is rejected and you will be asked to resubmit:\n{list}", + all_ords.len() + )); + components + .push(json!({ "section": "coverage_contract", "count": all_ords.len() })); + } + } + // On a plan replan (a prior plan's tasks were superseded by the coverage + // gate), call out the criteria still uncovered by ANY task so the new plan + // closes them. + if stage == Stage::Plan + && dag + .artifacts + .iter() + .any(|a| a.kind == ArtifactKind::Task && a.status == ArtifactStatus::Superseded) + { + let ord_pairs = + loop_service::coverage::acceptance_ordinals_for_issue(conn, issue.id).await?; + let all_tasks: HashSet = dag + .artifacts + .iter() + .filter(|a| a.kind == ArtifactKind::Task) + .map(|a| a.id) + .collect(); + let gap = + loop_service::coverage::uncovered_ordinals(&ord_pairs, &dag.coverage, &all_tasks); + if !gap.is_empty() { + let list = gap + .iter() + .map(|o| format!("- {o}")) + .collect::>() + .join("\n"); + sections.push(format!( + "# Coverage gap\nA previous plan left these acceptance criteria uncovered by \ + any task. The new plan MUST cover them:\n{list}" + )); + components.push(json!({ "section": "coverage_gap", "count": gap.len() })); + } + } + } else if let Some(target) = target_artifact_id { + let dag = loop_service::artifact::list_dag(conn, issue.id).await?; + let chain = build_lineage(&dag.links, target); + + let mut details = Vec::new(); + for id in &chain { + if let Some(d) = loop_service::artifact::get_artifact_detail(conn, *id).await? { + details.push(d); + } + } + + if let Some((head, ancestors)) = details.split_first() { + // ④ Lineage: target verbatim, ancestors as title + first-paragraph. + let head_body = head + .revisions + .last() + .map(|r| r.content.trim()) + .unwrap_or(""); + let mut lineage = format!("## {} (direct parent)\n{}\n", head.row.title, head_body); + for d in ancestors { + let summary = + first_paragraph(d.revisions.last().map(|r| r.content.as_str()).unwrap_or("")); + lineage.push_str(&format!("\n## {} (ancestor)\n{}\n", d.row.title, summary)); + } + sections.push(format!("# Lineage\n{}", lineage.trim_end())); + components.push(json!({ "section": "lineage", "depth": details.len() })); + + // ⑤ Criteria. + // • implement gets the AC closure (coverage + design obligations + + // fallback) as guidance; + // • review gets the per-criterion CHECKLIST (the exact handles it + // must submit one check each for) + the manifest the gate resolves + // against — design obligations shown as awareness-only; + // • other staged targets get the target's + parent's criteria verbatim. + if stage == Stage::Implement { + if let Some(closure) = acceptance_closure(conn, issue.id, target, &dag).await? { + sections.push(format!("# Acceptance criteria\n{closure}")); + components.push(json!({ "section": "acceptance_criteria", "closure": true })); + } + } else if stage == Stage::Review { + // A review targeting the `result` is the INTEGRATION gate (whole-issue + // closure); a review targeting a task is the task gate (covered ACs + + // the task's own acceptance, obligations awareness-only). + let is_integration = dag + .artifacts + .iter() + .find(|a| a.id == target) + .map(|a| a.kind == ArtifactKind::Result) + .unwrap_or(false); + let (label, (section, manifest)) = if is_integration { + ("integration_checklist", integration_checklist_section(conn, issue.id).await?) + } else { + ("review_checklist", review_checklist_section(conn, issue.id, target).await?) + }; + if let Some(s) = section { + sections.push(s); + let count = manifest.as_object().map(|m| m.len()).unwrap_or(0); + components.push(json!({ "section": label, "count": count })); + } + criteria_manifest = Some(manifest); + } else { + let mut crit = String::new(); + if !head.criteria.is_empty() { + crit.push_str(&format!("From {}:\n", head.row.title)); + for c in &head.criteria { + crit.push_str(&format!("- [{}] {}\n", c.label, c.text)); + } + } + if let Some(parent) = ancestors.first() { + if !parent.criteria.is_empty() { + crit.push_str(&format!("\nFrom {} (parent):\n", parent.row.title)); + for c in &parent.criteria { + crit.push_str(&format!("- [{}] {}\n", c.label, c.text)); + } + } + } + if !crit.is_empty() { + sections.push(format!("# Acceptance criteria\n{}", crit.trim_end())); + components.push(json!({ "section": "acceptance_criteria" })); + } + } + } + } else if stage == Stage::Reflect { + // Reflect runs on a completed, merged issue (target = None). Give the agent + // a retrospective: each live artifact (requirement/design/task/result) as + // title + first paragraph + its criteria + its review verdict, plus a + // one-line outcome — enough to distill episodic/procedural memories. (The + // constitution §① and full memory index §② — which supersedes handles + // resolve against — already inject above for every stage.) + let dag = loop_service::artifact::list_dag(conn, issue.id).await?; + let mut retro = String::new(); + let mut task_count = 0usize; + for a in dag.artifacts.iter().filter(|a| { + matches!( + a.kind, + ArtifactKind::Requirement + | ArtifactKind::Design + | ArtifactKind::Task + | ArtifactKind::Result + ) && !matches!(a.status, ArtifactStatus::Superseded | ArtifactStatus::Cancelled) + }) { + if a.kind == ArtifactKind::Task { + task_count += 1; + } + if let Some(d) = loop_service::artifact::get_artifact_detail(conn, a.id).await? { + let body = + first_paragraph(d.revisions.last().map(|r| r.content.as_str()).unwrap_or("")); + retro.push_str(&format!( + "\n## {} ({})\n{}\n", + d.row.title, + artifact_kind_label(a.kind), + body + )); + for c in &d.criteria { + retro.push_str(&format!("- [{}] {}\n", c.label, c.text)); + } + if let Some(v) = a.verdict { + retro.push_str(&format!("- verdict: {}\n", review_verdict_label(v))); + } + } + } + if !retro.is_empty() { + sections.push(format!( + "# What was built\nIssue #{} merged ({} task{}).\n{}", + issue.seq_no, + task_count, + if task_count == 1 { "" } else { "s" }, + retro.trim() + )); + components.push(json!({ "section": "reflect_context", "tasks": task_count })); + } + } + + // ⑤a Rework feedback — on an implement retry, surface why the last attempt + // was rejected (validation failure and/or reviewer findings) so the agent + // fixes forward instead of repeating it. + if stage == Stage::Implement { + if let Some(target) = target_artifact_id { + if let Some(run) = loop_service::validation::latest_for_task(conn, target).await? { + if !run.passed { + sections.push(format!( + "# Previous validation failure\nYour last attempt did not pass the \ + deterministic validation commands. Fix the problems below, then make \ + the change again.\n\n```\n{}\n```", + tail(run.output.trim(), 4000) + )); + components.push(json!({ "section": "validation_feedback", "run_id": run.id })); + } + } + let findings = loop_service::artifact::latest_failed_review_findings(conn, target).await?; + if !findings.is_empty() { + let mut body = String::from( + "# Previous review findings\nReviewers rejected your last attempt. Address \ + every point below, then make the change again.", + ); + for (i, f) in findings.iter().enumerate() { + body.push_str(&format!("\n\n## Reviewer {}\n{}", i + 1, tail(f, 2000))); + } + sections.push(body); + components.push(json!({ "section": "review_feedback", "count": findings.len() })); + } + } + } + + // ⑤b Review context — point reviewers at the committed work to inspect and at + // the validation result, so they review against the real changes. + if stage == Stage::Review { + if let Some(base) = issue.base_commit.as_deref() { + sections.push(format!( + "# What to review\nThe implementation is committed on this branch. Inspect the \ + changes since base commit `{base}` (e.g. `git diff {base}..HEAD`) and read the \ + affected files in the worktree." + )); + components.push(json!({ "section": "review_context", "base": base })); + } + // D12: if the implementer declared this task already satisfied (no change + // needed), tell the reviewer there is no fresh checkpoint commit to inspect + // — verify the acceptance criteria against the integrated HEAD instead. + if let Some(target) = target_artifact_id { + if let Some(reason) = + loop_service::iteration::latest_declared_completion_reason(conn, issue.id, target) + .await? + { + sections.push(format!( + "# Implementer declared this task already complete\nThe implementer made NO \ + change, declaring the task already satisfied: \"{}\". There is no new \ + checkpoint commit to inspect — verify the acceptance criteria hold against \ + the current worktree HEAD (its dependencies' integrated state).", + reason.trim() + )); + components.push(json!({ "section": "declared_complete" })); + } + } + if let Some(target) = target_artifact_id { + if let Some(run) = loop_service::validation::latest_for_task(conn, target).await? { + sections.push(format!( + "# Validation result\nDeterministic validation {}.\n\n```\n{}\n```", + if run.passed { "passed" } else { "did not pass" }, + tail(run.output.trim(), 2000) + )); + components.push(json!({ "section": "validation_result", "passed": run.passed })); + } + } + } + + // ⑤c Design rework feedback — on a design re-dispatched after a human + // rejection, surface the prior proposal and the reviewer's comment so the new + // design addresses it rather than repeating it. + if stage == Stage::Design { + let dag = loop_service::artifact::list_dag(conn, issue.id).await?; + let mut rejected = String::new(); + for a in dag.artifacts.iter().filter(|a| { + a.kind == ArtifactKind::Design && a.status == ArtifactStatus::Superseded + }) { + if let Some(d) = loop_service::artifact::get_artifact_detail(conn, a.id).await? { + let body = d + .revisions + .iter() + .rev() + .find(|r| r.actor_kind == ActorKind::Agent) + .map(|r| r.content.trim()) + .unwrap_or(""); + let note = d + .revisions + .iter() + .rev() + .find(|r| r.actor_kind == ActorKind::Human) + .map(|r| r.content.trim()) + .unwrap_or(""); + rejected.push_str(&format!( + "\n\n## {} (rejected)\n{}", + d.row.title, + first_paragraph(body) + )); + if !note.is_empty() { + rejected.push_str(&format!("\n\nReviewer feedback: {note}")); + } + } + } + if !rejected.is_empty() { + sections.push(format!( + "# Previously rejected design\nA prior design was rejected. Address the \ + feedback below and propose a revised design.{rejected}" + )); + components.push(json!({ "section": "design_rework_feedback" })); + } + } + + // ⑥ Stage instruction — what to do this turn. A parallel-mode finalize is a + // fan-in conflict resolution (the engine synthesizes the result), so it gets + // the conflict-resolution briefing instead of the serial result-submit one. + let parallel_finalize = + stage == Stage::Finalize && issue.execution_mode.as_deref() == Some("parallel"); + sections.push(format!( + "# Your task\n{}", + if parallel_finalize { + PARALLEL_FINALIZE_INSTRUCTION + } else { + stage_instruction(stage) + } + )); + components.push(json!({ "section": "stage_instruction", "stage": stage_label(stage) })); + + // ⑦ Tool contract — how to submit. + sections.push(format!( + "# How to submit\n{}", + if parallel_finalize { + PARALLEL_FINALIZE_TOOL_CONTRACT + } else { + tool_contract(stage) + } + )); + components.push(json!({ "section": "tool_contract", "stage": stage_label(stage) })); + + let mut manifest = json!({ + "v": 1, + "template": format!("{}@v1", stage_label(stage)), + "components": components, + }); + // A review iteration carries its injected criterion manifest (D10) — the + // single source ingest resolves submitted check handles against. + if let Some(criteria) = criteria_manifest { + manifest["criteria"] = criteria; + } + // The full memory index handles (when any active memory exists) — what + // loop_read_memory resolves submitted [M{n}] handles against. + if let Some(mem) = memory_index_manifest { + manifest["memory_index"] = mem; + } + + Ok(BriefingOutput { + text: sections.join("\n\n"), + manifest, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::entities::loop_artifact::{ArtifactKind, ArtifactStatus}; + use crate::db::entities::loop_artifact_revision::ActorKind; + use crate::db::entities::loop_criterion::CriterionKind; + use crate::db::entities::loop_issue::IssuePriority; + use crate::db::entities::loop_iteration; + use crate::db::test_helpers::{fresh_in_memory_db, seed_folder}; + use crate::loop_engine::transitions::{try_claim_iteration, IterationClaim}; + use crate::models::loops::IssueConfig; + use sea_orm::sea_query::Expr; + use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; + + /// Seed space + issue (auto-creates the kind=issue root artifact). Returns + /// `(db, space_id, issue_model, root_artifact_id)`. + async fn seed() -> (crate::db::AppDatabase, i32, loop_issue::Model, i32) { + let db = fresh_in_memory_db().await; + let folder_id = seed_folder(&db, "/repo").await; + let space = loop_service::space::create_space(&db.conn, "S", folder_id) + .await + .unwrap(); + let issue = loop_service::issue::create_issue( + &db.conn, + space.id, + "Add login", + "Users must be able to authenticate.", + IssuePriority::Medium, + Some(&IssueConfig::default()), + ) + .await + .unwrap(); + let model = loop_service::issue::get_issue(&db.conn, issue.row.id) + .await + .unwrap() + .unwrap(); + let dag = loop_service::artifact::list_dag(&db.conn, issue.row.id) + .await + .unwrap(); + let root = dag + .artifacts + .iter() + .find(|a| matches!(a.kind, ArtifactKind::Issue)) + .expect("root issue artifact") + .id; + (db, space.id, model, root) + } + + /// Create an artifact + one revision + optional criteria + a DerivesFrom edge + /// to `source`. Returns the new artifact id. + #[allow(clippy::too_many_arguments)] + async fn add_node( + db: &crate::db::AppDatabase, + space_id: i32, + issue_id: i32, + kind: ArtifactKind, + title: &str, + content: &str, + criteria: &[&str], + source: i32, + ) -> i32 { + let art = loop_service::artifact::create_artifact( + &db.conn, + space_id, + issue_id, + kind, + title, + ArtifactStatus::Done, + ActorKind::Agent, + None, + ) + .await + .unwrap(); + loop_service::artifact::add_revision(&db.conn, art.id, content, ActorKind::Agent, None) + .await + .unwrap(); + for c in criteria { + loop_service::artifact::add_criterion(&db.conn, art.id, CriterionKind::Acceptance, c) + .await + .unwrap(); + } + loop_service::link::create_link( + &db.conn, + space_id, + art.id, + source, + LinkKind::DerivesFrom, + None, + ) + .await + .unwrap(); + art.id + } + + #[tokio::test] + async fn design_briefing_has_all_sections() { + let (db, space, issue, root) = seed().await; + // Constitution + a design-relevant decision + an implement-only pitfall. + loop_service::memory::create_memory( + &db.conn, + space, + MemoryKind::Constitution, + ActorKind::Human, + "House rules", + None, + "No new dependencies without approval.", + loop_memory::TrustTier::Human, + loop_service::memory::MemoryProvenance::default(), + ) + .await + .unwrap(); + let decision_id = loop_service::memory::create_memory( + &db.conn, + space, + MemoryKind::Decision, + ActorKind::Agent, + "Token store", + None, + "Use the existing keyring abstraction.", + loop_memory::TrustTier::Proposed, + loop_service::memory::MemoryProvenance::default(), + ) + .await + .unwrap() + .id; + let pitfall_id = loop_service::memory::create_memory( + &db.conn, + space, + MemoryKind::Pitfall, + ActorKind::Agent, + "Flaky test", + None, + "auth_test is order-dependent.", + loop_memory::TrustTier::Proposed, + loop_service::memory::MemoryProvenance::default(), + ) + .await + .unwrap() + .id; + + // issue root → requirement → (design target is the requirement). + let req = add_node( + &db, + space, + issue.id, + ArtifactKind::Requirement, + "R1: credential check", + "The system must verify a username/password pair.\n\nDetails follow.", + &["Rejects an unknown user", "Accepts a valid pair"], + root, + ) + .await; + + let out = assemble_briefing(&db.conn, &issue, Stage::Design, Some(req)) + .await + .unwrap(); + let t = &out.text; + + // ① constitution shown in full text (title + body via render_memories). + assert!(t.contains("Space constitution")); + assert!(t.contains("No new dependencies")); + // ② full memory index — EVERY active non-constitution memory by title with a + // stable [M{n}] handle, id-ascending, no stage filter (the pitfall is present + // too now). Bodies are NOT shown — they're read on demand via loop_read_memory. + assert!(t.contains("# Memory index")); + assert!(t.contains("[M1] (decision · proposed) Token store")); + assert!(t.contains("[M2] (pitfall · proposed) Flaky test")); + assert!( + !t.contains("Use the existing keyring abstraction."), + "memory bodies are read on demand, never shown in the index" + ); + assert!(!t.contains("auth_test is order-dependent")); + // ③ issue full text. + assert!(t.contains("Add login")); + assert!(t.contains("Users must be able to authenticate.")); + // ④ requirements: design sees ALL requirements (title + body + criteria), + // not a single-target lineage. + assert!(t.contains("# Requirements")); + assert!(t.contains("R1: credential check")); + assert!(t.contains("The system must verify a username/password pair.")); + assert!(t.contains("Rejects an unknown user")); + // ⑥ stage instruction (design-specific) + ⑦ tool contract. + assert!(t.contains("Produce ONE design")); + assert!(t.contains("loop_submit_artifacts")); + + // Manifest lists every emitted component + the stage template. + assert_eq!(out.manifest["template"], "design@v1"); + let sections: Vec<&str> = out.manifest["components"] + .as_array() + .unwrap() + .iter() + .map(|c| c["section"].as_str().unwrap()) + .collect(); + for expected in [ + "constitution", + "memory_index", + "issue", + "requirements", + "stage_instruction", + "tool_contract", + ] { + assert!(sections.contains(&expected), "manifest missing {expected}"); + } + // The memory_index manifest maps each handle to its id, id-ascending — the + // single source loop_read_memory resolves submitted [M{n}] handles against. + assert_eq!(out.manifest["memory_index"]["M1"], decision_id); + assert_eq!(out.manifest["memory_index"]["M2"], pitfall_id); + assert!(decision_id < pitfall_id, "handles follow id-ascending order"); + } + + #[tokio::test] + async fn design_briefing_shows_all_requirements() { + let (db, space, issue, root) = seed().await; + add_node(&db, space, issue.id, ArtifactKind::Requirement, "Alpha", "req alpha", &[], root).await; + add_node(&db, space, issue.id, ArtifactKind::Requirement, "Beta", "req beta", &[], root).await; + add_node(&db, space, issue.id, ArtifactKind::Requirement, "Gamma", "req gamma", &[], root).await; + let out = assemble_briefing(&db.conn, &issue, Stage::Design, Some(root)) + .await + .unwrap(); + let t = &out.text; + assert!( + t.contains("Alpha") && t.contains("Beta") && t.contains("Gamma"), + "design must see every requirement, not just one" + ); + } + + #[tokio::test] + async fn implement_offers_task_complete_and_review_surfaces_declared() { + let (db, space, issue, root) = seed().await; + let req = add_node( + &db, space, issue.id, ArtifactKind::Requirement, "R1", "req", &["AC holds"], root, + ) + .await; + let task = + add_node(&db, space, issue.id, ArtifactKind::Task, "T1", "do it", &[], req).await; + + // Implement briefing tells the agent about loop_task_complete (D12 contract). + let impl_out = assemble_briefing(&db.conn, &issue, Stage::Implement, Some(task)) + .await + .unwrap(); + assert!( + impl_out.text.contains("loop_task_complete"), + "implement briefing must mention the declaration tool" + ); + + // Review briefing WITHOUT a declared completion → no declared section. + let before = assemble_briefing(&db.conn, &issue, Stage::Review, Some(task)) + .await + .unwrap(); + assert!(!before.text.contains("declared this task already complete")); + + // Record a declared-complete implement iteration for this task. + let it = try_claim_iteration( + &db.conn, + IterationClaim { + space_id: space, + issue_id: issue.id, + stage: Stage::Implement, + target_artifact_id: Some(task), + slot_no: None, + capability_token: "brief-decl".into(), + attempt: 0, + }, + ) + .await + .unwrap() + .unwrap(); + loop_iteration::Entity::update_many() + .col_expr( + loop_iteration::Column::AgentCompletionReason, + Expr::value("dependency already shipped it"), + ) + .filter(loop_iteration::Column::Id.eq(it.id)) + .exec(&db.conn) + .await + .unwrap(); + // The declared no-op settlement records `outcome = declared_complete`; the + // briefing's surfacing query gates on it (Codex r1), so mirror production. + loop_service::iteration::set_iteration_outcome( + &db.conn, + it.id, + crate::db::entities::loop_iteration::IterationOutcome::DeclaredComplete, + ) + .await + .unwrap(); + + // Review briefing now surfaces it with the verify-against-HEAD note. + let after = assemble_briefing(&db.conn, &issue, Stage::Review, Some(task)) + .await + .unwrap(); + assert!(after.text.contains("declared this task already complete")); + assert!(after.text.contains("dependency already shipped it")); + assert!(after.text.contains("current worktree HEAD")); + } + + #[tokio::test] + async fn plan_briefing_enumerates_criterion_ordinals() { + let (db, space, issue, root) = seed().await; + add_node(&db, space, issue.id, ArtifactKind::Requirement, "R1", "first req", &["alpha holds"], root).await; + add_node(&db, space, issue.id, ArtifactKind::Requirement, "R2", "second req", &["beta holds"], root).await; + let out = assemble_briefing(&db.conn, &issue, Stage::Plan, Some(root)) + .await + .unwrap(); + let t = &out.text; + assert!(t.contains("# Requirements")); + assert!(t.contains("[R1.AC1] alpha holds"), "plan enumerates ordinals"); + assert!(t.contains("[R2.AC1] beta holds")); + assert!(t.contains("covers"), "plan tool contract explains covers"); + // The coverage contract restates the full target set as one flat checklist. + assert!(t.contains("# Coverage contract"), "plan gets the coverage contract"); + assert!( + t.contains("cover ALL 2 acceptance ordinals"), + "coverage contract states the full count" + ); + } + + #[tokio::test] + async fn implement_briefing_ac_closure_covered_and_fallback() { + let (db, space, issue, root) = seed().await; + let r1 = add_node(&db, space, issue.id, ArtifactKind::Requirement, "R1", "first", &["alpha holds"], root).await; + add_node(&db, space, issue.id, ArtifactKind::Requirement, "R2", "second", &["beta holds"], root).await; + // A design carrying a cross-cutting obligation (invariant). + let design = loop_service::artifact::create_artifact(&db.conn, space, issue.id, ArtifactKind::Design, "D", ArtifactStatus::Done, ActorKind::Agent, None).await.unwrap(); + loop_service::artifact::add_criterion(&db.conn, design.id, CriterionKind::Invariant, "stays O(1)").await.unwrap(); + // Task 1 covers R1.AC1; task 2 covers nothing. + let t1 = loop_service::artifact::create_artifact(&db.conn, space, issue.id, ArtifactKind::Task, "T1", ArtifactStatus::Pending, ActorKind::Agent, None).await.unwrap(); + let t2 = loop_service::artifact::create_artifact(&db.conn, space, issue.id, ArtifactKind::Task, "T2", ArtifactStatus::Pending, ActorKind::Agent, None).await.unwrap(); + let r1ac = loop_service::artifact::get_artifact_detail(&db.conn, r1).await.unwrap().unwrap().criteria[0].id; + loop_service::coverage::create_coverage(&db.conn, space, t1.id, r1ac).await.unwrap(); + + // Covered task: its criterion (by ordinal) + the design obligation, NOT + // the unrelated R2.AC1. + let out1 = assemble_briefing(&db.conn, &issue, Stage::Implement, Some(t1.id)).await.unwrap(); + assert!(out1.text.contains("[R1.AC1] alpha holds")); + assert!(out1.text.contains("(invariant) stays O(1)")); + assert!(!out1.text.contains("beta holds"), "covered task isn't shown unrelated criteria"); + + // Uncovered task: falls back to ALL requirement acceptance criteria. + let out2 = assemble_briefing(&db.conn, &issue, Stage::Implement, Some(t2.id)).await.unwrap(); + assert!(out2.text.contains("alpha holds")); + assert!(out2.text.contains("beta holds")); + assert!(out2.text.contains("(invariant) stays O(1)")); + } + + #[tokio::test] + async fn review_briefing_emits_checklist_and_manifest() { + let (db, space, issue, root) = seed().await; + let r1 = add_node(&db, space, issue.id, ArtifactKind::Requirement, "R1", "first", &["alpha holds"], root).await; + // A design with a cross-cutting obligation. + let design = loop_service::artifact::create_artifact(&db.conn, space, issue.id, ArtifactKind::Design, "D", ArtifactStatus::Done, ActorKind::Agent, None).await.unwrap(); + loop_service::artifact::add_criterion(&db.conn, design.id, CriterionKind::Invariant, "stays O(1)").await.unwrap(); + // A task that covers R1.AC1 AND has its own acceptance. + let t1 = loop_service::artifact::create_artifact(&db.conn, space, issue.id, ArtifactKind::Task, "T1", ArtifactStatus::InProgress, ActorKind::Agent, None).await.unwrap(); + loop_service::artifact::add_criterion(&db.conn, t1.id, CriterionKind::Acceptance, "task own ac").await.unwrap(); + let r1ac = loop_service::artifact::get_artifact_detail(&db.conn, r1).await.unwrap().unwrap().criteria[0].id; + loop_service::coverage::create_coverage(&db.conn, space, t1.id, r1ac).await.unwrap(); + loop_service::link::create_link(&db.conn, space, t1.id, root, LinkKind::DerivesFrom, None).await.unwrap(); + + let out = assemble_briefing(&db.conn, &issue, Stage::Review, Some(t1.id)).await.unwrap(); + let t = &out.text; + // Checklist prints the covered requirement AC handle + the task's own T1. + assert!(t.contains("[R1.AC1] alpha holds"), "covered AC is in the checklist"); + assert!(t.contains("[T1] task own ac"), "task's own acceptance is in the checklist"); + // The design obligation is shown as awareness-only context, not a handle. + assert!(t.contains("(invariant) stays O(1)")); + assert!(t.contains("context only")); + assert!(t.contains("one check per handle")); + + // The manifest carries the resolution map (handle → criterion id), and ONLY + // the task-verifiable criteria — never a design obligation. + let crit = out.manifest.get("criteria").unwrap().as_object().unwrap(); + assert_eq!(crit.len(), 2, "only task-verifiable criteria are injected"); + assert_eq!(crit["R1.AC1"], json!(r1ac)); + assert!(crit.contains_key("T1")); + } + + #[tokio::test] + async fn triage_briefing_minimal_without_target() { + let (db, _space, issue, _root) = seed().await; + let out = assemble_briefing(&db.conn, &issue, Stage::Triage, None) + .await + .unwrap(); + // No target → no lineage / criteria sections, but the core stays. + assert!(out.text.contains("Triage this issue")); + assert!(out.text.contains("loop_submit_route")); + assert_eq!(out.manifest["template"], "triage@v1"); + let sections: Vec<&str> = out.manifest["components"] + .as_array() + .unwrap() + .iter() + .map(|c| c["section"].as_str().unwrap()) + .collect(); + assert!(!sections.contains(&"lineage")); + assert!(!sections.contains(&"acceptance_criteria")); + assert!(sections.contains(&"issue")); + } + + #[test] + fn lineage_is_cycle_protected() { + // A → B → A: build_lineage must terminate rather than loop forever. + let link = |from, to| crate::models::loops::LoopLinkRow { + id: 0, + from_artifact_id: from, + to_artifact_id: to, + kind: LinkKind::DerivesFrom, + source_revision_id: None, + }; + let links = vec![link(1, 2), link(2, 1)]; + // Walk from 1: 1 → 2 → (1 already seen) stops. Bounded chain, no hang. + assert_eq!(build_lineage(&links, 1), vec![1, 2]); + } + + #[test] + fn first_paragraph_stops_at_blank_line() { + assert_eq!(first_paragraph("one\ntwo\n\nthree"), "one\ntwo"); + assert_eq!(first_paragraph("\n\nlead\nmore"), "lead\nmore"); + assert_eq!(first_paragraph(" "), ""); + } + + #[tokio::test] + async fn reflect_briefing_has_retrospective_and_read_only_instruction() { + let (db, space, issue, root) = seed().await; + // A non-constitution memory so the Memory index (§②) renders — reflect's + // supersede handles resolve against it. + loop_service::memory::create_memory( + &db.conn, + space, + MemoryKind::Decision, + ActorKind::Agent, + "Use keyring", + Some("store tokens in the OS keyring"), + "We chose the OS keyring.", + loop_memory::TrustTier::Proposed, + loop_service::memory::MemoryProvenance::default(), + ) + .await + .unwrap(); + // A requirement (+criterion) and a result — the retrospective surface. + let req = add_node( + &db, + space, + issue.id, + ArtifactKind::Requirement, + "Authenticate users", + "Users can log in.", + &["session persists"], + root, + ) + .await; + let _result = add_node( + &db, + space, + issue.id, + ArtifactKind::Result, + "Login shipped", + "Auth is implemented and merged.", + &[], + req, + ) + .await; + + let out = assemble_briefing(&db.conn, &issue, Stage::Reflect, None) + .await + .unwrap(); + let t = &out.text; + assert!(t.contains("# What was built"), "retro header present"); + assert!(t.contains("Authenticate users"), "requirement title in retro"); + assert!(t.contains("session persists"), "criterion in retro"); + assert!(t.contains("READ-ONLY"), "read-only stage instruction"); + assert!(t.contains("loop_submit_reflection"), "tool contract"); + assert!(t.contains("# Memory index"), "memory index injects for reflect"); + assert!( + out.manifest["memory_index"]["M1"].is_i64(), + "manifest carries memory_index" + ); + } +} diff --git a/src-tauri/src/loop_engine/config_resolver.rs b/src-tauri/src/loop_engine/config_resolver.rs new file mode 100644 index 0000000000..9d8fdc1708 --- /dev/null +++ b/src-tauri/src/loop_engine/config_resolver.rs @@ -0,0 +1,153 @@ +//! Resolve an issue's effective Loop Contract config. An issue either stores its +//! own `config` JSON, or leaves it `NULL` to inherit the space's +//! `default_config`, resolved at read time so a space-default change propagates +//! to every inheriting issue without rewriting their rows. + +use sea_orm::{DatabaseConnection, EntityTrait}; + +use crate::db::entities::{loop_issue, loop_space}; +use crate::loop_engine::error::LoopError; +use crate::models::loops::IssueConfig; + +/// The config the engine should act on for `issue`. An issue with its own +/// `config` parses that; an issue with `config = NULL` resolves the space +/// `default_config` (always present). Malformed JSON is a hard error +/// ([`LoopError::InvalidConfig`]) — the engine never silently downgrades a broken +/// config to the default. +pub async fn effective_config( + conn: &DatabaseConnection, + issue: &loop_issue::Model, +) -> Result { + match issue.config.as_deref() { + Some(json) => { + serde_json::from_str(json).map_err(|e| LoopError::InvalidConfig(e.to_string())) + } + None => { + let space = loop_space::Entity::find_by_id(issue.space_id) + .one(conn) + .await? + .ok_or_else(|| LoopError::NotFound(format!("loop_space {}", issue.space_id)))?; + serde_json::from_str(&space.default_config) + .map_err(|e| LoopError::InvalidConfig(e.to_string())) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::entities::loop_issue::IssuePriority; + use crate::db::service::loop_service::{issue, space}; + use crate::db::test_helpers::{fresh_in_memory_db, seed_folder}; + use sea_orm::sea_query::Expr; + use sea_orm::{ColumnTrait, QueryFilter}; + + async fn fetch_issue(db: &crate::db::AppDatabase, id: i32) -> loop_issue::Model { + loop_issue::Entity::find_by_id(id) + .one(&db.conn) + .await + .unwrap() + .unwrap() + } + + /// Create a space + an inheriting issue (`config = NULL`); returns + /// (db, space_id, issue_id). + async fn seed() -> (crate::db::AppDatabase, i32, i32) { + let db = fresh_in_memory_db().await; + let folder_id = seed_folder(&db, "/tmp/cfg-resolver").await; + let space = space::create_space(&db.conn, "S", folder_id).await.unwrap(); + let detail = issue::create_issue( + &db.conn, + space.id, + "Issue", + "body", + IssuePriority::Medium, + None, // inheriting + ) + .await + .unwrap(); + (db, space.id, detail.row.id) + } + + /// Overwrite the space's `default_config` (NOT NULL) with the given JSON. + async fn set_space_default(db: &crate::db::AppDatabase, space_id: i32, json: String) { + loop_space::Entity::update_many() + .col_expr(loop_space::Column::DefaultConfig, Expr::value(json)) + .filter(loop_space::Column::Id.eq(space_id)) + .exec(&db.conn) + .await + .unwrap(); + } + + /// Overwrite an issue's `config` (nullable: `None` = inherit). + async fn set_issue_config(db: &crate::db::AppDatabase, issue_id: i32, json: Option) { + loop_issue::Entity::update_many() + .col_expr(loop_issue::Column::Config, Expr::value(json)) + .filter(loop_issue::Column::Id.eq(issue_id)) + .exec(&db.conn) + .await + .unwrap(); + } + + #[tokio::test] + async fn inheriting_issue_resolves_space_default() { + let (db, space_id, issue_id) = seed().await; + let space_default = IssueConfig { + max_attempts: 99, + ..IssueConfig::default() + }; + set_space_default(&db, space_id, serde_json::to_string(&space_default).unwrap()).await; + + let cfg = effective_config(&db.conn, &fetch_issue(&db, issue_id).await) + .await + .unwrap(); + assert_eq!(cfg.max_attempts, 99, "inherits the space default"); + } + + #[tokio::test] + async fn fresh_space_default_is_the_engine_default() { + // A freshly created space stores the engine default, so an inheriting + // issue resolves it without any explicit set. + let (db, _space_id, issue_id) = seed().await; + let cfg = effective_config(&db.conn, &fetch_issue(&db, issue_id).await) + .await + .unwrap(); + assert_eq!(cfg.max_attempts, IssueConfig::default().max_attempts); + } + + #[tokio::test] + async fn custom_issue_uses_its_own_config() { + let (db, space_id, issue_id) = seed().await; + // A space default exists, but the issue has its own config → ignored. + set_space_default( + &db, + space_id, + serde_json::to_string(&IssueConfig { + max_attempts: 99, + ..IssueConfig::default() + }) + .unwrap(), + ) + .await; + let own = IssueConfig { + max_attempts: 42, + ..IssueConfig::default() + }; + set_issue_config(&db, issue_id, Some(serde_json::to_string(&own).unwrap())).await; + + let cfg = effective_config(&db.conn, &fetch_issue(&db, issue_id).await) + .await + .unwrap(); + assert_eq!(cfg.max_attempts, 42, "uses its own config, not the space default"); + } + + #[tokio::test] + async fn malformed_config_is_hard_error() { + let (db, _space_id, issue_id) = seed().await; + set_issue_config(&db, issue_id, Some("{not valid json".to_string())).await; + let err = effective_config(&db.conn, &fetch_issue(&db, issue_id).await) + .await + .unwrap_err(); + assert!(matches!(err, LoopError::InvalidConfig(_))); + } +} diff --git a/src-tauri/src/loop_engine/dispatch.rs b/src-tauri/src/loop_engine/dispatch.rs new file mode 100644 index 0000000000..b678249ad2 --- /dev/null +++ b/src-tauri/src/loop_engine/dispatch.rs @@ -0,0 +1,1327 @@ +//! Single-iteration dispatch (§4.3) + settlement (§4.9). +//! +//! Given a frontier decision (`DispatchInput`) chosen upstream by the driver, +//! [`dispatch_iteration`] runs the seven-step launch sequence: +//! +//! 1. resolve the issue's worktree path; +//! 2. assemble the briefing prompt + audit manifest; +//! 3. claim the DB-authoritative dispatch lease (a lost race → `Ok(None)`, no +//! orphan conversation); +//! 4. mint the backing `kind=loop` conversation and link it to the lease; +//! 5. spawn the agent in the worktree, injecting the per-iteration capability +//! token (turns on the codeg-mcp companion's loop tools); +//! 6. CAS the lease `queued → running`; +//! 7. send the briefing as the iteration's first prompt. +//! +//! [`settle_iteration`] finalizes a completed run: token accounting (§4.9), the +//! success CAS, and — when nothing was produced — the no-progress signal the +//! circuit breaker reads (enforced in M2.2). +//! +//! This module never decides *what* to dispatch; that is the driver's job +//! (Task 1.6). The agent spawn is abstracted behind [`LoopAgentSpawner`] so the +//! whole sequence is testable without launching a real agent subprocess. + +use std::collections::BTreeMap; +use std::path::Path; + +use async_trait::async_trait; +use chrono::Utc; +use sea_orm::sea_query::Expr; +use sea_orm::{ActiveEnum, ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; + +use crate::acp::error::AcpError; +use crate::acp::manager::ConnectionManager; +use crate::acp::types::PromptInputBlock; +use crate::commands::acp::build_session_runtime_env; +use crate::commands::conversations::get_folder_conversation_core; +use crate::db::entities::loop_inbox_item::InboxKind; +use crate::db::entities::loop_issue::{IssueStatus, PauseReason}; +use crate::db::entities::loop_iteration::{self, IterationOutcome, IterationStatus, Stage}; +use crate::db::entities::{loop_artifact, loop_issue}; +use crate::db::service::conversation_service::create_loop; +use crate::db::service::folder_service; +use crate::db::service::loop_service::{inbox, iteration}; +use crate::db::AppDatabase; +use crate::models::agent::AgentType; +use crate::models::loops::{LoopChanged, LOOP_CHANGED_EVENT}; +use crate::web::event_bridge::{emit_event, EventEmitter}; + +use crate::loop_engine::briefing::{assemble_briefing, BriefingOutput}; +use crate::loop_engine::config_resolver::effective_config; +use crate::loop_engine::error::LoopError; +use crate::loop_engine::transitions::{ + cas_issue_status, cas_iteration_status, try_claim_iteration, IterationClaim, +}; + +/// Emit the coarse `loop://changed` event so every client refetches the issue's +/// DAG. The autonomous pipeline (dispatch + settle) is the engine's own write +/// path — distinct from the command layer's CRUD emits — so without this a +/// triggered issue would grow its DAG silently until something else refetched. +pub(crate) fn emit_changed( + emitter: &EventEmitter, + space_id: i32, + issue_id: i32, + subject_id: i32, + kind: &str, +) { + emit_event( + emitter, + LOOP_CHANGED_EVENT, + LoopChanged { + v: 1, + space_id, + issue_id: Some(issue_id), + subject_kind: "iteration".to_string(), + subject_id, + kind: kind.to_string(), + }, + ); +} + +/// Block an issue whose node burned `max_attempts` with no progress: CAS the +/// issue `running → blocked` (so the driver stops on its next tick) and file an +/// idempotent `no_progress:{node}` inbox card the human resolves via retry/cancel. +/// This is the shared no-progress terminal for the settle-time read-stage / abandon +/// breaker. The write pipeline files the same card kind and key shape for tasks, +/// so the two dedupe naturally if they ever land on the same implement node. +#[allow(clippy::too_many_arguments)] +pub(crate) async fn block_issue_no_progress( + db: &AppDatabase, + emitter: &EventEmitter, + space_id: i32, + issue_id: i32, + node_artifact_id: i32, + iteration_id: Option, + reason: &str, + sig: &str, + attempt: i32, +) -> Result<(), LoopError> { + cas_issue_status(&db.conn, issue_id, IssueStatus::Running, IssueStatus::Blocked).await?; + inbox::upsert_inbox( + &db.conn, + space_id, + issue_id, + iteration_id, + InboxKind::Blocked, + &format!("no_progress:{node_artifact_id}"), + serde_json::json!({ + "v": 1, + "node_artifact_id": node_artifact_id, + "reason": reason, + "failure_sig": sig, + "attempt": attempt, + }), + ) + .await?; + emit_changed(emitter, space_id, issue_id, issue_id, "blocked"); + Ok(()) +} + +/// The frontier decision the driver hands to dispatch: which iteration to run +/// for which issue / stage / target. Everything here is chosen upstream; +/// dispatch only executes it. +pub struct DispatchInput { + pub space_id: i32, + pub issue_id: i32, + pub stage: Stage, + pub target_artifact_id: Option, + /// Review slot `[0, reviewer_count)`; `None` for non-review stages. + pub slot_no: Option, + pub attempt: i32, + pub agent_type: AgentType, + /// Startup mode for the spawned agent (per-reviewer override); `None` for + /// stages that take the agent's own default. + pub mode_id: Option, + /// Startup config values for the spawned agent (per-reviewer override); + /// empty for stages that take no extra config. + pub config_values: BTreeMap, + /// The issue's engine-created worktree folder (`folder.id`). + pub worktree_folder_id: i32, +} + +/// What a successful dispatch produced — enough for the driver to track the +/// live iteration and correlate its turn-complete event back to the lease. +pub struct DispatchHandle { + pub iteration_id: i32, + pub conversation_id: i32, + pub connection_id: String, + pub capability_token: String, +} + +/// Outcome of settling a finished iteration. +pub struct SettleOutcome { + pub iteration_id: i32, + pub produced_artifact_ids: Vec, + pub tokens_used: i64, + /// `true` when the iteration produced at least one artifact. + pub made_progress: bool, +} + +/// How a settle resolves the iteration's terminal status. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SettleResolution { + /// The agent turn completed normally (incl. an empty turn). → `Succeeded`. + Completed, + /// The backing connection died with no completed turn (reconcile `Missing`). + /// → `Failed` + a no-progress signature so redispatch stays bounded by + /// `max_attempts`. A do-nothing orphan must never be faked as success. + Abandoned, +} + +/// Abstraction over the `ConnectionManager` calls dispatch makes, so the +/// seven-step sequence is testable without spawning a real agent. Production +/// wires this to [`ConnectionManager`]; tests use a stub. Runtime-env assembly +/// lives inside the production impl (it touches settings + the filesystem) so +/// tests never run it. +#[async_trait] +pub trait LoopAgentSpawner: Send + Sync { + #[allow(clippy::too_many_arguments)] + async fn spawn_loop_agent( + &self, + db: &AppDatabase, + data_dir: &Path, + agent_type: AgentType, + working_dir: String, + emitter: EventEmitter, + preferred_mode_id: Option, + preferred_config_values: BTreeMap, + capability_token: String, + ) -> Result; + + async fn send_loop_prompt( + &self, + db: &AppDatabase, + conn_id: &str, + text: String, + folder_id: i32, + conversation_id: i32, + ) -> Result<(), AcpError>; + + async fn disconnect_loop_agent(&self, conn_id: &str); + + /// Resolve the live connection backing a loop conversation, if any. Used to + /// reap a cancelled reviewer's agent process (conversation_id → connection), + /// so a voided reviewer can't keep writing to the shared worktree. + async fn find_loop_connection(&self, conversation_id: i32) -> Option; +} + +#[async_trait] +impl LoopAgentSpawner for ConnectionManager { + #[allow(clippy::too_many_arguments)] + async fn spawn_loop_agent( + &self, + db: &AppDatabase, + data_dir: &Path, + agent_type: AgentType, + working_dir: String, + emitter: EventEmitter, + preferred_mode_id: Option, + preferred_config_values: BTreeMap, + capability_token: String, + ) -> Result { + let runtime_env = build_session_runtime_env(db, agent_type, None, data_dir).await?; + self.spawn_agent( + agent_type, + Some(working_dir), + None, // fresh session — loop iterations never resume + runtime_env, + "loop-engine".to_string(), + emitter, + preferred_mode_id, + preferred_config_values, + Some(capability_token), + ) + .await + } + + async fn send_loop_prompt( + &self, + db: &AppDatabase, + conn_id: &str, + text: String, + folder_id: i32, + conversation_id: i32, + ) -> Result<(), AcpError> { + self.send_prompt_linked( + db, + conn_id, + vec![PromptInputBlock::Text { text }], + Some(folder_id), + Some(conversation_id), + None, + ) + .await + .map(|_| ()) + } + + async fn disconnect_loop_agent(&self, conn_id: &str) { + let _ = self.disconnect(conn_id).await; + } + + async fn find_loop_connection(&self, conversation_id: i32) -> Option { + self.find_connection_by_conversation_id(conversation_id).await + } +} + +/// §4.3 single-iteration dispatch. Returns `Ok(None)` when the dispatch lease is +/// already held (lost the race — no conversation created), `Ok(Some(handle))` +/// on a launched iteration, and `Err` after marking the claimed lease failed + +/// filing a blocked inbox item. +pub async fn dispatch_iteration( + db: &AppDatabase, + data_dir: &Path, + spawner: &dyn LoopAgentSpawner, + emitter: EventEmitter, + input: DispatchInput, +) -> Result, LoopError> { + let conn = &db.conn; + + // Step 1: resolve the issue's worktree path. + let folder = folder_service::get_folder_by_id(conn, input.worktree_folder_id) + .await? + .ok_or_else(|| { + LoopError::NotFound(format!("worktree folder {}", input.worktree_folder_id)) + })?; + let worktree_path = folder.path; + + let issue = loop_issue::Entity::find_by_id(input.issue_id) + .one(conn) + .await? + .ok_or_else(|| LoopError::NotFound(format!("issue {}", input.issue_id)))?; + + // Step 2: assemble the briefing prompt + audit manifest. + let briefing = assemble_briefing(conn, &issue, input.stage, input.target_artifact_id).await?; + + // Step 3: claim the dispatch lease (conversation attached afterwards). A + // lost race surfaces as `Ok(None)` — the driver simply skips, no orphan. + let capability_token = uuid::Uuid::new_v4().to_string(); + let iter = match try_claim_iteration( + conn, + IterationClaim { + space_id: input.space_id, + issue_id: input.issue_id, + stage: input.stage, + target_artifact_id: input.target_artifact_id, + slot_no: input.slot_no, + capability_token: capability_token.clone(), + attempt: input.attempt, + }, + ) + .await? + { + Some(iter) => iter, + None => return Ok(None), + }; + + // From here the lease row exists: any failure must mark it failed and file + // a blocked inbox item so the issue doesn't silently stall. + match launch_claimed_iteration( + db, + data_dir, + spawner, + emitter.clone(), + &input, + &issue, + &iter, + &worktree_path, + &capability_token, + briefing, + ) + .await + { + Ok(handle) => { + // A new iteration is now running — surface it live so the DAG's + // "executing now" highlight appears without waiting for settlement. + emit_changed( + &emitter, + input.space_id, + input.issue_id, + handle.iteration_id, + "dispatched", + ); + Ok(Some(handle)) + } + Err(e) => { + fail_iteration(conn, &emitter, &input, &iter, &e).await; + Err(e) + } + } +} + +/// Steps 4–7, isolated so the caller can run failure cleanup on any error. +#[allow(clippy::too_many_arguments)] +async fn launch_claimed_iteration( + db: &AppDatabase, + data_dir: &Path, + spawner: &dyn LoopAgentSpawner, + emitter: EventEmitter, + input: &DispatchInput, + issue: &loop_issue::Model, + iter: &loop_iteration::Model, + worktree_path: &str, + capability_token: &str, + briefing: BriefingOutput, +) -> Result { + let conn = &db.conn; + + // Step 4: mint the backing kind=loop conversation, link it to the lease, + // and stash the briefing manifest for audit. + let title = Some(format!("{} · #{}", stage_title(input.stage), issue.seq_no)); + let conv = create_loop(conn, input.worktree_folder_id, input.agent_type, title, None).await?; + + let mut linked: loop_iteration::ActiveModel = iter.clone().into(); + linked.conversation_id = Set(Some(conv.id)); + linked.context_manifest = Set(Some(briefing.manifest.to_string())); + linked.update(conn).await?; + + // Step 5: spawn the agent in the worktree, injecting the capability token. + let conn_id = spawner + .spawn_loop_agent( + db, + data_dir, + input.agent_type, + worktree_path.to_string(), + emitter, + input.mode_id.clone(), + input.config_values.clone(), + capability_token.to_string(), + ) + .await + .map_err(|e| LoopError::Acp(e.to_string()))?; + + // Steps 6–7: flip the lease to running, then send the briefing. Any failure + // after the spawn must also tear down the live connection we just created. + if let Err(e) = finish_launch( + db, + spawner, + iter.id, + &conn_id, + conv.id, + input.worktree_folder_id, + briefing.text, + ) + .await + { + spawner.disconnect_loop_agent(&conn_id).await; + return Err(e); + } + + Ok(DispatchHandle { + iteration_id: iter.id, + conversation_id: conv.id, + connection_id: conn_id, + capability_token: capability_token.to_string(), + }) +} + +/// Step 6 (CAS `queued → running` + stamp `started_at`) and step 7 (deliver the +/// briefing as the iteration's first prompt). +async fn finish_launch( + db: &AppDatabase, + spawner: &dyn LoopAgentSpawner, + iteration_id: i32, + conn_id: &str, + conversation_id: i32, + folder_id: i32, + briefing_text: String, +) -> Result<(), LoopError> { + let conn = &db.conn; + let swapped = cas_iteration_status( + conn, + iteration_id, + IterationStatus::Queued, + IterationStatus::Running, + ) + .await?; + if !swapped { + // Cancelled/changed between claim and spawn — abort this launch. + return Err(LoopError::Conflict); + } + loop_iteration::Entity::update_many() + .col_expr(loop_iteration::Column::StartedAt, Expr::value(Utc::now())) + .filter(loop_iteration::Column::Id.eq(iteration_id)) + .exec(conn) + .await?; + + spawner + .send_loop_prompt(db, conn_id, briefing_text, folder_id, conversation_id) + .await + .map_err(|e| LoopError::Acp(e.to_string()))?; + Ok(()) +} + +/// Best-effort failure cleanup for a claimed-but-not-launched iteration: mark +/// the lease failed, stamp `ended_at`, and surface a blocked inbox item. Emits +/// `loop://changed` when the card is new/changed so the failure shows live (the +/// caller's success path emits `dispatched`, but this error path does not). +async fn fail_iteration( + conn: &sea_orm::DatabaseConnection, + emitter: &EventEmitter, + input: &DispatchInput, + iter: &loop_iteration::Model, + err: &LoopError, +) { + // Atomic fail from whichever active state the lease holds (§2.6) — one + // multi-from UPDATE that also stamps `ended_at`, so the row can't wedge in + // `running` if the process dies between two separate CAS calls. + let _ = crate::loop_engine::transitions::fail_iteration_active(conn, iter.id).await; + if let Ok(upsert) = inbox::upsert_inbox( + conn, + input.space_id, + input.issue_id, + Some(iter.id), + InboxKind::Blocked, + &format!("dispatch_failed:{}", iter.id), + serde_json::json!({ + "stage": input.stage.to_value(), + "error": err.to_string(), + }), + ) + .await + { + if upsert.changed() { + emit_changed(emitter, input.space_id, input.issue_id, iter.id, "blocked"); + } + } +} + +/// The artifact ids an iteration produced (its `produced_by_iteration_id` +/// fan-out). Used by both settle branches — the winner to report progress, the +/// non-winner to mirror the already-landed state without mutating. +async fn produced_artifact_ids( + conn: &sea_orm::DatabaseConnection, + iteration_id: i32, +) -> Result, LoopError> { + Ok(loop_artifact::Entity::find() + .filter(loop_artifact::Column::ProducedByIterationId.eq(iteration_id)) + .all(conn) + .await? + .into_iter() + .map(|a| a.id) + .collect()) +} + +/// Attempts to read the iteration's turn token total from its parsed session +/// file, with a few short retries (the file may not be flushed the instant the +/// turn-complete fires). `None` ⇒ unreadable after all retries (caller marks the +/// iteration `tokens_pending` rather than charging a phantom 0). `Some(0)` is a +/// *genuine* zero-token turn and is charged normally (§2.7). +async fn read_turn_tokens(conn: &sea_orm::DatabaseConnection, conversation_id: i32) -> Option { + const ATTEMPTS: u32 = 5; + const BACKOFF_MS: u64 = 120; + for attempt in 0..ATTEMPTS { + if let Ok((detail, _)) = get_folder_conversation_core(conn, conversation_id).await { + // A parsed session with stats present is authoritative (incl. a real 0). + if let Some(stats) = detail.session_stats { + return Some(stats.total_tokens.unwrap_or(0) as i64); + } + } + if attempt + 1 < ATTEMPTS { + tokio::time::sleep(std::time::Duration::from_millis(BACKOFF_MS)).await; + } + } + None +} + +/// §4.9 settlement: finalize a completed iteration. Re-parses the session file +/// for token usage, succeeds the lease, and — when the run produced nothing — +/// bumps the target node's rework counter + records a failure signature for the +/// no-progress breaker. +pub async fn settle_iteration( + db: &AppDatabase, + emitter: &EventEmitter, + iteration_id: i32, +) -> Result { + settle_iteration_as(db, emitter, iteration_id, SettleResolution::Completed).await +} + +/// Settle with an explicit terminal resolution. `Completed` succeeds the lease +/// (the normal turn-complete path); `Abandoned` fails it (reconcile of a dead +/// connection with no completed turn) and records a no-progress signature so +/// redispatch stays bounded by `max_attempts`. +pub async fn settle_iteration_as( + db: &AppDatabase, + emitter: &EventEmitter, + iteration_id: i32, + resolution: SettleResolution, +) -> Result { + let conn = &db.conn; + let iter = iteration::get_iteration(conn, iteration_id) + .await? + .ok_or_else(|| LoopError::NotFound(format!("iteration {iteration_id}")))?; + + // A normal completion succeeds the lease; an abandoned orphan (dead + // connection, no completed turn) fails it — never faked as success. + let terminal = match resolution { + SettleResolution::Completed => IterationStatus::Succeeded, + SettleResolution::Abandoned => IterationStatus::Failed, + }; + + // Single-winner gate (§2.2): the CAS `running → terminal` is the settlement + // authority. Token accounting, the node rework bump, and the budget breaker + // run ONLY for the winner, so a double settle (turn-complete event + the + // reconcile backstop racing) can never double-count. + let won = cas_iteration_status(conn, iteration_id, IterationStatus::Running, terminal).await?; + if !won { + // Already settled by the other trigger — report its landed state, mutate + // nothing. + let produced = produced_artifact_ids(conn, iteration_id).await?; + return Ok(SettleOutcome { + iteration_id, + tokens_used: iter.tokens_used, + made_progress: !produced.is_empty(), + produced_artifact_ids: produced, + }); + } + + // ----- winner-only side effects ----- + // §4.9 token settlement (§2.7 hardened): read the turn's token total from the + // parsed session file with bounded retries. `None` ⇒ unreadable after all + // retries → mark the iteration `tokens_pending` and DON'T charge a phantom 0; + // the backfill sweep (`reconcile_pending_tokens`) re-reads and charges later. + let token_read: Option = match iter.conversation_id { + Some(conv_id) => read_turn_tokens(conn, conv_id).await, + None => Some(0), // no backing conversation ⇒ genuinely nothing to charge + }; + let tokens_used = token_read.unwrap_or(0); + let tokens_pending = token_read.is_none(); + + let mut am: loop_iteration::ActiveModel = iter.clone().into(); + am.tokens_used = Set(tokens_used); + am.tokens_pending = Set(tokens_pending); + am.ended_at = Set(Some(Utc::now())); + am.update(conn).await?; + // Accumulate ONLY a known, non-zero charge (a pending read never contaminates + // the issue total → the budget breaker can't false-trip on a phantom 0). + if !tokens_pending && tokens_used > 0 { + loop_issue::Entity::update_many() + .col_expr( + loop_issue::Column::TokenUsed, + Expr::col(loop_issue::Column::TokenUsed).add(tokens_used), + ) + .filter(loop_issue::Column::Id.eq(iter.issue_id)) + .exec(conn) + .await?; + } + + // Which artifacts did this iteration produce? + let produced_artifact_ids = produced_artifact_ids(conn, iteration_id).await?; + + let made_progress = !produced_artifact_ids.is_empty(); + let abandoned = resolution == SettleResolution::Abandoned; + + // Record the iteration outcome (D11). Implement's real outcome is only known at + // its checkpoint (empty_diff / validation_failed / succeeded), so leave it NULL + // here and let `gates` fill it; everything else settles its outcome now. + // Write-once, so this never clobbers a value the checkpoint already wrote. + let settle_outcome = if abandoned { + Some(IterationOutcome::Abandoned) + } else if iter.stage == Stage::Implement { + None + } else if made_progress { + Some(IterationOutcome::Succeeded) + } else { + Some(IterationOutcome::NoArtifacts) + }; + if let Some(outcome) = settle_outcome { + iteration::set_iteration_outcome(conn, iteration_id, outcome).await?; + } + + // Bump the target node's rework counter + record a failure signature when the + // run made no progress, or was abandoned (dead connection) — so redispatch is + // bounded by the breaker. Implement measures progress by the worktree diff, so + // its counter is owned by the gates checkpoint, not this artifact-count + // heuristic — skip the no-progress bump for it, but still bump on abandon + // (no checkpoint runs on a dead connection, so nothing else would bound it). + if abandoned || (!made_progress && iter.stage != Stage::Implement) { + if let Some(target) = iter.target_artifact_id { + // No output → bump the node rework counter + record a failure + // signature for the no-progress breaker. + let sig = if abandoned { + format!("abandoned:{}", iter.stage.to_value()) + } else { + format!("no_artifacts:{}", iter.stage.to_value()) + }; + loop_artifact::Entity::update_many() + .col_expr( + loop_artifact::Column::Attempt, + Expr::col(loop_artifact::Column::Attempt).add(1), + ) + .col_expr( + loop_artifact::Column::LastFailureSig, + Expr::value(sig.clone()), + ) + .col_expr(loop_artifact::Column::UpdatedAt, Expr::value(Utc::now())) + .filter(loop_artifact::Column::Id.eq(target)) + .exec(conn) + .await?; + + // No-progress breaker — the read-stage / abandon analogue of the write + // pipeline's `record_rework`. The node lease guarantees no other + // iteration bumped this node between our dispatch and now, so its new + // attempt is exactly `iter.attempt + 1`. Once that reaches the issue's + // `max_attempts` (0 = unlimited → no breaker), stop redispatching: + // block the issue and file a `no_progress:{node}` card. Deliberately + // settle-time, not dispatch-time — a human "retry" does not reset node + // attempts, so gating *before* dispatch would make retry a no-op; + // gating *after* lets each retry burn one real attempt before the + // breaker re-trips, matching the write pipeline's retry contract. + let new_attempt = iter.attempt + 1; + if let Some(issue_row) = loop_issue::Entity::find_by_id(iter.issue_id).one(conn).await? { + let max = effective_config(conn, &issue_row).await?.max_attempts as i32; + if max > 0 && new_attempt >= max { + block_issue_no_progress( + db, + emitter, + iter.space_id, + iter.issue_id, + target, + Some(iteration_id), + "max_attempts", + &sig, + new_attempt, + ) + .await?; + } + } + } + } + + // Issue-level budget breaker: this iteration's tokens have now accumulated, + // so re-evaluate whether the issue has crossed its budget. + trip_budget_if_exhausted(conn, iter.issue_id, iteration_id).await?; + + // The iteration's outputs (new artifacts, route, token totals) have landed — + // tell every client to refetch so the DAG grows in real time. + emit_changed( + emitter, + iter.space_id, + iter.issue_id, + iteration_id, + "settled", + ); + + Ok(SettleOutcome { + iteration_id, + produced_artifact_ids, + tokens_used, + made_progress, + }) +} + +/// Re-read and charge any of an issue's settled iterations whose token total was +/// left pending (the session file wasn't flushed at settle time, §2.7). +/// Idempotent: clears `tokens_pending` only when the read now succeeds, and +/// accumulates the recovered total into the issue before re-evaluating the +/// budget breaker. +/// +/// Boot backfill needs no separate wiring: after `recover_on_boot` restarts the +/// per-issue drivers, each driver's first heartbeat runs this sweep, so a crash +/// mid-settle is re-charged on the next tick. +pub async fn reconcile_pending_tokens( + db: &AppDatabase, + emitter: &EventEmitter, + issue_id: i32, +) -> Result<(), LoopError> { + let conn = &db.conn; + let pending = loop_iteration::Entity::find() + .filter(loop_iteration::Column::IssueId.eq(issue_id)) + .filter(loop_iteration::Column::TokensPending.eq(true)) + .all(conn) + .await?; + for it in pending { + let Some(conv_id) = it.conversation_id else { + // Nothing to read; clear the flag so it stops being swept. + clear_pending(conn, it.id, 0).await?; + continue; + }; + if let Some(tokens) = read_turn_tokens(conn, conv_id).await { + clear_pending(conn, it.id, tokens).await?; + if tokens > 0 { + loop_issue::Entity::update_many() + .col_expr( + loop_issue::Column::TokenUsed, + Expr::col(loop_issue::Column::TokenUsed).add(tokens), + ) + .filter(loop_issue::Column::Id.eq(issue_id)) + .exec(conn) + .await?; + } + trip_budget_if_exhausted(conn, issue_id, it.id).await?; + emit_changed(emitter, it.space_id, issue_id, it.id, "settled"); + } + } + Ok(()) +} + +/// Stamp a recovered token total and clear the pending flag in one UPDATE. +async fn clear_pending( + conn: &sea_orm::DatabaseConnection, + iter_id: i32, + tokens: i64, +) -> Result<(), LoopError> { + loop_iteration::Entity::update_many() + .col_expr(loop_iteration::Column::TokensUsed, Expr::value(tokens)) + .col_expr(loop_iteration::Column::TokensPending, Expr::value(false)) + .filter(loop_iteration::Column::Id.eq(iter_id)) + .exec(conn) + .await?; + Ok(()) +} + +/// Whether the issue has a budget and has reached or exceeded it — i.e. there is +/// no room to start new work. `NULL` budget = unlimited (the default — no +/// artificial cap). Used by the dispatch-time pre-check; the settle-time trip +/// uses a strict `>` (already overspent). +pub(crate) fn over_budget(issue: &loop_issue::Model) -> bool { + issue.token_budget.is_some_and(|b| issue.token_used >= b) +} + +/// Pause an issue for budget exhaustion: CAS `running → paused`, stamp +/// `pause_reason = budget`, and file the dedup'd `budget_exhausted` card. Shared +/// by the settle-time trip and the dispatch-time pre-check. Returns whether it +/// applied the pause. Idempotent: the CAS only fires on the `running → paused` +/// edge, and the inbox upsert dedupes on `(issue, budget_exhausted, budget:{id})`. +pub(crate) async fn pause_for_budget( + conn: &sea_orm::DatabaseConnection, + issue: &loop_issue::Model, + iteration_id: Option, +) -> Result { + if !cas_issue_status(conn, issue.id, IssueStatus::Running, IssueStatus::Paused).await? { + return Ok(false); + } + loop_issue::Entity::update_many() + .col_expr( + loop_issue::Column::PauseReason, + Expr::value(PauseReason::Budget.to_value()), + ) + .filter(loop_issue::Column::Id.eq(issue.id)) + .exec(conn) + .await?; + inbox::upsert_inbox( + conn, + issue.space_id, + issue.id, + iteration_id, + InboxKind::BudgetExhausted, + &format!("budget:{}", issue.id), + serde_json::json!({ + "token_used": issue.token_used, + "token_budget": issue.token_budget, + }), + ) + .await?; + Ok(true) +} + +/// Issue-level budget circuit breaker (§4.10), settle-time edge. Once accumulated +/// `token_used` crosses the issue's `token_budget`, pause the issue + file a card. +/// The per-issue driver then stops dispatching on its next tick (status no longer +/// `running`). The dispatch-time [`over_budget`] pre-check complements this by +/// refusing to start new work once the budget is reached (parallel fan-out can +/// otherwise launch several writes before any settles here). +async fn trip_budget_if_exhausted( + conn: &sea_orm::DatabaseConnection, + issue_id: i32, + iteration_id: i32, +) -> Result<(), LoopError> { + let Some(issue) = loop_issue::Entity::find_by_id(issue_id).one(conn).await? else { + return Ok(()); + }; + let Some(budget) = issue.token_budget else { + return Ok(()); + }; + if issue.token_used <= budget { + return Ok(()); + } + pause_for_budget(conn, &issue, Some(iteration_id)).await?; + Ok(()) +} + +/// Human-facing stage label for the loop conversation title. +fn stage_title(stage: Stage) -> &'static str { + match stage { + Stage::Triage => "Triage", + Stage::Refine => "Refine", + Stage::Design => "Design", + Stage::Plan => "Plan", + Stage::Implement => "Implement", + Stage::Review => "Review", + Stage::Finalize => "Finalize", + Stage::Reflect => "Reflect", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::entities::loop_artifact::{ArtifactKind, ArtifactStatus}; + use crate::db::entities::loop_artifact_revision::ActorKind; + use crate::db::entities::loop_inbox_item::InboxStatus; + use crate::db::entities::conversation::ConversationKind; + use crate::db::entities::loop_issue::IssuePriority; + use crate::db::service::loop_service::{artifact, inbox, issue, space}; + use crate::db::test_helpers::{fresh_in_memory_db, seed_folder}; + use crate::models::loops::IssueConfig; + use tokio::sync::Mutex as AsyncMutex; + + /// Records every call so tests can assert dispatch wired the right values. + #[derive(Default)] + struct StubCalls { + /// (agent_type, working_dir, capability_token) per spawn. + spawned: Vec<(AgentType, String, String)>, + /// (conn_id, text, folder_id, conversation_id) per prompt. + prompts: Vec<(String, String, i32, i32)>, + disconnects: Vec, + } + + #[derive(Default)] + struct StubSpawner { + fail_spawn: bool, + fail_prompt: bool, + calls: AsyncMutex, + } + + #[async_trait] + impl LoopAgentSpawner for StubSpawner { + async fn spawn_loop_agent( + &self, + _db: &AppDatabase, + _data_dir: &Path, + agent_type: AgentType, + working_dir: String, + _emitter: EventEmitter, + _preferred_mode_id: Option, + _preferred_config_values: BTreeMap, + capability_token: String, + ) -> Result { + if self.fail_spawn { + return Err(AcpError::protocol("stub spawn failure")); + } + self.calls + .lock() + .await + .spawned + .push((agent_type, working_dir, capability_token)); + Ok("loop-conn-1".to_string()) + } + + async fn send_loop_prompt( + &self, + _db: &AppDatabase, + conn_id: &str, + text: String, + folder_id: i32, + conversation_id: i32, + ) -> Result<(), AcpError> { + if self.fail_prompt { + return Err(AcpError::protocol("stub prompt failure")); + } + self.calls + .lock() + .await + .prompts + .push((conn_id.to_string(), text, folder_id, conversation_id)); + Ok(()) + } + + async fn disconnect_loop_agent(&self, conn_id: &str) { + self.calls.lock().await.disconnects.push(conn_id.to_string()); + } + + async fn find_loop_connection(&self, _conversation_id: i32) -> Option { + None + } + } + + /// Seed a space + a triggered issue whose worktree folder is `folder_id`. + /// Returns (db, data_dir, space_id, issue_id, worktree_folder_id). + async fn seed() -> (crate::db::AppDatabase, std::path::PathBuf, i32, i32, i32) { + let db = fresh_in_memory_db().await; + let folder_id = seed_folder(&db, "/tmp/loop-wt").await; + let space = space::create_space(&db.conn, "S", folder_id).await.unwrap(); + let issue = issue::create_issue( + &db.conn, + space.id, + "Fix it", + "body", + IssuePriority::Medium, + Some(&IssueConfig::default()), + ) + .await + .unwrap(); + // Point the issue at its worktree folder (same folder row is fine here — + // dispatch only reads `folder.path`). + loop_issue::Entity::update_many() + .col_expr(loop_issue::Column::WorktreeFolderId, Expr::value(folder_id)) + .filter(loop_issue::Column::Id.eq(issue.row.id)) + .exec(&db.conn) + .await + .unwrap(); + (db, std::path::PathBuf::from("/tmp/data"), space.id, issue.row.id, folder_id) + } + + fn input(space_id: i32, issue_id: i32, stage: Stage, folder_id: i32) -> DispatchInput { + DispatchInput { + space_id, + issue_id, + stage, + target_artifact_id: None, + slot_no: None, + attempt: 0, + agent_type: AgentType::ClaudeCode, + mode_id: None, + config_values: Default::default(), + worktree_folder_id: folder_id, + } + } + + async fn count_loop_conversations(db: &crate::db::AppDatabase) -> usize { + use crate::db::entities::conversation; + conversation::Entity::find() + .filter(conversation::Column::Kind.eq(ConversationKind::Loop)) + .all(&db.conn) + .await + .unwrap() + .len() + } + + #[tokio::test] + async fn dispatch_claims_creates_conversation_and_runs() { + let (db, data_dir, space_id, issue_id, folder_id) = seed().await; + let spawner = StubSpawner::default(); + + let handle = dispatch_iteration( + &db, + &data_dir, + &spawner, + EventEmitter::Noop, + input(space_id, issue_id, Stage::Triage, folder_id), + ) + .await + .unwrap() + .expect("a fresh lease is claimed"); + + // The lease is running, linked to a kind=loop conversation. + let iter = iteration::get_iteration(&db.conn, handle.iteration_id) + .await + .unwrap() + .unwrap(); + assert_eq!(iter.status, IterationStatus::Running); + assert_eq!(iter.conversation_id, Some(handle.conversation_id)); + assert!(iter.started_at.is_some()); + assert!(iter.context_manifest.is_some(), "briefing manifest stashed"); + assert_eq!(count_loop_conversations(&db).await, 1); + + // The spawn received the minted capability token (not a guessable id), + // and the briefing was delivered to the right conversation. + let calls = spawner.calls.lock().await; + assert_eq!(calls.spawned.len(), 1); + assert_eq!(calls.spawned[0].0, AgentType::ClaudeCode); + assert_eq!(calls.spawned[0].1, "/tmp/loop-wt"); + assert_eq!(calls.spawned[0].2, handle.capability_token); + assert_eq!(calls.spawned[0].2, iter.capability_token); + assert_eq!(calls.prompts.len(), 1); + assert_eq!(calls.prompts[0].0, handle.connection_id); + assert_eq!(calls.prompts[0].3, handle.conversation_id); + assert!(!calls.prompts[0].1.is_empty(), "briefing text is non-empty"); + assert!(calls.disconnects.is_empty()); + } + + #[tokio::test] + async fn dispatch_lost_lease_returns_none_without_side_effects() { + let (db, data_dir, space_id, issue_id, folder_id) = seed().await; + + // A node to advance. The `uniq_active_node(target, stage)` lease guards + // by (target, stage), so we dispatch against a real target — a NULL + // target wouldn't collide (SQLite treats NULLs as distinct). + let target = artifact::create_artifact( + &db.conn, + space_id, + issue_id, + ArtifactKind::Requirement, + "R", + ArtifactStatus::Done, + ActorKind::Agent, + None, + ) + .await + .unwrap(); + + // Pre-claim the same (target, stage) lease so the dispatch loses the race. + try_claim_iteration( + &db.conn, + IterationClaim { + space_id, + issue_id, + stage: Stage::Design, + target_artifact_id: Some(target.id), + slot_no: None, + capability_token: "held".into(), + attempt: 0, + }, + ) + .await + .unwrap() + .expect("first claim wins"); + + let mut lost = input(space_id, issue_id, Stage::Design, folder_id); + lost.target_artifact_id = Some(target.id); + + let spawner = StubSpawner::default(); + let result = dispatch_iteration(&db, &data_dir, &spawner, EventEmitter::Noop, lost) + .await + .unwrap(); + + assert!(result.is_none(), "lost race returns None"); + assert_eq!(count_loop_conversations(&db).await, 0, "no orphan conversation"); + assert!(spawner.calls.lock().await.spawned.is_empty(), "never spawned"); + } + + #[tokio::test] + async fn dispatch_spawn_failure_marks_failed_and_files_inbox() { + let (db, data_dir, space_id, issue_id, folder_id) = seed().await; + let spawner = StubSpawner { + fail_spawn: true, + ..Default::default() + }; + + let err = dispatch_iteration( + &db, + &data_dir, + &spawner, + EventEmitter::Noop, + input(space_id, issue_id, Stage::Triage, folder_id), + ) + .await; + assert!(err.is_err(), "spawn failure propagates"); + + // The claimed lease is marked failed + ended, and a blocked inbox item + // surfaces the stall. + use crate::db::entities::loop_iteration as li; + let iters = li::Entity::find() + .filter(li::Column::IssueId.eq(issue_id)) + .all(&db.conn) + .await + .unwrap(); + assert_eq!(iters.len(), 1); + assert_eq!(iters[0].status, IterationStatus::Failed); + assert!(iters[0].ended_at.is_some()); + + let items = inbox::list_inbox(&db.conn, space_id, Some(InboxStatus::Pending)) + .await + .unwrap(); + assert_eq!(items.len(), 1); + assert_eq!(items[0].kind, InboxKind::Blocked); + } + + #[tokio::test] + async fn dispatch_prompt_failure_disconnects_the_connection() { + let (db, data_dir, space_id, issue_id, folder_id) = seed().await; + let spawner = StubSpawner { + fail_prompt: true, + ..Default::default() + }; + + let err = dispatch_iteration( + &db, + &data_dir, + &spawner, + EventEmitter::Noop, + input(space_id, issue_id, Stage::Triage, folder_id), + ) + .await; + assert!(err.is_err()); + // The spawn succeeded but the prompt failed → the live connection is torn + // down rather than leaked. + assert_eq!( + spawner.calls.lock().await.disconnects, + vec!["loop-conn-1".to_string()] + ); + } + + #[tokio::test] + async fn settle_with_produced_artifact_succeeds() { + let (db, _data_dir, space_id, issue_id, _folder_id) = seed().await; + // A running iteration that produced a requirement artifact. + let iter = try_claim_iteration( + &db.conn, + IterationClaim { + space_id, + issue_id, + stage: Stage::Triage, + target_artifact_id: None, + slot_no: None, + capability_token: "t".into(), + attempt: 0, + }, + ) + .await + .unwrap() + .unwrap(); + cas_iteration_status( + &db.conn, + iter.id, + IterationStatus::Queued, + IterationStatus::Running, + ) + .await + .unwrap(); + let produced = artifact::create_artifact( + &db.conn, + space_id, + issue_id, + ArtifactKind::Requirement, + "R1", + ArtifactStatus::Done, + ActorKind::Agent, + Some(iter.id), + ) + .await + .unwrap(); + + let outcome = settle_iteration(&db, &EventEmitter::Noop, iter.id).await.unwrap(); + assert!(outcome.made_progress); + assert_eq!(outcome.produced_artifact_ids, vec![produced.id]); + + let settled = iteration::get_iteration(&db.conn, iter.id) + .await + .unwrap() + .unwrap(); + assert_eq!(settled.status, IterationStatus::Succeeded); + assert!(settled.ended_at.is_some()); + // D11: a read stage that produced its artifact records `succeeded`. + assert_eq!(settled.outcome, Some(IterationOutcome::Succeeded)); + } + + #[tokio::test] + async fn double_settle_bumps_node_attempt_exactly_once() { + let (db, _data_dir, space_id, issue_id, _f) = seed().await; + let target = artifact::create_artifact(&db.conn, space_id, issue_id, ArtifactKind::Requirement, "R", ArtifactStatus::Done, ActorKind::Agent, None) + .await + .unwrap(); + let iter = try_claim_iteration( + &db.conn, + IterationClaim { + space_id, + issue_id, + stage: Stage::Design, + target_artifact_id: Some(target.id), + slot_no: None, + capability_token: "t".into(), + attempt: 0, + }, + ) + .await + .unwrap() + .unwrap(); + cas_iteration_status(&db.conn, iter.id, IterationStatus::Queued, IterationStatus::Running) + .await + .unwrap(); + + // Two settles race; only the CAS winner mutates (a Design iteration that + // produced nothing bumps its target's rework counter — exactly once). + let a = settle_iteration(&db, &EventEmitter::Noop, iter.id); + let b = settle_iteration(&db, &EventEmitter::Noop, iter.id); + let (_ra, _rb) = tokio::join!(a, b); + + let node = loop_artifact::Entity::find_by_id(target.id) + .one(&db.conn) + .await + .unwrap() + .unwrap(); + assert_eq!(node.attempt, 1, "no-progress bump applied exactly once across a double settle"); + } + + #[tokio::test] + async fn settle_marks_tokens_pending_when_session_unreadable() { + let (db, _d, space_id, issue_id, _f) = seed().await; + let iter = try_claim_iteration( + &db.conn, + IterationClaim { + space_id, + issue_id, + stage: Stage::Triage, + target_artifact_id: None, + slot_no: None, + capability_token: "t".into(), + attempt: 0, + }, + ) + .await + .unwrap() + .unwrap(); + // Link a non-existent conversation id so the session read fails every retry. + loop_iteration::Entity::update_many() + .col_expr(loop_iteration::Column::ConversationId, Expr::value(999_999)) + .filter(loop_iteration::Column::Id.eq(iter.id)) + .exec(&db.conn) + .await + .unwrap(); + cas_iteration_status(&db.conn, iter.id, IterationStatus::Queued, IterationStatus::Running) + .await + .unwrap(); + + settle_iteration(&db, &EventEmitter::Noop, iter.id).await.unwrap(); + let settled = iteration::get_iteration(&db.conn, iter.id).await.unwrap().unwrap(); + assert_eq!(settled.status, IterationStatus::Succeeded); + assert!(settled.tokens_pending, "unreadable usage left pending, not charged 0"); + let issue = loop_issue::Entity::find_by_id(issue_id).one(&db.conn).await.unwrap().unwrap(); + assert_eq!(issue.token_used, 0, "no phantom 0-charge"); + } + + #[tokio::test] + async fn settle_without_artifact_bumps_node_attempt() { + let (db, _data_dir, space_id, issue_id, _folder_id) = seed().await; + // An artifact-producing stage (implement is exempt — its progress is the + // worktree checkpoint, exercised in the gates tests). A design iteration + // that produces nothing bumps its target requirement's rework counter. + let target = artifact::create_artifact( + &db.conn, + space_id, + issue_id, + ArtifactKind::Requirement, + "R1", + ArtifactStatus::Done, + ActorKind::Agent, + None, + ) + .await + .unwrap(); + let iter = try_claim_iteration( + &db.conn, + IterationClaim { + space_id, + issue_id, + stage: Stage::Design, + target_artifact_id: Some(target.id), + slot_no: None, + capability_token: "t".into(), + attempt: 0, + }, + ) + .await + .unwrap() + .unwrap(); + cas_iteration_status( + &db.conn, + iter.id, + IterationStatus::Queued, + IterationStatus::Running, + ) + .await + .unwrap(); + + let outcome = settle_iteration(&db, &EventEmitter::Noop, iter.id).await.unwrap(); + assert!(!outcome.made_progress); + assert!(outcome.produced_artifact_ids.is_empty()); + + let node = loop_artifact::Entity::find_by_id(target.id) + .one(&db.conn) + .await + .unwrap() + .unwrap(); + assert_eq!(node.attempt, 1, "node rework counter bumped"); + assert_eq!(node.last_failure_sig.as_deref(), Some("no_artifacts:design")); + } +} diff --git a/src-tauri/src/loop_engine/driver.rs b/src-tauri/src/loop_engine/driver.rs new file mode 100644 index 0000000000..8ac8326348 --- /dev/null +++ b/src-tauri/src/loop_engine/driver.rs @@ -0,0 +1,2444 @@ +//! Per-issue driver: the autonomous tick loop (§4.2) plus the pure frontier +//! computation that decides what to dispatch next. +//! +//! A driver is one tokio task per `running` issue. It is event-driven, not a +//! poller: each tick computes the ready frontier, dispatches it (idempotently, +//! guarded by the §4.1a DB leases), then parks on a per-issue `Notify` that the +//! completion watcher fires when an iteration settles. The DB is the +//! concurrency authority; this loop is just the scheduler that turns DAG state +//! into dispatch calls. +//! +//! The driver runs the full pipeline: the read stages (triage → refine → design +//! → plan) compute their frontier here in [`ready_nodes`]; once the plan stage has +//! produced `pending` tasks the read frontier empties and the write pipeline +//! (implement → verify → review → finalize, in [`crate::loop_engine::gates`]) +//! takes over for each task. Both are dispatched from [`tick_once`]. + +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; + +use async_trait::async_trait; +use chrono::Utc; +use sea_orm::sea_query::Expr; +use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter}; +use tokio::sync::Notify; +use tokio::time::{interval, Duration, MissedTickBehavior}; +use tracing::Instrument; + +use crate::acp::manager::{ConnectionManager, TurnLiveness}; +use crate::db::entities::loop_artifact::{ArtifactKind, ArtifactStatus}; +use crate::db::entities::loop_inbox_item::InboxKind; +use crate::db::entities::loop_issue::{self, IssueRoute, IssueStatus}; +use crate::db::entities::loop_iteration::{self, IterationStatus, Stage}; +use crate::db::entities::loop_link::LinkKind; +use crate::db::service::loop_service::{artifact, coverage, inbox, link}; +use crate::db::AppDatabase; +use crate::models::agent::AgentType; +use crate::models::loops::{AgentSpec, IssueConfig, LoopArtifactRow, LoopDagView}; +use crate::web::event_bridge::EventEmitter; + +use crate::loop_engine::config_resolver::effective_config; +use crate::loop_engine::dispatch::{ + dispatch_iteration, emit_changed, settle_iteration, settle_iteration_as, DispatchInput, + LoopAgentSpawner, SettleResolution, +}; +use crate::loop_engine::error::LoopError; +use crate::loop_engine::gates; +use crate::loop_engine::transitions::{cas_issue_repark_if_wedged, cas_issue_status}; +use crate::loop_engine::LoopEngine; + +/// Liveness oracle for the reconcile backstop — implemented by `ConnectionManager` +/// in prod, stubbed in tests. Mirrors the `LoopAgentSpawner` seam so reconcile's +/// three-state handling is unit-testable without live ACP connections. +#[async_trait] +pub(crate) trait IterationLiveness { + async fn turn_state(&self, conversation_id: i32) -> TurnLiveness; +} + +#[async_trait] +impl IterationLiveness for ConnectionManager { + async fn turn_state(&self, conversation_id: i32) -> TurnLiveness { + self.connection_turn_state(conversation_id).await + } +} + +/// Result of a single tick. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum TickOutcome { + /// The issue is no longer `running`; the driver should exit. + Stop, + /// At least one iteration was dispatched this tick. + Dispatched, + /// Durable state moved forward but nothing is in flight — the driver should + /// re-tick immediately (to dispatch the follow-on step, or observe the issue + /// leaving `running` and stop) rather than park on the no-timeout wake. + Advanced, + /// Nothing to dispatch right now (frontier empty / all in-flight / lease + /// held). The driver parks until the next completion or external wake. + Idle, + /// The result is produced and `auto_merge` is on — the driver should land it + /// via the engine merge gate (which needs `&LoopEngine`, so `tick_once` only + /// signals; `run_driver` performs the merge). + AutoMerge, +} + +/// One unit of work the frontier wants dispatched. +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct FrontierItem { + pub stage: Stage, + pub target_artifact_id: Option, + pub attempt: i32, +} + +/// The issue's root artifact (`kind = issue`), seeded at issue creation. +fn root_artifact_id(dag: &LoopDagView) -> Option { + dag.artifacts + .iter() + .find(|a| a.kind == ArtifactKind::Issue) + .map(|a| a.id) +} + +/// Live artifacts of a kind — excludes `superseded` / `cancelled` nodes (e.g. a +/// rejected design) so the frontier ignores dead branches and can re-dispatch +/// the stage fresh. +fn artifacts_of_kind(dag: &LoopDagView, kind: ArtifactKind) -> Vec<&LoopArtifactRow> { + dag.artifacts + .iter() + .filter(|a| { + a.kind == kind + && !matches!( + a.status, + ArtifactStatus::Superseded | ArtifactStatus::Cancelled + ) + }) + .collect() +} + +fn all_done(rows: &[&LoopArtifactRow]) -> bool { + rows.iter().all(|a| a.status == ArtifactStatus::Done) +} + +fn node_attempt(dag: &LoopDagView, id: i32) -> i32 { + dag.artifacts + .iter() + .find(|a| a.id == id) + .map(|a| a.attempt) + .unwrap_or(0) +} + +/// Compute the next dispatch(es) for the read pipeline, per route. Pure over the +/// DAG snapshot — no I/O — so it is unit-tested directly. +/// +/// The pipeline advances one stage at a time: a stage is dispatched only when +/// its output kind is absent, and the driver waits (empty frontier) while a +/// stage's outputs exist but aren't all `done`. Routes shorten the pipeline: +/// `full` = refine→design→plan, `skip_design` = refine→plan, `direct` = plan. +/// Once tasks exist the read frontier is empty — the write pipeline takes over. +pub(crate) fn ready_nodes(dag: &LoopDagView, route: IssueRoute) -> Vec { + let Some(root) = root_artifact_id(dag) else { + return Vec::new(); + }; + + let needs_refine = matches!(route, IssueRoute::Full | IssueRoute::SkipDesign); + let needs_design = matches!(route, IssueRoute::Full); + + let reqs = artifacts_of_kind(dag, ArtifactKind::Requirement); + let designs = artifacts_of_kind(dag, ArtifactKind::Design); + let tasks = artifacts_of_kind(dag, ArtifactKind::Task); + + let one = |stage: Stage, target: i32| { + vec![FrontierItem { + stage, + target_artifact_id: Some(target), + attempt: node_attempt(dag, target), + }] + }; + + // 1. Refine → requirements (derive from the issue root). + if needs_refine { + if reqs.is_empty() { + return one(Stage::Refine, root); + } + if !all_done(&reqs) { + return Vec::new(); // refinement in flight + } + } + + // 2. Design → design. Anchored at the issue root (a stable target); the + // design's real lineage — `derives_from` edges to EVERY requirement, each + // bound to its revision — is wired by ingest's design fan-in, not by this + // single dispatch target. + if needs_design { + if designs.is_empty() { + return one(Stage::Design, root); + } + if !all_done(&designs) { + return Vec::new(); + } + } + + // 3. Plan → tasks. Target is the nearest upstream node the route reached. + if tasks.is_empty() { + let target = match route { + IssueRoute::Full => designs.last().map(|d| d.id), + IssueRoute::SkipDesign => reqs.last().map(|r| r.id), + IssueRoute::Direct | IssueRoute::Undecided => None, + } + .unwrap_or(root); + return one(Stage::Plan, target); + } + + // 4. Tasks exist → read frontier done; the write pipeline (gates) drives them. + Vec::new() +} + +/// True if the task DAG ever has ≥2 tasks simultaneously ready — i.e. real +/// concurrency. Frontier simulation; correct for FAN-OUT (A→{B,C}, which has +/// edges==n-1 yet is parallel — the naive edge-count heuristic was WRONG here) +/// and multiple roots. (v1 deps are a forest, but this stays general.) Cycles +/// are rejected at submit, so the simulation always terminates. +fn dag_has_parallelism(dag: &LoopDagView) -> bool { + let tasks: Vec = dag + .artifacts + .iter() + .filter(|a| a.kind == ArtifactKind::Task) + .map(|a| a.id) + .collect(); + if tasks.len() < 2 { + return false; + } + let preds = |t: i32| -> Vec { + dag.links + .iter() + .filter(|l| l.kind == LinkKind::DependsOn && l.from_artifact_id == t) + .map(|l| l.to_artifact_id) + .collect() + }; + let mut done: std::collections::HashSet = Default::default(); + loop { + let ready: Vec = tasks + .iter() + .copied() + .filter(|t| !done.contains(t) && preds(*t).iter().all(|p| done.contains(p))) + .collect(); + if ready.len() >= 2 { + return true; // ≥2 concurrently ready ⇒ parallel + } + if ready.is_empty() { + return false; // all settled (or only dead-pred tasks left) ⇒ serial + } + done.insert(ready[0]); + } +} + +/// Decide and persist the issue's `execution_mode` exactly once — the first tick +/// at which its task DAG exists. Returns the resolved mode (`Some` once decided, +/// `None` while there are still no tasks). +/// +/// Timing safety: a plan submission is atomic (all tasks + their `depends_on` +/// edges land in a single `submit_artifacts` call), so the moment any task is +/// present the whole task set is too — there is no half-built-DAG window. A +/// write-once conditional UPDATE (`WHERE execution_mode IS NULL`) then guarantees +/// a re-read or a racing tick can never relatch a different mode. +async fn ensure_execution_mode( + conn: &sea_orm::DatabaseConnection, + issue: &loop_issue::Model, + dag: &LoopDagView, +) -> Result, LoopError> { + // Already decided → keep it (in-memory guard before any write). + if let Some(mode) = &issue.execution_mode { + return Ok(Some(mode.clone())); + } + // No tasks yet (read stages still in flight) → nothing to decide. + if !dag.artifacts.iter().any(|a| a.kind == ArtifactKind::Task) { + return Ok(None); + } + let mode = if dag_has_parallelism(dag) { + "parallel" + } else { + "serial" + }; + // Write-once CAS: only the first writer sets it; a racing tick no-ops. + loop_issue::Entity::update_many() + .col_expr(loop_issue::Column::ExecutionMode, Expr::value(mode)) + .filter(loop_issue::Column::Id.eq(issue.id)) + .filter(loop_issue::Column::ExecutionMode.is_null()) + .exec(conn) + .await?; + // Read back the authoritative value (in case another writer won the CAS). + let resolved = loop_issue::Entity::find_by_id(issue.id) + .one(conn) + .await? + .and_then(|i| i.execution_mode); + Ok(resolved) +} + +/// Resolve the full agent spec (agent + startup mode + config) for a stage from +/// the issue's Loop Contract: the per-stage override if set, else `agents.default`. +pub(crate) fn resolve_agent_spec(config: &IssueConfig, stage: Stage) -> AgentSpec { + config.agents.for_stage(stage).clone() +} + +/// Just the agent type for a stage (e.g. to route a question to the right +/// agent). For dispatch, prefer [`resolve_agent_spec`] so the per-stage mode/ +/// config overrides are carried through. +pub(crate) fn resolve_agent(config: &IssueConfig, stage: Stage) -> AgentType { + resolve_agent_spec(config, stage).agent +} + +/// Does this issue already have a triage iteration on record (in ANY state)? +/// Triage targets the whole issue (`target = None`), so the §4.1a node lease +/// can't dedup it (SQLite treats NULL targets as distinct) — this app-level gate +/// is what stops `tick_once` from launching a *second* initial triage. +/// +/// It counts every status, not just the live/succeeded ones: once any triage +/// exists, all further (bounded) redispatch is owned by `recover_undecided_triage` +/// — never `tick_once`'s own branch. If this gate excluded `failed`/`interrupted`, +/// an abandoned triage (now settled `Failed` by the reconcile) would re-trigger +/// `tick_once`'s unbounded attempt-0 dispatch here instead of going through the +/// bounded recovery, looping forever. So the rule is simply "any triage row = +/// the slot is taken; defer to recovery". +async fn has_any_triage( + conn: &sea_orm::DatabaseConnection, + issue_id: i32, +) -> Result { + let triage = loop_iteration::Entity::find() + .filter(loop_iteration::Column::IssueId.eq(issue_id)) + .filter(loop_iteration::Column::Stage.eq(Stage::Triage)) + .all(conn) + .await?; + Ok(!triage.is_empty()) +} + +/// Record `skips_to` provenance for routes that skip stages: every task gets a +/// `skips_to` edge to the issue root marking that it bypassed the normal +/// refine/design steps. Idempotent (skips a task that already has one). +async fn ensure_skip_provenance( + db: &AppDatabase, + space_id: i32, + dag: &LoopDagView, + route: IssueRoute, +) -> Result<(), LoopError> { + if matches!(route, IssueRoute::Full | IssueRoute::Undecided) { + return Ok(()); + } + let Some(root) = root_artifact_id(dag) else { + return Ok(()); + }; + for task in artifacts_of_kind(dag, ArtifactKind::Task) { + let has_skip = dag + .links + .iter() + .any(|l| l.from_artifact_id == task.id && l.kind == LinkKind::SkipsTo); + if !has_skip { + link::create_link(&db.conn, space_id, task.id, root, LinkKind::SkipsTo, None).await?; + } + } + Ok(()) +} + +/// Keep the design-approval inbox card filed while any design sits +/// `awaiting_approval`. Idempotent (the upsert dedups), so it is safe to call +/// every tick; the card is resolved by `approve_design` / `reject_design`. +async fn ensure_design_gate_card( + db: &AppDatabase, + emitter: &EventEmitter, + issue: &loop_issue::Model, + dag: &LoopDagView, +) -> Result<(), LoopError> { + let awaiting = dag.artifacts.iter().any(|a| { + a.kind == ArtifactKind::Design && a.status == ArtifactStatus::AwaitingApproval + }); + if awaiting { + // Filed every tick while a design awaits approval; the `{gate}` payload is + // identical each time, so only the first filing is Created/changed → emit + // once (D6 real-time), no per-tick spam. + let upsert = inbox::upsert_inbox( + &db.conn, + issue.space_id, + issue.id, + None, + InboxKind::Approval, + &format!("design:{}", issue.id), + serde_json::json!({ "v": 1, "gate": "design" }), + ) + .await?; + if upsert.changed() { + emit_changed(emitter, issue.space_id, issue.id, issue.id, "approval"); + } + } + Ok(()) +} + +/// Liveness backstop (DB-authoritative): settle any of this issue's `running` +/// iterations whose turn is no longer actually in flight. The completion watcher +/// settles on `TurnComplete`, but that single in-process event can be dropped +/// (broadcast lag) or race the connection teardown — and a finished loop +/// connection stays *alive and idle* (it is never disconnected on turn complete), +/// so a check keyed on connection *existence* alone would never settle it. We +/// therefore inspect the turn's three-state liveness: +/// +/// - `Missing` (no live connection) → abandon: settle `Failed` (the run died +/// with no completed turn; never faked as success). Bounded by `max_attempts`. +/// - `Idle` (connection alive, no turn in flight) → the turn finished but its +/// settle event was missed → settle `Succeeded` (the normal completion result). +/// - `InFlight` (a turn is genuinely running) → leave it; killing live work is +/// the operator's call (surfaced via opt-in stall alerts), never a timer here. +/// +/// Idempotent: `settle_iteration`/`settle_iteration_as` are CAS, so a double +/// settle (event + reconcile) is a no-op the second time. +pub(crate) async fn reconcile_orphaned_iterations( + db: &AppDatabase, + emitter: &EventEmitter, + liveness: &L, + issue_id: i32, +) -> Result<(), LoopError> { + let running = loop_iteration::Entity::find() + .filter(loop_iteration::Column::IssueId.eq(issue_id)) + .filter(loop_iteration::Column::Status.eq(IterationStatus::Running)) + .all(&db.conn) + .await?; + if running.is_empty() { + return Ok(()); + } + // Opt-in stall watchdog threshold (None = off; the common case skips the + // config read entirely once there is nothing running anyway, handled above). + let stall_alert_secs = match loop_issue::Entity::find_by_id(issue_id).one(&db.conn).await? { + Some(issue) => effective_config(&db.conn, &issue).await?.stall_alert_secs, + None => None, + }; + for it in running { + let Some(cid) = it.conversation_id else { + continue; + }; + match liveness.turn_state(cid).await { + TurnLiveness::InFlight => { + // Genuinely working — never disturbed here. If the operator armed + // the opt-in watchdog, surface a (idempotent) stall card so they + // can decide; the iteration itself is left untouched. + if let Some(threshold) = stall_alert_secs { + if let Err(e) = maybe_file_stall_alert(db, emitter, &it, threshold).await { + tracing::warn!(iteration_id = it.id, error = %e, "reconcile: stall alert failed"); + } + } + } + TurnLiveness::Idle => { + tracing::debug!( + iteration_id = it.id, + issue_id, + conv = cid, + "reconcile: settling idle-but-unsettled iteration (turn finished, event missed)" + ); + if let Err(e) = settle_iteration(db, emitter, it.id).await { + tracing::warn!(iteration_id = it.id, error = %e, "reconcile: settle failed"); + } + } + TurnLiveness::Missing => { + tracing::warn!( + iteration_id = it.id, + issue_id, + conv = cid, + "reconcile: abandoning orphaned iteration (no live connection)" + ); + if let Err(e) = + settle_iteration_as(db, emitter, it.id, SettleResolution::Abandoned).await + { + tracing::warn!(iteration_id = it.id, error = %e, "reconcile: abandon failed"); + } + } + } + } + Ok(()) +} + +/// Opt-in stall watchdog: when an in-flight iteration has been running at least +/// `threshold_secs` (measured from `started_at`), file an idempotent `stalled:{id}` +/// inbox card so the human can decide whether to step in. Surface-only — it never +/// settles or kills the iteration. A long turn is not necessarily a dead one, and +/// "no artificial limits" means this timer reports, never enforces. The card +/// dedups on `(issue, kind, stalled:{id})`, so re-running every reconcile tick is +/// a no-op once the card is filed. +async fn maybe_file_stall_alert( + db: &AppDatabase, + emitter: &EventEmitter, + iter: &loop_iteration::Model, + threshold_secs: u64, +) -> Result<(), LoopError> { + let Some(started) = iter.started_at else { + return Ok(()); // not actually started yet — nothing to time + }; + let elapsed = (Utc::now() - started).num_seconds(); + if elapsed < threshold_secs as i64 { + return Ok(()); + } + inbox::upsert_inbox( + &db.conn, + iter.space_id, + iter.issue_id, + Some(iter.id), + InboxKind::Blocked, + &format!("stalled:{}", iter.id), + serde_json::json!({ + "v": 1, + "reason": "stalled", + "stage": iter.stage, + "elapsed_secs": elapsed, + "threshold_secs": threshold_secs, + }), + ) + .await?; + emit_changed(emitter, iter.space_id, iter.issue_id, iter.id, "stalled"); + Ok(()) +} + +/// Coverage loop-back (spec §3.3). When a plan leaves some requirement +/// acceptance criterion unclaimed by any live task, supersede the under-covering +/// tasks so the read frontier re-emits Plan (whose briefing then carries the +/// gap) — a bounded feedback edge, not a dead end. Bounded by `max_attempts` +/// (0 = unlimited): on exhaustion, block the issue and file a `coverage_gap` +/// card for a human (raise the cap / fix the requirements / retry). +/// +/// Returns `Some(Advanced)` when it acted (caller returns it and re-ticks), +/// `None` when coverage is complete so the caller proceeds to the write pipeline. +/// +/// The transient replan does NOT file an inbox card: it is the engine converging +/// as designed, not a state needing human action, and the churn is already +/// visible via `emit_changed` (superseded tasks + a fresh plan attempt). Only the +/// terminal blocked state is an inbox item. +async fn maybe_coverage_loopback( + db: &AppDatabase, + emitter: &EventEmitter, + issue: &loop_issue::Model, + config: &IssueConfig, + dag: &LoopDagView, +) -> Result, LoopError> { + let conn = &db.conn; + + // Live (non-superseded/cancelled) task rows. If none exist the read frontier + // would have re-emitted Plan, so there is nothing to gate here. + let live_tasks: Vec<&LoopArtifactRow> = dag + .artifacts + .iter() + .filter(|a| { + a.kind == ArtifactKind::Task + && !matches!(a.status, ArtifactStatus::Superseded | ArtifactStatus::Cancelled) + }) + .collect(); + if live_tasks.is_empty() { + return Ok(None); + } + let live_ids: std::collections::HashSet = live_tasks.iter().map(|a| a.id).collect(); + + let ordinals = coverage::acceptance_ordinals_for_issue(conn, issue.id).await?; + let uncovered = coverage::uncovered_ordinals(&ordinals, &dag.coverage, &live_ids); + if uncovered.is_empty() { + return Ok(None); // every acceptance criterion is covered → proceed + } + + // The gate runs BEFORE any implement, so a clean replan supersedes the + // still-`pending` plan output. Bound the loop by the rework cap (every plan + // dispatch is one plan iteration on record). + let pending: Vec = live_tasks + .iter() + .filter(|a| a.status == ArtifactStatus::Pending) + .map(|a| a.id) + .collect(); + let plan_attempts = loop_iteration::Entity::find() + .filter(loop_iteration::Column::IssueId.eq(issue.id)) + .filter(loop_iteration::Column::Stage.eq(Stage::Plan)) + .count(conn) + .await? as u32; + let exhausted = config.max_attempts != 0 && plan_attempts >= config.max_attempts; + // If a task already advanced past `pending` while a gap remains (shouldn't + // happen — coverage is monotonic for a fixed task set — but defend against + // it), superseding the pending subset would NOT clear the read frontier, so + // auto-replan can't make progress. Block instead of spinning on `Advanced`. + let can_replan = pending.len() == live_tasks.len(); + + if exhausted || !can_replan { + cas_issue_status(conn, issue.id, IssueStatus::Running, IssueStatus::Blocked).await?; + let reason = if exhausted { + "coverage_gap_exhausted" + } else { + "coverage_gap_unresolvable" + }; + inbox::upsert_inbox( + conn, + issue.space_id, + issue.id, + None, + InboxKind::Blocked, + &format!("coverage_gap:{}", issue.id), + serde_json::json!({ + "v": 1, + "reason": reason, + "uncovered": uncovered, + "plan_attempts": plan_attempts, + }), + ) + .await?; + emit_changed(emitter, issue.space_id, issue.id, issue.id, "blocked"); + return Ok(Some(TickOutcome::Advanced)); + } + + // All live tasks are still pending → supersede them so the read frontier + // re-emits Plan next tick (the briefing then carries the gap). + for &tid in &pending { + crate::loop_engine::transitions::cas_artifact_status_from( + conn, + tid, + &[ArtifactStatus::Pending], + ArtifactStatus::Superseded, + ) + .await?; + } + tracing::info!( + issue_id = issue.id, + plan_attempts, + uncovered = ?uncovered, + "coverage gap: superseding tasks and replanning" + ); + emit_changed(emitter, issue.space_id, issue.id, issue.id, "issue"); + Ok(Some(TickOutcome::Advanced)) +} + +/// D12: a human-rejected design supersedes it, and the read frontier re-emits +/// Design next tick (a bounded feedback edge, like the coverage loop-back). Bound +/// it by the count of Design iterations vs `max_attempts` (0 = unlimited): on +/// exhaustion, block the issue and file a `design_rejected` card rather than +/// re-dispatching forever. Returns `Some(Advanced)` only on exhaustion; `None` +/// otherwise — letting the frontier re-emit Design for the next bounded attempt. +/// +/// Like the coverage loop-back, the transient re-emit files no card (the engine is +/// converging as designed); only the terminal blocked state is an inbox item. +async fn maybe_design_reject_exhausted( + db: &AppDatabase, + emitter: &EventEmitter, + issue: &loop_issue::Model, + config: &IssueConfig, + dag: &LoopDagView, +) -> Result, LoopError> { + let conn = &db.conn; + // Only relevant once a design was rejected (superseded) and none is live — i.e. + // the frontier is about to re-emit Design. + let rejected = dag + .artifacts + .iter() + .any(|a| a.kind == ArtifactKind::Design && a.status == ArtifactStatus::Superseded); + let live = dag.artifacts.iter().any(|a| { + a.kind == ArtifactKind::Design + && !matches!(a.status, ArtifactStatus::Superseded | ArtifactStatus::Cancelled) + }); + if !rejected || live { + return Ok(None); + } + + // Each Design dispatch is one Design iteration on record. + let design_attempts = loop_iteration::Entity::find() + .filter(loop_iteration::Column::IssueId.eq(issue.id)) + .filter(loop_iteration::Column::Stage.eq(Stage::Design)) + .count(conn) + .await? as u32; + if config.max_attempts != 0 && design_attempts >= config.max_attempts { + cas_issue_status(conn, issue.id, IssueStatus::Running, IssueStatus::Blocked).await?; + inbox::upsert_inbox( + conn, + issue.space_id, + issue.id, + None, + InboxKind::Blocked, + &format!("design_rejected:{}", issue.id), + serde_json::json!({ + "v": 1, + "reason": "design_rejected_exhausted", + "design_attempts": design_attempts, + }), + ) + .await?; + emit_changed(emitter, issue.space_id, issue.id, issue.id, "blocked"); + return Ok(Some(TickOutcome::Advanced)); + } + Ok(None) +} + +/// One scheduling tick for a single issue: ensure triage, then dispatch the +/// ready frontier. Idempotent and side-effect-guarded by the DB leases, so it +/// is safe to call repeatedly. Takes explicit handles (not `&LoopEngine`) so it +/// is testable with just a database + a stub spawner. +pub(crate) async fn tick_once( + db: &AppDatabase, + data_dir: &Path, + spawner: &dyn LoopAgentSpawner, + emitter: &EventEmitter, + issue_id: i32, + infra_retries: &mut HashMap, +) -> Result { + let conn = &db.conn; + let mut issue = loop_issue::Entity::find_by_id(issue_id) + .one(conn) + .await? + .ok_or_else(|| LoopError::NotFound(format!("issue {issue_id}")))?; + if issue.status != IssueStatus::Running { + return Ok(TickOutcome::Stop); + } + + let config = effective_config(conn, &issue).await?; + + let Some(worktree_folder_id) = issue.worktree_folder_id else { + // No worktree yet (trigger sets it up before starting the driver). Can't + // make progress; idle until a wake. + tracing::debug!(issue_id, "driver: issue has no worktree folder; idling"); + return Ok(TickOutcome::Idle); + }; + + // Triage first: it decides the route the rest of the pipeline follows. This + // branch dispatches only the *initial* triage (none on record yet); every + // retry afterwards is owned by `recover_undecided_triage`, which bounds it by + // `max_attempts`. + if !has_any_triage(conn, issue_id).await? { + let spec = resolve_agent_spec(&config, Stage::Triage); + let dispatched = dispatch_iteration( + db, + data_dir, + spawner, + emitter.clone(), + DispatchInput { + space_id: issue.space_id, + issue_id, + stage: Stage::Triage, + target_artifact_id: None, + slot_no: None, + attempt: 0, + agent_type: spec.agent, + mode_id: spec.mode_id, + config_values: spec.config_values, + worktree_folder_id, + }, + ) + .await?; + return Ok(if dispatched.is_some() { + TickOutcome::Dispatched + } else { + TickOutcome::Idle + }); + } + + // Route is written by triage; honor a human force_route override. While the + // route is still undecided, recover instead of parking forever: wait if a + // triage is in flight, else re-dispatch (bounded) or block. + let route = match issue.route { + IssueRoute::Undecided => match config.force_route { + Some(r) => r, + None => { + return recover_undecided_triage( + db, + data_dir, + spawner, + emitter, + &issue, + &config, + worktree_folder_id, + ) + .await; + } + }, + r => r, + }; + + let dag = artifact::list_dag(conn, issue_id).await?; + ensure_skip_provenance(db, issue.space_id, &dag, route).await?; + // Design approval gate (route=full): while a produced design awaits human + // approval, keep its inbox card filed; the read frontier idles until approved. + ensure_design_gate_card(db, emitter, &issue, &dag).await?; + + // D12: a rejected design re-emits Design via the frontier below; bound that + // loop-back so endless rejections terminate at `block + design_rejected` + // instead of re-dispatching forever (route=full only — other routes have no + // design stage). + if route == IssueRoute::Full { + if let Some(outcome) = + maybe_design_reject_exhausted(db, emitter, &issue, &config, &dag).await? + { + return Ok(outcome); + } + } + + // Read pipeline first (triage → refine → design → plan). While it has work, + // the write pipeline waits. + let frontier = ready_nodes(&dag, route); + if !frontier.is_empty() { + let mut dispatched_any = false; + for item in frontier { + let spec = resolve_agent_spec(&config, item.stage); + let handle = dispatch_iteration( + db, + data_dir, + spawner, + emitter.clone(), + DispatchInput { + space_id: issue.space_id, + issue_id, + stage: item.stage, + target_artifact_id: item.target_artifact_id, + slot_no: None, + attempt: item.attempt, + agent_type: spec.agent, + mode_id: spec.mode_id, + config_values: spec.config_values, + worktree_folder_id, + }, + ) + .await?; + if handle.is_some() { + dispatched_any = true; + } + } + return Ok(if dispatched_any { + TickOutcome::Dispatched + } else { + TickOutcome::Idle + }); + } + + // Coverage gate (spec §3.3): on a route that produces requirements, a plan + // that leaves any requirement acceptance criterion unclaimed is incomplete — + // implementing it would let a verifier pass a partial solution (Goodhart). + // Supersede the under-covering tasks and replan (bounded by `max_attempts`), + // before latching the execution mode or driving the write pipeline. Runs only + // once live tasks exist (otherwise the read frontier above re-emits Plan). + if matches!(route, IssueRoute::Full | IssueRoute::SkipDesign) { + if let Some(outcome) = maybe_coverage_loopback(db, emitter, &issue, &config, &dag).await? { + return Ok(outcome); + } + } + + // Decide the issue's execution mode once the task DAG exists (write-once; + // no-op while read stages are still in flight), and apply the authoritative + // value to the in-memory issue for THIS tick. The write pipeline below picks + // each task's worktree by it (parallel → per-task tree; else the shared issue + // tree) and fans out parallel tasks; a stale in-memory `None` would drive the + // first task of a freshly decided parallel issue into the wrong (shared) tree + // and only fan out from the next tick. + if let Some(mode) = ensure_execution_mode(conn, &issue, &dag).await? { + issue.execution_mode = Some(mode); + } + + // Read pipeline complete (tasks exist) → drive the write pipeline. A no-op + // when there are no tasks yet (read stages still in flight), so it is safe + // to call on every "frontier empty" tick. + match gates::drive_active_task( + db, + data_dir, + spawner, + emitter, + &issue, + &dag, + &config, + worktree_folder_id, + infra_retries, + ) + .await? + { + gates::StepOutcome::Dispatched => return Ok(TickOutcome::Dispatched), + gates::StepOutcome::Advanced => return Ok(TickOutcome::Advanced), + gates::StepOutcome::Idle => {} + } + + // Write pipeline drained → finalize when every task is done (produce the + // result artifact). A no-op until then. + match gates::run_finalize( + db, + data_dir, + spawner, + emitter, + &issue, + &dag, + &config, + worktree_folder_id, + ) + .await? + { + gates::StepOutcome::Dispatched => return Ok(TickOutcome::Dispatched), + gates::StepOutcome::Advanced => return Ok(TickOutcome::Advanced), + gates::StepOutcome::Idle => {} + } + + // Result produced AND integration-verified → merge gate. With `auto_merge` on, + // signal the driver to land it (the merge needs `&LoopEngine`). Gating on + // `integration_passed` (not just "a result exists") means auto-merge fires only + // after the whole-issue closure is verified — and never on a superseded result a + // loop-back left behind. Otherwise idle: the human gate awaits approve_merge. + if config.auto_merge && gates::integration_passed(&db.conn, &dag).await? { + return Ok(TickOutcome::AutoMerge); + } + + // Global re-park invariant (D13/r4 I2): we reached the end of the tick — read + // frontier empty, write pipeline + finalize idle, auto-merge did not fire. If + // the issue is genuinely wedged (atomic fresh check: a blocked task, nothing + // pending/in_progress, no in-flight iteration), park it `blocked` so a human + // exit (retry / override / force-complete — all require a blocked issue) can + // reach it, instead of parking `running` with no completion path. The single + // conditional UPDATE closes the race with a concurrent exit (a re-armed task or + // a completed last-blocked one makes the WHERE false). On re-park, re-tick: the + // top-of-tick guard then sees `blocked` and Stops cleanly. + if cas_issue_repark_if_wedged(conn, issue_id).await? { + emit_changed(emitter, issue.space_id, issue.id, issue.id, "blocked"); + return Ok(TickOutcome::Advanced); + } + Ok(TickOutcome::Idle) +} + +/// Recover a triage that finished without producing a route. Triage decides the +/// pipeline's route; if its agent's turn ended without `loop_submit_route`, the +/// issue would otherwise idle forever on `route = undecided`. While a triage is +/// still in flight we keep waiting; once all triage iterations have settled and +/// the route is still undecided we re-dispatch a fresh triage (bounded by +/// `max_attempts`, 0 = unlimited), and give up into `blocked` + an inbox card +/// once the bound is hit. Never parks silently. +#[allow(clippy::too_many_arguments)] +async fn recover_undecided_triage( + db: &AppDatabase, + data_dir: &Path, + spawner: &dyn LoopAgentSpawner, + emitter: &EventEmitter, + issue: &loop_issue::Model, + config: &IssueConfig, + worktree_folder_id: i32, +) -> Result { + let conn = &db.conn; + let triage: Vec = loop_iteration::Entity::find() + .filter(loop_iteration::Column::IssueId.eq(issue.id)) + .filter(loop_iteration::Column::Stage.eq(Stage::Triage)) + .all(conn) + .await?; + // Still deciding → keep waiting. + if triage + .iter() + .any(|it| matches!(it.status, IterationStatus::Queued | IterationStatus::Running)) + { + return Ok(TickOutcome::Idle); + } + // All triage settled but no route. Bounded recovery. + let attempts = triage.len() as i32; + let max = config.max_attempts as i32; // 0 = unlimited + if max == 0 || attempts < max { + tracing::debug!( + issue_id = issue.id, + attempts, + "triage: undecided; re-dispatching" + ); + let spec = resolve_agent_spec(config, Stage::Triage); + let dispatched = dispatch_iteration( + db, + data_dir, + spawner, + emitter.clone(), + DispatchInput { + space_id: issue.space_id, + issue_id: issue.id, + stage: Stage::Triage, + target_artifact_id: None, + slot_no: None, + attempt: attempts, + agent_type: spec.agent, + mode_id: spec.mode_id, + config_values: spec.config_values, + worktree_folder_id, + }, + ) + .await?; + return Ok(if dispatched.is_some() { + TickOutcome::Dispatched + } else { + TickOutcome::Idle + }); + } + // Bound hit → block + inbox card (the human can retry or cancel). + tracing::warn!( + issue_id = issue.id, + attempts, + "triage: gave up with no route; blocking" + ); + cas_issue_status(conn, issue.id, IssueStatus::Running, IssueStatus::Blocked).await?; + inbox::upsert_inbox( + conn, + issue.space_id, + issue.id, + None, + InboxKind::Blocked, + &format!("triage_no_route:{}", issue.id), + serde_json::json!({ + "v": 1, + "reason": "triage produced no route", + "attempts": attempts, + }), + ) + .await?; + emit_changed(emitter, issue.space_id, issue.id, issue.id, "blocked"); + // Issue is now blocked → re-tick so the driver observes it and stops cleanly + // (a human retry then respawns the driver). + Ok(TickOutcome::Advanced) +} + +/// Backstop cadence for the liveness reconcile. The happy path is event-driven +/// (turn-complete → settle → wake); a `Lagged` burst is swept immediately by the +/// completion watcher. This heartbeat is only a coarse net for a missed wake, and +/// is armed ONLY while the issue has in-flight iterations — an idle driver parks +/// on `wake` alone and issues no periodic query. +const RECONCILE_INTERVAL: Duration = Duration::from_secs(15); + +/// Diagnostic-only ceiling on *consecutive* `Advanced` re-ticks. The write +/// pipeline is strictly forward-moving, so a correct engine converges in a few +/// ticks; crossing this only signals a logic bug (a gate reporting `Advanced` +/// with no durable progress). It logs — it never caps real work (honoring the +/// "no artificial limits" rule). +const ADVANCE_DIAG_THRESHOLD: u32 = 1000; + +/// The per-issue driver task body: tick, then park on the wake `Notify` until a +/// completion (or external nudge) arrives. Exits when the issue leaves +/// `running`, deregistering itself from the engine's driver registry. +pub(crate) async fn run_driver(engine: Arc, issue_id: i32, wake: Arc) { + // Periodic liveness heartbeat: re-tick even without a wake so the reconcile + // below catches iterations whose turn-complete event was missed or raced. + let mut heartbeat = interval(RECONCILE_INTERVAL); + heartbeat.set_missed_tick_behavior(MissedTickBehavior::Delay); + heartbeat.tick().await; // consume the immediate first fire + // Counts consecutive `Advanced` re-ticks for the diagnostic above; reset on + // any tick that parks or breaks. + let mut consecutive_advances: u32 = 0; + // Per-task infrastructure-failure streaks (worktree-ensure failures), in driver + // memory. While non-empty, the park below arms the heartbeat so the driver + // re-ticks to retry even when nothing is in flight. Reset/pruned per tick by + // `drive_active_task`; a streak crossing INFRA_RETRY_MAX blocks the task. + let mut infra_retries: HashMap = HashMap::new(); + loop { + // DB-authoritative backstop before each tick: settle iterations whose + // agent connection is gone (the event-driven settle alone can wedge). + if let Err(e) = + reconcile_orphaned_iterations(&engine.db, &engine.emitter, &engine.manager, issue_id) + .await + { + tracing::warn!(issue_id, error = %e, "driver: reconcile failed"); + } + // §2.7 backfill: re-read and charge any iterations whose token total was + // left pending (session file wasn't flushed at settle time). Cheap — + // filtered by (issue_id, tokens_pending) on the new composite index. + if let Err(e) = crate::loop_engine::dispatch::reconcile_pending_tokens( + &engine.db, + &engine.emitter, + issue_id, + ) + .await + { + tracing::warn!(issue_id, error = %e, "driver: pending-token reconcile failed"); + } + match tick_once( + &engine.db, + &engine.data_dir, + &engine.manager, + &engine.emitter, + issue_id, + &mut infra_retries, + ) + .instrument(tracing::info_span!("loop_tick", issue_id)) + .await + { + Ok(TickOutcome::Stop) => break, + Ok(TickOutcome::Advanced) => { + // Durable progress with nothing in flight: re-tick now to dispatch + // the follow-on step, or observe a block and stop — instead of + // parking on the no-timeout wake (the wedge that used to need a + // manual pause→resume, and that left human retries ineffective). + // `yield_now` keeps the re-tick cooperative rather than a hot loop. + consecutive_advances += 1; + if consecutive_advances == ADVANCE_DIAG_THRESHOLD { + tracing::warn!( + issue_id, + consecutive_advances, + "driver: unusually long advance chain; possible non-progressing Advanced" + ); + } + tokio::task::yield_now().await; + continue; + } + Ok(TickOutcome::AutoMerge) => { + // Land the finalized work without a human gate. On success, only + // re-tick immediately if the merge actually advanced the issue out + // of `running` (→ Done, or → Blocked on a merge fault); the next + // tick then observes that state and stops. If it returned Ok yet + // left the issue `running` (a lost-CAS race, or a future merge + // variant that defers), DON'T `continue` — that would re-attempt + // the same merge every tick with no wait. Fall through to park + // instead. On error, park too (a later wake retries). + match engine.merge_issue(issue_id).await { + Ok(()) => { + let still_running = loop_issue::Entity::find_by_id(issue_id) + .one(&engine.db.conn) + .await + .ok() + .flatten() + .is_some_and(|i| i.status == IssueStatus::Running); + if !still_running { + continue; // advanced (or gone) → re-tick to stop + } + tracing::warn!( + issue_id, + "driver: auto-merge returned Ok but issue still running; parking instead of busy-looping" + ); + } + Err(e) => { + tracing::warn!(issue_id, error = %e, "driver: auto-merge failed"); + } + } + } + Ok(_) => {} + Err(e) => { + tracing::warn!(issue_id, error = %e, "driver: tick failed"); + } + } + // A tick that parks (or errs) ends any advance chain. + consecutive_advances = 0; + // Park until an iteration settles (the completion watcher fires `wake`) + // or — while work is in flight OR an infra-retry is pending — the periodic + // heartbeat elapses (which runs the reconcile above and re-ticks to retry + // the failed worktree). An otherwise-idle issue waits purely on `wake` and + // issues no blind periodic query. `notify_one` buffers a permit, so a wake + // that races ahead is not lost. + if has_inflight_iteration(&engine.db, issue_id).await || !infra_retries.is_empty() { + tokio::select! { + _ = wake.notified() => {} + _ = heartbeat.tick() => {} + } + } else { + wake.notified().await; + } + } + engine.deregister_driver(issue_id).await; +} + +/// Whether the issue has any queued/running iteration. Gates the periodic +/// reconcile heartbeat so an idle driver parks on `wake` alone (uses the new +/// `(issue_id, status)` index). +async fn has_inflight_iteration(db: &AppDatabase, issue_id: i32) -> bool { + loop_iteration::Entity::find() + .filter(loop_iteration::Column::IssueId.eq(issue_id)) + .filter( + loop_iteration::Column::Status + .is_in([IterationStatus::Queued, IterationStatus::Running]), + ) + .one(&db.conn) + .await + .ok() + .flatten() + .is_some() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::acp::error::AcpError; + use sea_orm::ActiveEnum; // for `IssueStatus::*.to_value()` in test helpers + use crate::db::entities::loop_artifact::{ArtifactKind, ContributionKind}; + use crate::db::entities::loop_inbox_item::{self, InboxStatus}; + use crate::db::entities::loop_issue::IssuePriority; + use crate::db::service::loop_service::{issue, space}; + use crate::db::test_helpers::{fresh_in_memory_db, seed_folder}; + use crate::loop_engine::dispatch::{settle_iteration, settle_iteration_as, SettleResolution}; + use crate::loop_engine::ingest::ingest; + use crate::loop_engine::transitions::cas_artifact_status; + use crate::models::loops::IssueConfig; + use async_trait::async_trait; + use sea_orm::sea_query::Expr; + use serde_json::json; + use std::path::PathBuf; + + use crate::db::entities::loop_artifact_revision::ActorKind; + use crate::models::loops::LoopLinkRow; + + /// Minimal `Task` artifact row for the pure `dag_has_parallelism` tests. + fn task_row(id: i32) -> LoopArtifactRow { + LoopArtifactRow { + id, + issue_id: 1, + issue_seq: 1, + kind: ArtifactKind::Task, + title: format!("T{id}"), + status: ArtifactStatus::Pending, + origin: ActorKind::Agent, + produced_by_iteration_id: None, + verdict: None, + attempt: 0, + contribution_kind: ContributionKind::Delta, + sort: id, + updated_at: Utc::now(), + } + } + + /// Build a task-only DAG. `edges` are `(successor, predecessor)` pairs — the + /// `DependsOn` direction (from = successor, to = predecessor). + fn dag_of(task_ids: &[i32], edges: &[(i32, i32)]) -> LoopDagView { + LoopDagView { + artifacts: task_ids.iter().map(|&id| task_row(id)).collect(), + links: edges + .iter() + .enumerate() + .map(|(i, &(succ, pred))| LoopLinkRow { + id: i as i32 + 1, + from_artifact_id: succ, + to_artifact_id: pred, + kind: LinkKind::DependsOn, + source_revision_id: None, + }) + .collect(), + coverage: Vec::new(), + criterion_checks: Vec::new(), + gate_decisions: Vec::new(), + live_iterations: Vec::new(), + artifact_iteration_refs: Vec::new(), + } + } + + #[test] + fn execution_mode_chain_is_serial() { + // A→B→C: never ≥2 ready at once. + assert!(!dag_has_parallelism(&dag_of(&[1, 2, 3], &[(2, 1), (3, 2)]))); + } + + #[test] + fn execution_mode_independent_roots_is_parallel() { + // Two tasks, no edges: both ready immediately. + assert!(dag_has_parallelism(&dag_of(&[1, 2], &[]))); + } + + #[test] + fn execution_mode_fanout_is_parallel() { + // A→{B,C}: 2 edges over 3 tasks (edges == n-1) yet B and C are ready + // together once A is done. Regression for the naive edge-count heuristic. + assert!(dag_has_parallelism(&dag_of(&[1, 2, 3], &[(2, 1), (3, 1)]))); + } + + #[test] + fn execution_mode_single_task_is_serial() { + assert!(!dag_has_parallelism(&dag_of(&[1], &[]))); + } + + #[tokio::test] + async fn execution_mode_write_once() { + let (db, _data_dir, issue_id) = setup().await; + let issue = loop_issue::Entity::find_by_id(issue_id) + .one(&db.conn) + .await + .unwrap() + .unwrap(); + assert!(issue.execution_mode.is_none()); + + // First decision: a parallel DAG (two independent tasks) → "parallel". + let m1 = ensure_execution_mode(&db.conn, &issue, &dag_of(&[1, 2], &[])) + .await + .unwrap(); + assert_eq!(m1.as_deref(), Some("parallel")); + let issue = loop_issue::Entity::find_by_id(issue_id) + .one(&db.conn) + .await + .unwrap() + .unwrap(); + assert_eq!(issue.execution_mode.as_deref(), Some("parallel")); + + // A later tick with a (hypothetically) serial DAG must NOT relatch it. + let m2 = ensure_execution_mode(&db.conn, &issue, &dag_of(&[1], &[])) + .await + .unwrap(); + assert_eq!(m2.as_deref(), Some("parallel"), "write-once: never recomputed"); + let issue = loop_issue::Entity::find_by_id(issue_id) + .one(&db.conn) + .await + .unwrap() + .unwrap(); + assert_eq!(issue.execution_mode.as_deref(), Some("parallel")); + } + + #[test] + fn resolve_agent_spec_uses_stage_override_with_mode_and_config() { + let mut cfg = IssueConfig::default(); + let mut cv = std::collections::BTreeMap::new(); + cv.insert("reasoning".to_string(), "high".to_string()); + cfg.agents.implement = Some(AgentSpec { + agent: AgentType::Codex, + mode_id: Some("auto".into()), + config_values: cv.clone(), + }); + let spec = resolve_agent_spec(&cfg, Stage::Implement); + assert_eq!(spec.agent, AgentType::Codex); + assert_eq!(spec.mode_id.as_deref(), Some("auto")); + assert_eq!(spec.config_values, cv); + // A stage with no override falls back to default (Claude Code, no extras). + let plan = resolve_agent_spec(&cfg, Stage::Plan); + assert_eq!(plan.agent, AgentType::ClaudeCode); + assert!(plan.mode_id.is_none() && plan.config_values.is_empty()); + } + + #[tokio::test] + async fn has_inflight_reflects_queued_and_running_only() { + let db = fresh_in_memory_db().await; + let folder_id = seed_folder(&db, "/tmp/inflight").await; + let space = space::create_space(&db.conn, "S", folder_id).await.unwrap(); + let issue = issue::create_issue(&db.conn, space.id, "I", "b", IssuePriority::Medium, Some(&IssueConfig::default())) + .await + .unwrap(); + assert!(!has_inflight_iteration(&db, issue.row.id).await, "no iterations → idle"); + let it = crate::loop_engine::transitions::try_claim_iteration( + &db.conn, + crate::loop_engine::transitions::IterationClaim { + space_id: space.id, + issue_id: issue.row.id, + stage: Stage::Triage, + target_artifact_id: None, + slot_no: None, + capability_token: "t".into(), + attempt: 0, + }, + ) + .await + .unwrap() + .unwrap(); + assert!(has_inflight_iteration(&db, issue.row.id).await, "queued → in flight"); + crate::loop_engine::transitions::cas_iteration_status(&db.conn, it.id, IterationStatus::Queued, IterationStatus::Running) + .await + .unwrap(); + assert!(has_inflight_iteration(&db, issue.row.id).await, "running → in flight"); + crate::loop_engine::transitions::cas_iteration_status(&db.conn, it.id, IterationStatus::Running, IterationStatus::Succeeded) + .await + .unwrap(); + assert!(!has_inflight_iteration(&db, issue.row.id).await, "terminal → idle"); + } + + /// Simulate a human approving the design gate (route=full), so the read + /// pipeline can proceed past it. The gate's blocking behavior has its own test. + async fn approve_awaiting_designs(db: &AppDatabase, issue_id: i32) { + let dag = artifact::list_dag(&db.conn, issue_id).await.unwrap(); + for a in dag.artifacts.iter().filter(|a| { + a.kind == ArtifactKind::Design && a.status == ArtifactStatus::AwaitingApproval + }) { + cas_artifact_status( + &db.conn, + a.id, + ArtifactStatus::AwaitingApproval, + ArtifactStatus::Done, + ) + .await + .unwrap(); + } + } + + /// Minimal spawner: records nothing, just hands back a connection id so + /// dispatch can flip the lease to running. The "agent" is simulated by the + /// test driving `ingest` + `settle_iteration` directly. + struct StubSpawner; + + #[async_trait] + impl LoopAgentSpawner for StubSpawner { + async fn spawn_loop_agent( + &self, + _db: &AppDatabase, + _data_dir: &Path, + _agent_type: AgentType, + _working_dir: String, + _emitter: EventEmitter, + _preferred_mode_id: Option, + _preferred_config_values: std::collections::BTreeMap, + _capability_token: String, + ) -> Result { + Ok("loop-conn".to_string()) + } + async fn send_loop_prompt( + &self, + _db: &AppDatabase, + _conn_id: &str, + _text: String, + _folder_id: i32, + _conversation_id: i32, + ) -> Result<(), AcpError> { + Ok(()) + } + async fn disconnect_loop_agent(&self, _conn_id: &str) {} + async fn find_loop_connection(&self, _conversation_id: i32) -> Option { + None + } + } + + /// Liveness oracle stub: every conversation reports the same fixed state, so + /// reconcile's three branches are testable without live ACP connections. + struct StubLiveness(TurnLiveness); + + #[async_trait] + impl IterationLiveness for StubLiveness { + async fn turn_state(&self, _conversation_id: i32) -> TurnLiveness { + self.0 + } + } + + async fn setup() -> (AppDatabase, PathBuf, i32) { + let db = fresh_in_memory_db().await; + let folder_id = seed_folder(&db, "/tmp/loop-driver").await; + let space = space::create_space(&db.conn, "S", folder_id).await.unwrap(); + let issue = issue::create_issue( + &db.conn, + space.id, + "Issue", + "body", + IssuePriority::Medium, + Some(&IssueConfig::default()), + ) + .await + .unwrap(); + // Trigger: mark running + bind the worktree folder. + loop_issue::Entity::update_many() + .col_expr( + loop_issue::Column::Status, + Expr::value(IssueStatus::Running.to_value()), + ) + .col_expr(loop_issue::Column::WorktreeFolderId, Expr::value(folder_id)) + .filter(loop_issue::Column::Id.eq(issue.row.id)) + .exec(&db.conn) + .await + .unwrap(); + (db, PathBuf::from("/tmp/data"), issue.row.id) + } + + fn git(dir: &Path, args: &[&str]) { + let st = std::process::Command::new("git") + .args(args) + .current_dir(dir) + .status() + .expect("spawn git"); + assert!(st.success(), "git {args:?} failed"); + } + + fn init_repo(dir: &Path) { + git(dir, &["init", "-q"]); + git(dir, &["config", "user.email", "t@example.com"]); + git(dir, &["config", "user.name", "tester"]); + std::fs::write(dir.join("README.md"), "hello\n").unwrap(); + git(dir, &["add", "-A"]); + git(dir, &["commit", "-q", "-m", "init"]); + } + + /// Like [`setup`] but backed by a real git repo + on-disk worktree, so a tick + /// that reaches the write pipeline (which provisions per-task worktrees) works. + /// Returns the tempdir guards so the repo/data dirs outlive the test. + async fn setup_real() -> (AppDatabase, tempfile::TempDir, tempfile::TempDir, i32) { + let repo = tempfile::tempdir().unwrap(); + init_repo(repo.path()); + let data = tempfile::tempdir().unwrap(); + let db = fresh_in_memory_db().await; + let folder_id = seed_folder(&db, &repo.path().to_string_lossy()).await; + let space = space::create_space(&db.conn, "S", folder_id).await.unwrap(); + let issue = issue::create_issue( + &db.conn, + space.id, + "Issue", + "body", + IssuePriority::Medium, + Some(&IssueConfig::default()), + ) + .await + .unwrap(); + let ctx = crate::loop_engine::worktree::ensure_worktree(&db.conn, data.path(), issue.row.id) + .await + .unwrap(); + // Trigger: mark running + bind the issue's real worktree folder. + loop_issue::Entity::update_many() + .col_expr( + loop_issue::Column::Status, + Expr::value(IssueStatus::Running.to_value()), + ) + .col_expr( + loop_issue::Column::WorktreeFolderId, + Expr::value(ctx.worktree_folder_id), + ) + .filter(loop_issue::Column::Id.eq(issue.row.id)) + .exec(&db.conn) + .await + .unwrap(); + (db, data, repo, issue.row.id) + } + + /// Settle every currently-running triage iteration WITHOUT submitting a + /// route (simulates a triage agent whose turn ended without + /// `loop_submit_route`), leaving `issue.route` undecided. + async fn settle_running_triage_without_route(db: &AppDatabase) { + let running = loop_iteration::Entity::find() + .filter(loop_iteration::Column::Stage.eq(Stage::Triage)) + .filter(loop_iteration::Column::Status.eq(IterationStatus::Running)) + .all(&db.conn) + .await + .unwrap(); + for it in running { + settle_iteration(db, &EventEmitter::Noop, it.id) + .await + .unwrap(); + } + } + + /// Drive one tick to dispatch triage, returning its single running iteration. + async fn dispatch_one_running_triage( + db: &AppDatabase, + data_dir: &Path, + issue_id: i32, + ) -> loop_iteration::Model { + let spawner = StubSpawner; + tick_once(db, data_dir, &spawner, &EventEmitter::Noop, issue_id, &mut HashMap::new()) + .await + .unwrap(); + let running = loop_iteration::Entity::find() + .filter(loop_iteration::Column::IssueId.eq(issue_id)) + .filter(loop_iteration::Column::Status.eq(IterationStatus::Running)) + .all(&db.conn) + .await + .unwrap(); + assert_eq!(running.len(), 1, "triage dispatched and running"); + running.into_iter().next().unwrap() + } + + #[tokio::test] + async fn reconcile_abandons_iteration_with_missing_connection() { + let (db, data_dir, issue_id) = setup().await; + let it = dispatch_one_running_triage(&db, &data_dir, issue_id).await; + // No live connection (empty manager / Missing) → abandon → Failed, never + // faked as Succeeded. + reconcile_orphaned_iterations( + &db, + &EventEmitter::Noop, + &StubLiveness(TurnLiveness::Missing), + issue_id, + ) + .await + .unwrap(); + let row = loop_iteration::Entity::find_by_id(it.id) + .one(&db.conn) + .await + .unwrap() + .unwrap(); + assert_eq!(row.status, IterationStatus::Failed); + } + + #[tokio::test] + async fn reconcile_settles_idle_connection_as_succeeded() { + let (db, data_dir, issue_id) = setup().await; + let it = dispatch_one_running_triage(&db, &data_dir, issue_id).await; + // Connection alive but no turn in flight → the turn finished, its settle + // event was missed → reconcile completes it as Succeeded. + reconcile_orphaned_iterations( + &db, + &EventEmitter::Noop, + &StubLiveness(TurnLiveness::Idle), + issue_id, + ) + .await + .unwrap(); + let row = loop_iteration::Entity::find_by_id(it.id) + .one(&db.conn) + .await + .unwrap() + .unwrap(); + assert_eq!(row.status, IterationStatus::Succeeded); + } + + #[tokio::test] + async fn reconcile_leaves_inflight_iteration_running() { + let (db, data_dir, issue_id) = setup().await; + let it = dispatch_one_running_triage(&db, &data_dir, issue_id).await; + // A turn is genuinely in flight → reconcile must not disturb it. + reconcile_orphaned_iterations( + &db, + &EventEmitter::Noop, + &StubLiveness(TurnLiveness::InFlight), + issue_id, + ) + .await + .unwrap(); + let row = loop_iteration::Entity::find_by_id(it.id) + .one(&db.conn) + .await + .unwrap() + .unwrap(); + assert_eq!(row.status, IterationStatus::Running); + } + + /// Overwrite an issue's config with `stall_alert_secs` set (config_inherits is + /// false after `create_issue`, so the issue's own config is what's resolved). + async fn set_stall_alert(db: &AppDatabase, issue_id: i32, secs: Option) { + let cfg = IssueConfig { + stall_alert_secs: secs, + ..IssueConfig::default() + }; + loop_issue::Entity::update_many() + .col_expr( + loop_issue::Column::Config, + Expr::value(serde_json::to_string(&cfg).unwrap()), + ) + .filter(loop_issue::Column::Id.eq(issue_id)) + .exec(&db.conn) + .await + .unwrap(); + } + + /// Backdate an iteration's `started_at` so it reads as having run `secs` ago. + async fn backdate_started(db: &AppDatabase, iter_id: i32, secs: i64) { + loop_iteration::Entity::update_many() + .col_expr( + loop_iteration::Column::StartedAt, + Expr::value(Utc::now() - chrono::Duration::seconds(secs)), + ) + .filter(loop_iteration::Column::Id.eq(iter_id)) + .exec(&db.conn) + .await + .unwrap(); + } + + async fn stall_card(db: &AppDatabase, iter_id: i32) -> Option { + loop_inbox_item::Entity::find() + .filter(loop_inbox_item::Column::SubjectKey.eq(format!("stalled:{iter_id}"))) + .one(&db.conn) + .await + .unwrap() + } + + #[tokio::test] + async fn stall_alert_files_card_only_when_configured() { + // Configured: an in-flight iteration older than the threshold files a + // `stalled` card — but is never killed (surface-only watchdog). + let (db, data_dir, issue_id) = setup().await; + set_stall_alert(&db, issue_id, Some(1)).await; + let it = dispatch_one_running_triage(&db, &data_dir, issue_id).await; + backdate_started(&db, it.id, 10).await; + reconcile_orphaned_iterations( + &db, + &EventEmitter::Noop, + &StubLiveness(TurnLiveness::InFlight), + issue_id, + ) + .await + .unwrap(); + let row = loop_iteration::Entity::find_by_id(it.id) + .one(&db.conn) + .await + .unwrap() + .unwrap(); + assert_eq!(row.status, IterationStatus::Running, "stall alert never kills"); + let card = stall_card(&db, it.id).await.expect("configured → card filed"); + assert_eq!(card.kind, InboxKind::Blocked); + assert_eq!(card.iteration_id, Some(it.id)); + + // Not configured (default None): the iteration may run arbitrarily long + // and no card is ever filed — honors "no artificial limits". + let (db2, data_dir2, issue_id2) = setup().await; + let it2 = dispatch_one_running_triage(&db2, &data_dir2, issue_id2).await; + backdate_started(&db2, it2.id, 100_000).await; + reconcile_orphaned_iterations( + &db2, + &EventEmitter::Noop, + &StubLiveness(TurnLiveness::InFlight), + issue_id2, + ) + .await + .unwrap(); + assert!( + stall_card(&db2, it2.id).await.is_none(), + "no threshold = no alert, ever" + ); + } + + #[tokio::test] + async fn undecided_triage_redispatches_then_blocks() { + let (db, data_dir, issue_id) = setup().await; + // max_attempts = 2 → one re-dispatch, then block. + let cfg = IssueConfig { + max_attempts: 2, + ..IssueConfig::default() + }; + loop_issue::Entity::update_many() + .col_expr( + loop_issue::Column::Config, + Expr::value(serde_json::to_string(&cfg).unwrap()), + ) + .filter(loop_issue::Column::Id.eq(issue_id)) + .exec(&db.conn) + .await + .unwrap(); + let spawner = StubSpawner; + + // Tick 1: dispatch triage, then settle it with no route. + tick_once(&db, &data_dir, &spawner, &EventEmitter::Noop, issue_id, &mut HashMap::new()) + .await + .unwrap(); + settle_running_triage_without_route(&db).await; + + // Tick 2: triage settled but undecided → re-dispatch (attempt 1). + let out = tick_once(&db, &data_dir, &spawner, &EventEmitter::Noop, issue_id, &mut HashMap::new()) + .await + .unwrap(); + assert_eq!(out, TickOutcome::Dispatched); + settle_running_triage_without_route(&db).await; + + // Tick 3: attempts hit max → block + inbox card. The block reports + // Advanced so the driver re-ticks and stops on the now-blocked issue. + let out = tick_once(&db, &data_dir, &spawner, &EventEmitter::Noop, issue_id, &mut HashMap::new()) + .await + .unwrap(); + assert_eq!(out, TickOutcome::Advanced); + let issue = loop_issue::Entity::find_by_id(issue_id) + .one(&db.conn) + .await + .unwrap() + .unwrap(); + assert_eq!(issue.status, IssueStatus::Blocked); + let card = loop_inbox_item::Entity::find() + .filter(loop_inbox_item::Column::IssueId.eq(issue_id)) + .filter(loop_inbox_item::Column::SubjectKey.eq(format!("triage_no_route:{issue_id}"))) + .one(&db.conn) + .await + .unwrap(); + assert!(card.is_some(), "blocked triage files an inbox card"); + } + + #[tokio::test] + async fn abandoned_triage_uses_bounded_recovery_not_unbounded_redispatch() { + let (db, data_dir, issue_id) = setup().await; + // max_attempts = 1 → a single failed triage with no route must BLOCK. A + // Failed triage still counts as "triage on record", so tick_once defers to + // bounded recovery instead of its unbounded attempt-0 initial-dispatch. + let cfg = IssueConfig { + max_attempts: 1, + ..IssueConfig::default() + }; + loop_issue::Entity::update_many() + .col_expr( + loop_issue::Column::Config, + Expr::value(serde_json::to_string(&cfg).unwrap()), + ) + .filter(loop_issue::Column::Id.eq(issue_id)) + .exec(&db.conn) + .await + .unwrap(); + let spawner = StubSpawner; + + // Tick 1: dispatch triage, then abandon it (dead connection → Failed). + tick_once(&db, &data_dir, &spawner, &EventEmitter::Noop, issue_id, &mut HashMap::new()) + .await + .unwrap(); + let running = loop_iteration::Entity::find() + .filter(loop_iteration::Column::Stage.eq(Stage::Triage)) + .filter(loop_iteration::Column::Status.eq(IterationStatus::Running)) + .all(&db.conn) + .await + .unwrap(); + for it in running { + settle_iteration_as(&db, &EventEmitter::Noop, it.id, SettleResolution::Abandoned) + .await + .unwrap(); + } + + // Tick 2: one Failed triage + undecided route + max_attempts=1 → block, + // NOT a fresh attempt-0 dispatch (the pre-fix bug). The block reports + // Advanced (re-tick → stop), not a redispatch. + let out = tick_once(&db, &data_dir, &spawner, &EventEmitter::Noop, issue_id, &mut HashMap::new()) + .await + .unwrap(); + assert_eq!(out, TickOutcome::Advanced); + let issue = loop_issue::Entity::find_by_id(issue_id) + .one(&db.conn) + .await + .unwrap() + .unwrap(); + assert_eq!( + issue.status, + IssueStatus::Blocked, + "an abandoned triage must bound via recovery, not redispatch unbounded" + ); + } + + /// Build a post-plan DAG with a coverage gap: route=full, two done + /// requirements (each one acceptance criterion), a done design, a settled + /// plan iteration on record, and two pending tasks — but coverage only for + /// R1.AC1 (R2.AC1 left uncovered). Returns (db, data_dir, issue_id, task_ids). + async fn seed_coverage_gap() -> (AppDatabase, PathBuf, i32, Vec) { + use crate::db::entities::loop_criterion::CriterionKind; + use crate::loop_engine::transitions::{ + cas_iteration_status, try_claim_iteration, IterationClaim, + }; + + let (db, data_dir, issue_id) = setup().await; + let conn = &db.conn; + let issue = loop_issue::Entity::find_by_id(issue_id) + .one(conn) + .await + .unwrap() + .unwrap(); + let space_id = issue.space_id; + + // Route = full (the gate only runs on routes that produce requirements). + loop_issue::Entity::update_many() + .col_expr( + loop_issue::Column::Route, + Expr::value(IssueRoute::Full.to_value()), + ) + .filter(loop_issue::Column::Id.eq(issue_id)) + .exec(conn) + .await + .unwrap(); + + // A triage iteration on record so tick_once doesn't dispatch the initial one. + try_claim_iteration( + conn, + IterationClaim { + space_id, + issue_id, + stage: Stage::Triage, + target_artifact_id: None, + slot_no: None, + capability_token: "triage-tok".into(), + attempt: 0, + }, + ) + .await + .unwrap() + .unwrap(); + + // Two done requirements, each with one acceptance criterion. + let r1 = artifact::create_artifact(conn, space_id, issue_id, ArtifactKind::Requirement, "R1", ArtifactStatus::Done, ActorKind::Agent, None).await.unwrap(); + artifact::add_criterion(conn, r1.id, CriterionKind::Acceptance, "r1 ac").await.unwrap(); + let r2 = artifact::create_artifact(conn, space_id, issue_id, ArtifactKind::Requirement, "R2", ArtifactStatus::Done, ActorKind::Agent, None).await.unwrap(); + artifact::add_criterion(conn, r2.id, CriterionKind::Acceptance, "r2 ac").await.unwrap(); + + // A done design fanning into both requirements. + let d = artifact::create_artifact(conn, space_id, issue_id, ArtifactKind::Design, "D", ArtifactStatus::Done, ActorKind::Agent, None).await.unwrap(); + link::create_link(conn, space_id, d.id, r1.id, LinkKind::DerivesFrom, None).await.unwrap(); + link::create_link(conn, space_id, d.id, r2.id, LinkKind::DerivesFrom, None).await.unwrap(); + + // One settled plan iteration on record (the plan that produced the tasks). + let plan_it = try_claim_iteration( + conn, + IterationClaim { + space_id, + issue_id, + stage: Stage::Plan, + target_artifact_id: Some(d.id), + slot_no: None, + capability_token: "plan-tok".into(), + attempt: 0, + }, + ) + .await + .unwrap() + .unwrap(); + cas_iteration_status(conn, plan_it.id, IterationStatus::Queued, IterationStatus::Running).await.unwrap(); + cas_iteration_status(conn, plan_it.id, IterationStatus::Running, IterationStatus::Succeeded).await.unwrap(); + + // Two pending tasks; coverage only for R1's acceptance criterion. + let t1 = artifact::create_artifact(conn, space_id, issue_id, ArtifactKind::Task, "T1", ArtifactStatus::Pending, ActorKind::Agent, None).await.unwrap(); + let t2 = artifact::create_artifact(conn, space_id, issue_id, ArtifactKind::Task, "T2", ArtifactStatus::Pending, ActorKind::Agent, None).await.unwrap(); + let r1ac = artifact::get_artifact_detail(conn, r1.id).await.unwrap().unwrap().criteria[0].id; + coverage::create_coverage(conn, space_id, t1.id, r1ac).await.unwrap(); + + (db, data_dir, issue_id, vec![t1.id, t2.id]) + } + + #[tokio::test] + async fn coverage_gap_supersedes_tasks_and_replans() { + let (db, data_dir, issue_id, tasks) = seed_coverage_gap().await; + let spawner = StubSpawner; + let out = tick_once(&db, &data_dir, &spawner, &EventEmitter::Noop, issue_id, &mut HashMap::new()) + .await + .unwrap(); + assert_eq!(out, TickOutcome::Advanced, "coverage gap is durable progress"); + // Both under-covering tasks superseded. + for t in &tasks { + let node = artifact::get_artifact_detail(&db.conn, *t).await.unwrap().unwrap(); + assert_eq!(node.row.status, ArtifactStatus::Superseded, "task {t} superseded"); + } + // Issue still running (bounded replan, not exhausted) and the read frontier + // now re-emits Plan (live tasks empty). + let issue = loop_issue::Entity::find_by_id(issue_id).one(&db.conn).await.unwrap().unwrap(); + assert_eq!(issue.status, IssueStatus::Running); + let dag = artifact::list_dag(&db.conn, issue_id).await.unwrap(); + let frontier = ready_nodes(&dag, IssueRoute::Full); + assert_eq!(frontier.len(), 1); + assert_eq!(frontier[0].stage, Stage::Plan, "re-emits Plan to replan"); + } + + #[tokio::test] + async fn coverage_gap_exhausts_to_blocked_with_card() { + let (db, data_dir, issue_id, tasks) = seed_coverage_gap().await; + // Tighten the rework cap to 1: the single plan iteration on record already + // meets it, so the gap blocks instead of replanning. + let cfg = IssueConfig { + max_attempts: 1, + ..IssueConfig::default() + }; + write_config(&db, issue_id, &cfg).await; + + let spawner = StubSpawner; + let out = tick_once(&db, &data_dir, &spawner, &EventEmitter::Noop, issue_id, &mut HashMap::new()) + .await + .unwrap(); + assert_eq!(out, TickOutcome::Advanced); + // Issue blocked; tasks left untouched; a coverage_gap card filed. + let issue = loop_issue::Entity::find_by_id(issue_id).one(&db.conn).await.unwrap().unwrap(); + assert_eq!(issue.status, IssueStatus::Blocked); + let node = artifact::get_artifact_detail(&db.conn, tasks[0]).await.unwrap().unwrap(); + assert_eq!(node.row.status, ArtifactStatus::Pending, "tasks untouched on exhaustion"); + let cards = inbox::list_inbox(&db.conn, issue.space_id, None).await.unwrap(); + assert!( + cards.iter().any(|c| c.subject_key == format!("coverage_gap:{issue_id}") + && c.kind == InboxKind::Blocked), + "coverage_gap card filed" + ); + } + + /// D12: a rejected design re-emits Design via the frontier; the loop-back is + /// bounded by the Design-iteration count vs `max_attempts`. Under the bound it + /// re-emits (None); at/over it blocks the issue + files `design_rejected`; + /// `0` = unlimited never blocks. + #[tokio::test] + async fn design_reject_loopback_is_bounded() { + use crate::loop_engine::transitions::{try_claim_iteration, IterationClaim}; + let (db, _data_dir, issue_id) = setup().await; + let conn = &db.conn; + let issue = loop_issue::Entity::find_by_id(issue_id).one(conn).await.unwrap().unwrap(); + let space_id = issue.space_id; + + // A rejected (superseded) design with no live design + two Design iterations. + artifact::create_artifact(conn, space_id, issue_id, ArtifactKind::Design, "D1", ArtifactStatus::Superseded, ActorKind::Agent, None).await.unwrap(); + for n in 0..2 { + try_claim_iteration( + conn, + IterationClaim { + space_id, + issue_id, + stage: Stage::Design, + target_artifact_id: None, + slot_no: None, + capability_token: format!("d{n}"), + attempt: n, + }, + ) + .await + .unwrap() + .unwrap(); + } + let dag = artifact::list_dag(conn, issue_id).await.unwrap(); + + // Under the bound (3 > 2) → re-emit; issue stays running. + let under = IssueConfig { max_attempts: 3, ..IssueConfig::default() }; + assert!(maybe_design_reject_exhausted(&db, &EventEmitter::Noop, &issue, &under, &dag).await.unwrap().is_none()); + // Unlimited (0) never blocks. + let unlimited = IssueConfig { max_attempts: 0, ..IssueConfig::default() }; + assert!(maybe_design_reject_exhausted(&db, &EventEmitter::Noop, &issue, &unlimited, &dag).await.unwrap().is_none()); + assert_eq!( + loop_issue::Entity::find_by_id(issue_id).one(conn).await.unwrap().unwrap().status, + IssueStatus::Running + ); + + // At the bound (2 ≤ 2) → block + design_rejected card. + let at = IssueConfig { max_attempts: 2, ..IssueConfig::default() }; + let out = maybe_design_reject_exhausted(&db, &EventEmitter::Noop, &issue, &at, &dag).await.unwrap(); + assert_eq!(out, Some(TickOutcome::Advanced)); + assert_eq!( + loop_issue::Entity::find_by_id(issue_id).one(conn).await.unwrap().unwrap().status, + IssueStatus::Blocked + ); + let cards = inbox::list_inbox(conn, space_id, None).await.unwrap(); + assert!( + cards.iter().any(|c| c.subject_key == format!("design_rejected:{issue_id}") + && c.kind == InboxKind::Blocked), + "design_rejected card filed on exhaustion" + ); + } + + /// Overwrite an issue's whole config (config_inherits is false after + /// `create_issue`, so its own config is what `effective_config` resolves). + async fn write_config(db: &AppDatabase, issue_id: i32, cfg: &IssueConfig) { + loop_issue::Entity::update_many() + .col_expr( + loop_issue::Column::Config, + Expr::value(serde_json::to_string(cfg).unwrap()), + ) + .filter(loop_issue::Column::Id.eq(issue_id)) + .exec(&db.conn) + .await + .unwrap(); + } + + /// Settle every running iteration WITHOUT ingesting any artifact — simulates a + /// read-stage agent whose turn ended having produced nothing (no-progress). + async fn settle_all_running_without_output(db: &AppDatabase) { + let running = loop_iteration::Entity::find() + .filter(loop_iteration::Column::Status.eq(IterationStatus::Running)) + .all(&db.conn) + .await + .unwrap(); + for it in running { + settle_iteration(db, &EventEmitter::Noop, it.id) + .await + .unwrap(); + } + } + + #[tokio::test] + async fn read_stage_no_output_blocks_at_max_attempts() { + let (db, data_dir, issue_id) = setup().await; + // Small cap so the breaker trips quickly. skip_design route → refine is the + // first read stage and targets the issue root, so the root node's attempt + // is what the breaker counts. + write_config( + &db, + issue_id, + &IssueConfig { + max_attempts: 2, + ..IssueConfig::default() + }, + ) + .await; + let spawner = StubSpawner; + + // Get past triage with a decided route. + tick_once(&db, &data_dir, &spawner, &EventEmitter::Noop, issue_id, &mut HashMap::new()) + .await + .unwrap(); + respond_and_settle(&db, "skip_design").await; + + // Refine now runs but produces nothing, repeatedly. The settle-time breaker + // bumps the root node attempt each pass and blocks once it hits the cap — + // it must terminate, never redispatch forever (the D5 bug). + let mut stopped = false; + for _ in 0..12 { + let out = tick_once(&db, &data_dir, &spawner, &EventEmitter::Noop, issue_id, &mut HashMap::new()) + .await + .unwrap(); + if out == TickOutcome::Stop { + stopped = true; + break; // issue already blocked; driver would exit + } + settle_all_running_without_output(&db).await; + } + assert!(stopped, "read-stage no-progress must stop, not loop forever"); + + let issue = loop_issue::Entity::find_by_id(issue_id) + .one(&db.conn) + .await + .unwrap() + .unwrap(); + assert_eq!(issue.status, IssueStatus::Blocked); + let card = loop_inbox_item::Entity::find() + .filter(loop_inbox_item::Column::IssueId.eq(issue_id)) + .filter(loop_inbox_item::Column::Kind.eq(InboxKind::Blocked)) + .filter(loop_inbox_item::Column::SubjectKey.starts_with("no_progress:")) + .one(&db.conn) + .await + .unwrap(); + assert!(card.is_some(), "read-stage breaker files a no_progress card"); + } + + /// Simulate the dispatched iteration's agent: submit the stage-appropriate + /// output through the real ingest boundary, then settle it. + async fn respond_and_settle(db: &AppDatabase, route: &str) { + let running = loop_iteration::Entity::find() + .filter(loop_iteration::Column::Status.eq(IterationStatus::Running)) + .all(&db.conn) + .await + .unwrap(); + for it in running { + let tok = &it.capability_token; + match it.stage { + Stage::Triage => { + ingest(&db.conn, tok, "loop_submit_route", &json!({ "route": route })) + .await + .unwrap(); + } + Stage::Refine => { + ingest( + &db.conn, + tok, + "loop_submit_artifacts", + &json!({ "artifacts": [{ "title": "R1" }, { "title": "R2" }] }), + ) + .await + .unwrap(); + } + Stage::Design => { + ingest( + &db.conn, + tok, + "loop_submit_artifacts", + &json!({ "artifacts": [{ "title": "D1" }] }), + ) + .await + .unwrap(); + } + Stage::Plan => { + ingest( + &db.conn, + tok, + "loop_submit_artifacts", + &json!({ "artifacts": [{ "title": "T1" }, { "title": "T2" }] }), + ) + .await + .unwrap(); + } + other => panic!("read pipeline helper got non-read stage: {other:?}"), + } + settle_iteration(db, &EventEmitter::Noop, it.id).await.unwrap(); + } + } + + /// Drive `tick_once` through the read pipeline, simulating each dispatched + /// read iteration, and stop at the first implement dispatch. That dispatch + /// happens on the post-plan tick — the same tick that applies skip + /// provenance once the read frontier empties — so on return the DAG is fully + /// grown (incl. skips_to). The implement iteration is left freshly running + /// (the gates tests own its checkpoint, which needs a real worktree). + async fn drive_through_read_pipeline( + db: &AppDatabase, + data_dir: &Path, + issue_id: i32, + route: &str, + ) { + let spawner = StubSpawner; + for _ in 0..30 { + let _ = tick_once(db, data_dir, &spawner, &EventEmitter::Noop, issue_id, &mut HashMap::new()) + .await + .unwrap(); + let into_implement = loop_iteration::Entity::find() + .filter(loop_iteration::Column::Stage.eq(Stage::Implement)) + .one(&db.conn) + .await + .unwrap() + .is_some(); + if into_implement { + return; // read pipeline + skip provenance complete + } + respond_and_settle(db, route).await; + // A human approves the design gate so full-route pipelines advance. + approve_awaiting_designs(db, issue_id).await; + } + panic!("read pipeline did not reach implement within the iteration budget"); + } + + fn kind_count(dag: &LoopDagView, kind: ArtifactKind) -> usize { + dag.artifacts.iter().filter(|a| a.kind == kind).count() + } + + #[tokio::test] + async fn full_route_grows_dag_through_tasks() { + let (db, data, _repo, issue_id) = setup_real().await; + drive_through_read_pipeline(&db, data.path(), issue_id, "full").await; + + let dag = artifact::list_dag(&db.conn, issue_id).await.unwrap(); + assert_eq!(kind_count(&dag, ArtifactKind::Issue), 1); + assert_eq!(kind_count(&dag, ArtifactKind::Requirement), 2); + assert_eq!(kind_count(&dag, ArtifactKind::Design), 1); + assert_eq!(kind_count(&dag, ArtifactKind::Task), 2); + + let derives = dag + .links + .iter() + .filter(|l| l.kind == LinkKind::DerivesFrom) + .count(); + assert!(derives >= 5, "every produced node derives from a source"); + assert!( + !dag.links.iter().any(|l| l.kind == LinkKind::SkipsTo), + "full route skips nothing" + ); + + // Triage decided the route; the read pipeline ran to completion. (The + // implement iteration just dispatched and is still running — excluded.) + let settled = loop_iteration::Entity::find() + .filter(loop_iteration::Column::IssueId.eq(issue_id)) + .all(&db.conn) + .await + .unwrap(); + assert!(settled + .iter() + .filter(|it| it.stage != Stage::Implement) + .all(|it| it.status == IterationStatus::Succeeded)); + assert_eq!( + settled.iter().filter(|it| it.stage == Stage::Refine).count(), + 1 + ); + assert_eq!( + settled.iter().filter(|it| it.stage == Stage::Design).count(), + 1 + ); + assert_eq!(settled.iter().filter(|it| it.stage == Stage::Plan).count(), 1); + } + + #[tokio::test] + async fn direct_route_skips_refine_and_design_with_skips_to() { + let (db, data, _repo, issue_id) = setup_real().await; + drive_through_read_pipeline(&db, data.path(), issue_id, "direct").await; + + let dag = artifact::list_dag(&db.conn, issue_id).await.unwrap(); + assert_eq!(kind_count(&dag, ArtifactKind::Requirement), 0, "no requirements"); + assert_eq!(kind_count(&dag, ArtifactKind::Design), 0, "no design"); + assert_eq!(kind_count(&dag, ArtifactKind::Task), 2); + + let skips = dag + .links + .iter() + .filter(|l| l.kind == LinkKind::SkipsTo) + .count(); + assert_eq!(skips, 2, "each task records skip provenance to the root"); + + // No refine/design iterations were dispatched. + let iters = loop_iteration::Entity::find() + .filter(loop_iteration::Column::IssueId.eq(issue_id)) + .all(&db.conn) + .await + .unwrap(); + assert!(!iters + .iter() + .any(|it| matches!(it.stage, Stage::Refine | Stage::Design))); + } + + /// Regression: the execution mode decided on the first write tick must take + /// effect THAT tick. A parallel plan (two independent tasks) must fan out both + /// implements into their OWN per-task worktrees on the deciding tick — not + /// drive the first task serially into the shared issue worktree. (Bug: tick + /// persisted the decided mode to the DB but kept driving with the stale + /// pre-decision in-memory issue, so a freshly parallel issue's first task ran + /// in the shared tree and only fanned out from the next tick — stranding that + /// task's edits when its checkpoint later looked in the per-task tree.) + #[tokio::test] + async fn first_write_tick_applies_decided_parallel_mode() { + let (db, data, _repo, issue_id) = setup_real().await; + // "direct" route → the planner submits two independent tasks → parallel. + drive_through_read_pipeline(&db, data.path(), issue_id, "direct").await; + + let issue = loop_issue::Entity::find_by_id(issue_id) + .one(&db.conn) + .await + .unwrap() + .unwrap(); + assert_eq!(issue.execution_mode.as_deref(), Some("parallel")); + + // Both tasks' implements launched on the SAME tick that decided the mode — + // not just one. With the stale-`None` bug the deciding tick truncates to a + // single task driven into the shared issue worktree; the fan-out (and the + // per-task worktree each implement needs to dispatch) only appears here + // because the decided mode is applied in-tick. (Each running implement + // implies its per-task worktree was provisioned: a worktree failure would + // have skipped the task, leaving fewer than two.) + let impls = loop_iteration::Entity::find() + .filter(loop_iteration::Column::Stage.eq(Stage::Implement)) + .filter(loop_iteration::Column::Status.eq(IterationStatus::Running)) + .all(&db.conn) + .await + .unwrap(); + assert_eq!( + impls.len(), + 2, + "parallel fan-out applies on the deciding tick, not the next one" + ); + } + + #[tokio::test] + async fn design_gate_blocks_plan_until_approved() { + let (db, data_dir, issue_id) = setup().await; + let spawner = StubSpawner; + let space_id = loop_issue::Entity::find_by_id(issue_id) + .one(&db.conn) + .await + .unwrap() + .unwrap() + .space_id; + + // Drive triage(full) → refine → design, settling each but NOT approving. + let mut awaiting = false; + for _ in 0..12 { + tick_once(&db, &data_dir, &spawner, &EventEmitter::Noop, issue_id, &mut HashMap::new()) + .await + .unwrap(); + respond_and_settle(&db, "full").await; + let dag = artifact::list_dag(&db.conn, issue_id).await.unwrap(); + if dag.artifacts.iter().any(|a| { + a.kind == ArtifactKind::Design && a.status == ArtifactStatus::AwaitingApproval + }) { + awaiting = true; + break; + } + } + assert!(awaiting, "a design reached the approval gate"); + + // The gate holds: a card is filed and no task is dispatched, even on a + // further tick. + tick_once(&db, &data_dir, &spawner, &EventEmitter::Noop, issue_id, &mut HashMap::new()) + .await + .unwrap(); + let dag = artifact::list_dag(&db.conn, issue_id).await.unwrap(); + assert_eq!( + kind_count(&dag, ArtifactKind::Task), + 0, + "planning is blocked by the design gate" + ); + let cards = inbox::list_inbox(&db.conn, space_id, Some(InboxStatus::Pending)) + .await + .unwrap(); + assert!(cards + .iter() + .any(|c| c.kind == InboxKind::Approval + && c.subject_key == format!("design:{issue_id}"))); + + // Approve → the pipeline advances and planning produces tasks. + approve_awaiting_designs(&db, issue_id).await; + let mut tasks = 0; + for _ in 0..12 { + tick_once(&db, &data_dir, &spawner, &EventEmitter::Noop, issue_id, &mut HashMap::new()) + .await + .unwrap(); + respond_and_settle(&db, "full").await; + approve_awaiting_designs(&db, issue_id).await; + tasks = kind_count(&artifact::list_dag(&db.conn, issue_id).await.unwrap(), ArtifactKind::Task); + if tasks > 0 { + break; + } + } + assert!(tasks > 0, "planning produced tasks after approval"); + } + + #[test] + fn ready_nodes_full_pipeline_progression() { + // Build DAG snapshots by hand to exercise the pure frontier function. + let mk = |id: i32, kind: ArtifactKind, status: ArtifactStatus| LoopArtifactRow { + id, + issue_id: 1, + issue_seq: 1, + kind, + title: "x".into(), + status, + origin: crate::db::entities::loop_artifact_revision::ActorKind::Agent, + produced_by_iteration_id: None, + verdict: None, + attempt: 0, + contribution_kind: ContributionKind::Delta, + sort: 0, + updated_at: chrono::DateTime::from_timestamp(0, 0).unwrap(), + }; + let root = mk(1, ArtifactKind::Issue, ArtifactStatus::Done); + + // Only the root → refine is next. + let dag = LoopDagView { + artifacts: vec![root.clone()], + links: vec![], + coverage: vec![], + criterion_checks: vec![], + gate_decisions: vec![], + live_iterations: vec![], + artifact_iteration_refs: vec![], + }; + let f = ready_nodes(&dag, IssueRoute::Full); + assert_eq!(f.len(), 1); + assert_eq!(f[0].stage, Stage::Refine); + assert_eq!(f[0].target_artifact_id, Some(1)); + + // Requirements done → design is next. + let dag = LoopDagView { + artifacts: vec![ + root.clone(), + mk(2, ArtifactKind::Requirement, ArtifactStatus::Done), + ], + links: vec![], + coverage: vec![], + criterion_checks: vec![], + gate_decisions: vec![], + live_iterations: vec![], + artifact_iteration_refs: vec![], + }; + assert_eq!(ready_nodes(&dag, IssueRoute::Full)[0].stage, Stage::Design); + + // Design done → plan is next. + let dag = LoopDagView { + artifacts: vec![ + root.clone(), + mk(2, ArtifactKind::Requirement, ArtifactStatus::Done), + mk(3, ArtifactKind::Design, ArtifactStatus::Done), + ], + links: vec![], + coverage: vec![], + criterion_checks: vec![], + gate_decisions: vec![], + live_iterations: vec![], + artifact_iteration_refs: vec![], + }; + assert_eq!(ready_nodes(&dag, IssueRoute::Full)[0].stage, Stage::Plan); + + // Tasks exist → read frontier empty (the write pipeline drives them). + let dag = LoopDagView { + artifacts: vec![ + root.clone(), + mk(2, ArtifactKind::Requirement, ArtifactStatus::Done), + mk(3, ArtifactKind::Design, ArtifactStatus::Done), + mk(4, ArtifactKind::Task, ArtifactStatus::Pending), + ], + links: vec![], + coverage: vec![], + criterion_checks: vec![], + gate_decisions: vec![], + live_iterations: vec![], + artifact_iteration_refs: vec![], + }; + assert!(ready_nodes(&dag, IssueRoute::Full).is_empty()); + } + + #[test] + fn ready_nodes_route_shortening() { + let mk = |id: i32, kind: ArtifactKind, status: ArtifactStatus| LoopArtifactRow { + id, + issue_id: 1, + issue_seq: 1, + kind, + title: "x".into(), + status, + origin: crate::db::entities::loop_artifact_revision::ActorKind::Agent, + produced_by_iteration_id: None, + verdict: None, + attempt: 0, + contribution_kind: ContributionKind::Delta, + sort: 0, + updated_at: chrono::DateTime::from_timestamp(0, 0).unwrap(), + }; + let root = mk(1, ArtifactKind::Issue, ArtifactStatus::Done); + + // direct: straight to plan, no refine/design. + let dag = LoopDagView { + artifacts: vec![root.clone()], + links: vec![], + coverage: vec![], + criterion_checks: vec![], + gate_decisions: vec![], + live_iterations: vec![], + artifact_iteration_refs: vec![], + }; + let f = ready_nodes(&dag, IssueRoute::Direct); + assert_eq!(f[0].stage, Stage::Plan); + assert_eq!(f[0].target_artifact_id, Some(1)); + + // skip_design: refine first, then plan (no design step). + let f = ready_nodes(&dag, IssueRoute::SkipDesign); + assert_eq!(f[0].stage, Stage::Refine); + let dag = LoopDagView { + artifacts: vec![ + root.clone(), + mk(2, ArtifactKind::Requirement, ArtifactStatus::Done), + ], + links: vec![], + coverage: vec![], + criterion_checks: vec![], + gate_decisions: vec![], + live_iterations: vec![], + artifact_iteration_refs: vec![], + }; + assert_eq!(ready_nodes(&dag, IssueRoute::SkipDesign)[0].stage, Stage::Plan); + } +} diff --git a/src-tauri/src/loop_engine/error.rs b/src-tauri/src/loop_engine/error.rs new file mode 100644 index 0000000000..2fdb9b4938 --- /dev/null +++ b/src-tauri/src/loop_engine/error.rs @@ -0,0 +1,106 @@ +use std::collections::BTreeMap; + +use crate::app_error::{AppCommandError, AppErrorCode}; + +/// Errors raised by the loop engine and its services. `Conflict` is the +/// compare-and-swap miss (concurrent state change) that the frontend retries. +#[derive(Debug, thiserror::Error)] +pub enum LoopError { + #[error("not found: {0}")] + NotFound(String), + #[error("illegal loop state transition")] + IllegalTransition, + #[error("conflicting concurrent update")] + Conflict, + /// The issue is not in a state that can be merged (already terminal, blocked, + /// cancelled, paused, or finalize has not produced a result). Distinct from + /// `Conflict`: there is nothing transient to retry. + #[error("issue is not in a mergeable state")] + NotMergeable, + #[error("loop space is detached from its folder")] + Detached, + #[error("folder is not a git repository")] + NotGitRepo, + #[error("merge conflict")] + MergeConflict, + /// A merge attempt could not land for a concrete reason (dirty base, conflict, + /// failed re-validation, missing base). Carries the user-facing, actionable + /// message; the verbose git/validation output lives on the issue's inbox card. + #[error("merge failed: {0}")] + MergeFailed(String), + #[error("git command failed: {0}")] + Git(String), + #[error("invalid input: {0}")] + InvalidInput(String), + #[error("invalid loop config: {0}")] + InvalidConfig(String), + #[error("acp error: {0}")] + Acp(String), + #[error(transparent)] + Db(#[from] crate::db::error::DbError), +} + +// The state machine (transitions.rs) runs raw sea_orm queries, while the +// service layer returns the wrapper `DbError`. Support `?` on both. +impl From for LoopError { + fn from(e: sea_orm::DbErr) -> Self { + LoopError::Db(crate::db::error::DbError::from(e)) + } +} + +impl From for AppCommandError { + fn from(e: LoopError) -> Self { + match e { + LoopError::NotFound(m) => AppCommandError::not_found(m), + LoopError::IllegalTransition => { + AppCommandError::new(AppErrorCode::InvalidInput, "Illegal loop state transition") + } + // Surfaced as a retryable conflict (HTTP 409 via TurnInProgress); the + // frontend renders the localized `Loops.conflictRetry` toast. + LoopError::Conflict => AppCommandError::new( + AppErrorCode::TurnInProgress, + "Loop state changed concurrently; retry", + ) + .with_i18n("Loops.conflictRetry", BTreeMap::new()), + // A non-retryable "can't do that" — the issue is already terminal / + // blocked / not finalized. `InvalidInput` (not `TurnInProgress`) so the + // frontend renders a plain failure, never the "retry" toast. + LoopError::NotMergeable => AppCommandError::new( + AppErrorCode::InvalidInput, + "This issue is no longer awaiting merge", + ), + LoopError::Detached => AppCommandError::new( + AppErrorCode::InvalidInput, + "Loop space is detached from its folder", + ), + LoopError::NotGitRepo => { + AppCommandError::not_a_git_repository("Loop space folder is not a git repository") + } + LoopError::MergeConflict => AppCommandError::new( + AppErrorCode::ExternalCommandFailed, + "Merge conflict while integrating the issue branch", + ), + // The merge could not land; surface the concrete, actionable reason + // (never a silent success). No `with_detail` — the frontend's + // `toErrorMessage` prefers `detail` over `message`, and we want the + // actionable sentence in the toast (the verbose output is on the card). + LoopError::MergeFailed(message) => { + AppCommandError::new(AppErrorCode::ExternalCommandFailed, message) + } + LoopError::Git(m) => { + AppCommandError::new(AppErrorCode::ExternalCommandFailed, "Git command failed") + .with_detail(m) + } + LoopError::InvalidInput(m) => AppCommandError::invalid_input(m), + LoopError::InvalidConfig(m) => AppCommandError::new( + AppErrorCode::InvalidInput, + "Invalid loop config", + ) + .with_detail(m), + LoopError::Acp(m) => AppCommandError::task_execution_failed(m), + LoopError::Db(err) => { + AppCommandError::database_error("Database operation failed").with_detail(err.to_string()) + } + } + } +} diff --git a/src-tauri/src/loop_engine/fan_in.rs b/src-tauri/src/loop_engine/fan_in.rs new file mode 100644 index 0000000000..81bf704011 --- /dev/null +++ b/src-tauri/src/loop_engine/fan_in.rs @@ -0,0 +1,836 @@ +//! Parallel result-stage fan-in (spec §4.4): atomically integrate a parallel +//! issue's frozen per-task commits onto its issue branch, then synthesize the +//! result. +//! +//! The shape is a **deferred, recoverable, atomic** integration: +//! 1. Claim a write-once session lock — the versioned `fan_in_manifest` +//! (`{v, issue_base_oid, ordered:[{task_id, sha}]}`), distinct from the +//! in-flight-agent lease. `ordered` freezes the topological merge order so a +//! resume replays it rather than recomputing from mutable DB state. +//! 2. Merge each frozen task commit into a temp `integrate` worktree/branch +//! ([`worktree::fan_in_tasks`]) — resumable (already-merged commits skip), +//! conflict-aware (a conflict is handed to a result-stage agent that resolves +//! it and `git commit`s). +//! 3. CAS-land the integrate tip onto the issue branch +//! ([`worktree::cas_advance_branch`]) — atomic w.r.t. the issue branch, so a +//! crash mid-fan-in leaves the issue branch untouched (the integrate branch is +//! discardable). Only AFTER landing is the result artifact synthesized, so a +//! failed land never strands a result row blocking retry. +//! +//! Crash recovery (every step is re-entrant): +//! - **Already-landed detection** runs before any re-merge: if the issue branch +//! already contains every frozen commit (a prior land succeeded but we crashed +//! before finishing), we repair-and-finish idempotently WITHOUT re-validating — +//! so flaky re-validation can never block work that already landed. +//! - **Conflict-resolver liveness** is tracked by `fan_in_resolver_tip`: a +//! `MERGE_HEAD` with no resolver recorded for that tip is a crash-before-dispatch +//! (re-dispatch); a `MERGE_HEAD` at the recorded tip is a resolver that ran and +//! left it unresolved (block). +//! +//! Serial issues never enter here — they keep the agent-submitted finalize path. + +use std::path::Path; + +use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; +use serde::{Deserialize, Serialize}; + +use crate::db::entities::loop_artifact::{self, ArtifactKind, ArtifactStatus}; +use crate::db::entities::loop_artifact_revision::{self, ActorKind}; +use crate::db::entities::loop_inbox_item::InboxKind; +use crate::db::entities::loop_issue::{self, IssueStatus}; +use crate::db::entities::loop_iteration::{self, IterationStatus, Stage}; +use crate::db::entities::loop_link::{self, LinkKind}; +use crate::db::service::{folder_service, loop_service}; +use crate::db::AppDatabase; +use crate::models::loops::{IssueConfig, LoopArtifactRow, LoopDagView}; +use crate::web::event_bridge::EventEmitter; + +use crate::loop_engine::dispatch::{ + dispatch_iteration, emit_changed, DispatchInput, LoopAgentSpawner, +}; +use crate::loop_engine::driver::resolve_agent_spec; +use crate::loop_engine::error::LoopError; +use crate::loop_engine::gates::StepOutcome; +use crate::loop_engine::transitions::{ + cas_issue_status, clear_fan_in, set_fan_in_resolver_tip, try_claim_fan_in, +}; +use crate::loop_engine::worktree::{self, FanInOutcome}; + +/// One frozen task commit in the fan-in manifest. +#[derive(Debug, Clone, Serialize, Deserialize)] +struct FanInEntry { + task_id: i32, + sha: String, +} + +/// Versioned, write-once fan-in session manifest. `ordered` is the topological +/// merge order frozen at claim time — a resume replays it verbatim, never +/// recomputing from (mutable) DB state. +#[derive(Debug, Clone, Serialize, Deserialize)] +struct FanInManifest { + v: u32, + /// The issue branch tip at claim time — the integrate branch's base AND the + /// CAS `expected_old` for the landing. + issue_base_oid: String, + /// D12: stable epoch anchor — the active task set at claim time, sorted. Resume + /// validates "fan-in set complete" against THIS exact set, never live DB state. + /// `#[serde(default)]` tolerates a pre-D12 (v1) manifest that lacked it. + #[serde(default)] + active_task_ids: Vec, + /// Delta tasks (carry a frozen commit) — the topological merge order. + ordered: Vec, + /// D12: no-op tasks (agent-declared satisfied; no commit) — recorded for + /// provenance, skipped by the merge. `ordered ∪ skipped == active_task_ids`. + #[serde(default)] + skipped_no_op_task_ids: Vec, +} + +impl FanInManifest { + fn ordered_pairs(&self) -> Vec<(i32, String)> { + self.ordered + .iter() + .map(|e| (e.task_id, e.sha.clone())) + .collect() + } + + /// All member task ids — delta (merged) ∪ no-op (skipped). The provenance set + /// the result capstone links `ResultsFrom`, so no-op tasks are not dropped from + /// the lineage (D12). + fn all_member_task_ids(&self) -> Vec { + self.ordered + .iter() + .map(|e| e.task_id) + .chain(self.skipped_no_op_task_ids.iter().copied()) + .collect() + } +} + +fn parse_manifest(json: &str) -> Result { + let m: FanInManifest = serde_json::from_str(json) + .map_err(|e| LoopError::InvalidInput(format!("fan-in manifest decode: {e}")))?; + // D12 (Codex r1): validate the v2 partition on resume too, not only at build — + // `ordered ∪ skipped_no_op_task_ids == active_task_ids`. A v1 manifest (no + // `active_task_ids`, pre-D12) skips the check. Guards against a corrupted / + // hand-edited manifest stranding a task on replay. + if !m.active_task_ids.is_empty() { + let mut covered: Vec = m + .ordered + .iter() + .map(|e| e.task_id) + .chain(m.skipped_no_op_task_ids.iter().copied()) + .collect(); + covered.sort_unstable(); + let mut active = m.active_task_ids.clone(); + active.sort_unstable(); + if covered != active { + return Err(LoopError::Git( + "fan-in manifest partition does not cover the active task set (resume)".into(), + )); + } + } + Ok(m) +} + +/// The all-no_op fast-path decision (D12, Codex r2). An all-no_op manifest +/// (`ordered` empty) means no task contributed a commit, so the integration MUST be +/// exactly the issue base. This is decided BEFORE the landed-recovery check, whose +/// `all_frozen_ancestors` predicate is vacuously true for an empty `ordered` and +/// would otherwise mistake any moved tip for "already landed". +#[derive(Debug, PartialEq, Eq)] +enum NoOpGate { + /// Empty manifest and the branch is at base → finish (idempotent). + FinishAtBase, + /// Empty manifest but the branch advanced past base → anomalous; block, never + /// synthesize a result against an unreviewed tip. + BlockMovedTip, + /// Has frozen commits → not an all-no_op session; fall through to normal flow. + NotAllNoOp, +} + +fn no_op_gate(manifest: &FanInManifest, issue_tip: &str) -> NoOpGate { + if !manifest.ordered.is_empty() { + return NoOpGate::NotAllNoOp; + } + if issue_tip == manifest.issue_base_oid { + NoOpGate::FinishAtBase + } else { + NoOpGate::BlockMovedTip + } +} + +/// Drive a parallel issue's result-stage fan-in for one tick. Returns +/// [`StepOutcome`] like the gates: `Dispatched` (a conflict resolver is in +/// flight), `Advanced` (durable progress — landed / blocked / restarted; re-tick), +/// or `Idle` (waiting on in-flight work). Called from [`super::gates::run_finalize`] +/// only when the issue is `parallel` and its result does not yet exist. +#[allow(clippy::too_many_arguments)] +pub(crate) async fn run_parallel_finalize( + db: &AppDatabase, + data_dir: &Path, + spawner: &dyn LoopAgentSpawner, + emitter: &EventEmitter, + issue: &loop_issue::Model, + dag: &LoopDagView, + config: &IssueConfig, + issue_worktree_folder_id: i32, +) -> Result { + let conn = &db.conn; + + // Wait while any iteration is in flight (a conflict resolver, or stray work) + // — never reset/re-merge under a live agent. + if issue_has_inflight(db, issue.id).await? { + return Ok(StepOutcome::Idle); + } + + let space = loop_service::space::get_space(conn, issue.space_id) + .await? + .ok_or_else(|| LoopError::NotFound(format!("space {}", issue.space_id)))?; + let repo = folder_service::get_folder_by_id(conn, space.folder_id) + .await? + .ok_or(LoopError::Detached)?; + let repo_path = Path::new(&repo.path); + let issue_branch = format!("loop/{}/issue-{}", issue.space_id, issue.seq_no); + + // Claim or adopt the fan-in manifest (write-once session lock). Keep the exact + // stored JSON alongside the parsed form — `clear_fan_in` CAS-guards on it. + let (manifest, manifest_json) = match &issue.fan_in_manifest { + Some(j) => (parse_manifest(j)?, j.clone()), + None => { + let m = build_manifest(db, dag, issue_worktree_folder_id).await?; + let json = serde_json::to_string(&m) + .map_err(|e| LoopError::InvalidInput(format!("fan-in manifest encode: {e}")))?; + if try_claim_fan_in(conn, issue.id, &json).await? { + (m, json) + } else { + let fresh = loop_issue::Entity::find_by_id(issue.id) + .one(conn) + .await? + .and_then(|i| i.fan_in_manifest) + .ok_or_else(|| { + LoopError::Git("fan-in manifest vanished after a lost claim".into()) + })?; + (parse_manifest(&fresh)?, fresh) + } + } + }; + // Provenance set = ALL members (delta + no-op), so the result capstone links + // every contributing task, not just the merged ones (D12). + let task_ids = manifest.all_member_task_ids(); + + // Ensure the integrate worktree (attach-first preserves in-progress merges). + let integrate = worktree::ensure_integrate_worktree( + conn, + data_dir, + issue.id, + &manifest.issue_base_oid, + ) + .await?; + let integrate_path = integrate.worktree_path.clone(); + + let issue_tip = worktree::resolve_oid(repo_path, &format!("refs/heads/{issue_branch}")).await?; + + // [D12] All-no_op fast path, decided BEFORE the landed-recovery check below: + // `all_frozen_ancestors` is vacuously true for an empty `ordered`, so a moved tip + // would otherwise be mistaken for "already landed" and synthesize a result against + // an unreviewed tip (Codex r2). `finish_landed` is idempotent and the capstone + // still links every no-op member for provenance. + match no_op_gate(&manifest, &issue_tip) { + NoOpGate::FinishAtBase => { + return finish_landed( + db, + emitter, + issue, + &task_ids, + &manifest_json, + repo_path, + &integrate_path, + issue_worktree_folder_id, + ) + .await; + } + NoOpGate::BlockMovedTip => { + return block_fan_in( + db, + emitter, + issue, + "fan_in_all_no_op_unexpected_tip", + "every task declared a no-op but the issue branch advanced past its base", + ) + .await; + } + NoOpGate::NotAllNoOp => {} + } + + // [recovery] Already landed? A prior land advanced the issue branch but we + // crashed before synthesizing the result / clearing the session. The issue + // branch then contains every frozen commit (and `ordered` is non-empty, so this + // check is not vacuous). Repair-and-finish idempotently — crucially WITHOUT + // re-running the merge/validation, so flaky re-validation can never block work + // that already landed. + if issue_tip != manifest.issue_base_oid + && all_frozen_ancestors(repo_path, &issue_tip, &manifest).await? + { + return finish_landed( + db, + emitter, + issue, + &task_ids, + &manifest_json, + repo_path, + &integrate_path, + issue_worktree_folder_id, + ) + .await; + } + + // [recovery] A merge left mid-flight (MERGE_HEAD) with NO resolver in flight + // (we passed the in-flight gate). Distinguish the two ways that happens: + // - the integrate tip matches `fan_in_resolver_tip` → a resolver already ran + // from this exact tip and left the merge unresolved → structural block; + // - otherwise → we crashed after `fan_in_tasks` left MERGE_HEAD but before a + // resolver was dispatched (or the tip advanced past an earlier resolved + // conflict) → dispatch a resolver now. + if worktree::integrate_in_progress(&integrate_path).await { + let cur = worktree::head_commit(&integrate_path).await?; + if issue.fan_in_resolver_tip.as_deref() == Some(cur.as_str()) { + return block_fan_in( + db, + emitter, + issue, + "fan_in_conflict_unresolved", + "a fan-in merge conflict was left unresolved by the result-stage agent", + ) + .await; + } + return dispatch_resolver_at( + db, + data_dir, + spawner, + emitter, + issue, + config, + integrate.worktree_folder_id, + &cur, + ) + .await; + } + // Clear any stray uncommitted state (committed merges are preserved by HEAD). + worktree::reset_to_head(&integrate_path).await?; + + match worktree::fan_in_tasks( + &integrate_path, + &manifest.ordered_pairs(), + &config.validation_commands, + config.iteration_timeout_secs, + ) + .await? + { + FanInOutcome::Conflict { .. } => { + // Hand the in-progress merge to a result-stage agent that resolves it + // and `git commit`s (working in the integrate worktree). Record the tip + // we dispatch from so a resolver that fails to resolve is detected on + // re-entry (above) rather than re-dispatched forever. + let cur = worktree::head_commit(&integrate_path).await?; + dispatch_resolver_at( + db, + data_dir, + spawner, + emitter, + issue, + config, + integrate.worktree_folder_id, + &cur, + ) + .await + } + FanInOutcome::RevalidationFailed { .. } => { + block_fan_in( + db, + emitter, + issue, + "fan_in_revalidation_failed", + "the integrated tree failed re-validation; the task combination broke", + ) + .await + } + FanInOutcome::Integrated { tip } => { + land_integration( + db, + emitter, + issue, + &task_ids, + &manifest_json, + repo_path, + &integrate_path, + &issue_branch, + issue_worktree_folder_id, + &manifest.issue_base_oid, + &tip, + ) + .await + } + } +} + +/// CAS-land the integrate tip onto the issue branch, then finish (sync worktree, +/// synthesize result, tear down). A genuine lost CAS (the issue branch moved) +/// discards the integration and restarts; a hard `update-ref` error propagates +/// ([`worktree::cas_advance_branch`] disambiguates the two). +#[allow(clippy::too_many_arguments)] +async fn land_integration( + db: &AppDatabase, + emitter: &EventEmitter, + issue: &loop_issue::Model, + task_ids: &[i32], + manifest_json: &str, + repo_path: &Path, + integrate_path: &Path, + issue_branch: &str, + issue_worktree_folder_id: i32, + base_oid: &str, + tip: &str, +) -> Result { + let conn = &db.conn; + + if !worktree::cas_advance_branch(repo_path, issue_branch, tip, base_oid).await? { + // Lost CAS (the issue branch moved under us) → discard the integration, + // clear the session, and restart fresh next tick. + cleanup_integrate(issue, repo_path, integrate_path).await; + clear_fan_in(conn, issue.id, manifest_json).await?; + emit_changed(emitter, issue.space_id, issue.id, issue.id, "iteration"); + return Ok(StepOutcome::Advanced); + } + + finish_landed( + db, + emitter, + issue, + task_ids, + manifest_json, + repo_path, + integrate_path, + issue_worktree_folder_id, + ) + .await +} + +/// Finish a landed fan-in: sync the issue worktree to the new tip, synthesize the +/// result (AFTER the worktree is clean), clear the session, tear down the integrate +/// worktree. Re-entrant — each step is idempotent, so a crash anywhere replays via +/// the already-landed detection. +#[allow(clippy::too_many_arguments)] +async fn finish_landed( + db: &AppDatabase, + emitter: &EventEmitter, + issue: &loop_issue::Model, + task_ids: &[i32], + manifest_json: &str, + repo_path: &Path, + integrate_path: &Path, + issue_worktree_folder_id: i32, +) -> Result { + let conn = &db.conn; + + // Sync the issue worktree to the landed tip FIRST. `update-ref` moved the branch + // ref but not the worktree's tree; that tree is now stale (a reverse diff vs + // HEAD). It MUST be reset before the result exists — otherwise the shared + // finalize tail (run once `has_result`) would `checkpoint` the stale tree, + // committing a reverse diff onto the issue branch. We have not yet created the + // result, so a failure here simply re-ticks (already-landed detection retries) + // and never strands a half-finished issue with a dirty tree. + if let Some(folder) = folder_service::get_folder_by_id(conn, issue_worktree_folder_id).await? { + let p = Path::new(&folder.path); + if p.exists() { + worktree::reset_to_head(p).await?; + } + } + + // Produce the result AFTER the worktree is clean and the branch has landed — a + // failed land never strands a result row, and the result is the durable + // done-marker, so it is created before the session lock is cleared. + create_result_artifact(conn, issue, task_ids).await?; + + clear_fan_in(conn, issue.id, manifest_json).await?; + cleanup_integrate(issue, repo_path, integrate_path).await; + emit_changed(emitter, issue.space_id, issue.id, issue.id, "iteration"); + // Result now exists → re-tick: run_finalize's shared tail opens the merge gate. + Ok(StepOutcome::Advanced) +} + +/// Whether every frozen task commit in the manifest is an ancestor of `tip` — i.e. +/// the integration already landed on the issue branch. +async fn all_frozen_ancestors( + repo_path: &Path, + tip: &str, + manifest: &FanInManifest, +) -> Result { + for e in &manifest.ordered { + if !worktree::is_ancestor(repo_path, &e.sha, tip).await? { + return Ok(false); + } + } + Ok(true) +} + +/// Build the manifest from the current Done-task set. `issue_base_oid` is the +/// issue branch tip (CAS `expected_old`); `ordered` is the Done tasks by +/// `(sort, id)` with their frozen commits. +async fn build_manifest( + db: &AppDatabase, + dag: &LoopDagView, + issue_worktree_folder_id: i32, +) -> Result { + let folder = folder_service::get_folder_by_id(&db.conn, issue_worktree_folder_id) + .await? + .ok_or_else(|| LoopError::NotFound(format!("worktree folder {issue_worktree_folder_id}")))?; + let issue_base_oid = worktree::head_commit(Path::new(&folder.path)).await?; + + let mut tasks: Vec<&LoopArtifactRow> = dag + .artifacts + .iter() + .filter(|a| a.kind == ArtifactKind::Task && a.status == ArtifactStatus::Done) + .collect(); + // `(sort, id)` IS a valid topological order: ingest assigns `sort` by batch + // index and rejects forward / multi `depends_on` references (backward-only), so + // a predecessor always has a smaller `sort` than its successor. (Order is in any + // case non-critical for the final tree — a successor's frozen commit already + // contains its predecessor's, so out-of-order merges resolve by ancestry — but + // a topological order keeps the merge sequence and any conflict blame sane.) + tasks.sort_by(|a, b| a.sort.cmp(&b.sort).then(a.id.cmp(&b.id))); + + // D12: partition Done tasks by contribution. Delta tasks (carry a frozen + // commit) form the merge order; no-op tasks (declared satisfied, NULL commit) + // are recorded for provenance and skipped by the merge. + let mut ordered = Vec::with_capacity(tasks.len()); + let mut skipped_no_op_task_ids = Vec::new(); + for t in &tasks { + // `fan_in_commit` / `contribution_kind` live on the raw row, not the DTO. + let row = loop_artifact::Entity::find_by_id(t.id) + .one(&db.conn) + .await? + .ok_or_else(|| LoopError::NotFound(format!("task {}", t.id)))?; + match row.contribution_kind { + loop_artifact::ContributionKind::Delta => { + let sha = row.fan_in_commit.ok_or_else(|| { + LoopError::Git(format!( + "delta task {} has no frozen commit (invariant)", + t.id + )) + })?; + ordered.push(FanInEntry { task_id: t.id, sha }); + } + loop_artifact::ContributionKind::NoOp => skipped_no_op_task_ids.push(t.id), + } + } + + // Stable epoch anchor (r4 I4): the active task set at claim time, sorted. Every + // active task is Done here (the run_finalize gate), so this is exactly the + // partition's union — assert the partition is total so a future bug that drops a + // task from both buckets fails loudly rather than silently losing it. + let mut active_task_ids: Vec = tasks.iter().map(|t| t.id).collect(); + active_task_ids.sort_unstable(); + let mut covered: Vec = ordered + .iter() + .map(|e| e.task_id) + .chain(skipped_no_op_task_ids.iter().copied()) + .collect(); + covered.sort_unstable(); + if covered != active_task_ids { + return Err(LoopError::Git( + "fan-in manifest partition does not cover the active task set (invariant)".into(), + )); + } + + Ok(FanInManifest { + v: 2, + issue_base_oid, + active_task_ids, + ordered, + skipped_no_op_task_ids, + }) +} + +/// Engine-synthesized result capstone (parallel mode produces no agent-submitted +/// result). Idempotent and crash-repairing: a prior partial run that created the +/// row but not its revision / links is completed, not skipped. Links `ResultsFrom` +/// to exactly the manifest's integrated tasks (not the live DAG, which could +/// diverge from what was actually integrated). +async fn create_result_artifact( + conn: &sea_orm::DatabaseConnection, + issue: &loop_issue::Model, + task_ids: &[i32], +) -> Result<(), LoopError> { + let existing = loop_artifact::Entity::find() + .filter(loop_artifact::Column::IssueId.eq(issue.id)) + .filter(loop_artifact::Column::Kind.eq(ArtifactKind::Result)) + .one(conn) + .await?; + let art = match existing { + Some(a) => a, + None => { + loop_service::artifact::create_artifact( + conn, + issue.space_id, + issue.id, + ArtifactKind::Result, + "Result", + ArtifactStatus::Done, + ActorKind::Agent, + None, + ) + .await? + } + }; + + // Repair-safe: ensure a revision exists (a crash could have created the row + // alone, and the early-return-on-existing would otherwise leave it empty). + let has_revision = loop_artifact_revision::Entity::find() + .filter(loop_artifact_revision::Column::ArtifactId.eq(art.id)) + .one(conn) + .await? + .is_some(); + if !has_revision { + let summary = format!( + "Integrated {} parallel task(s) into the issue branch.", + task_ids.len() + ); + loop_service::artifact::add_revision(conn, art.id, &summary, ActorKind::Agent, None).await?; + } + + // Ensure a `ResultsFrom` link to each integrated task (skip ones already linked + // by a prior partial run). + let linked: std::collections::HashSet = loop_link::Entity::find() + .filter(loop_link::Column::FromArtifactId.eq(art.id)) + .filter(loop_link::Column::Kind.eq(LinkKind::ResultsFrom)) + .all(conn) + .await? + .into_iter() + .map(|l| l.to_artifact_id) + .collect(); + for &task_id in task_ids { + if !linked.contains(&task_id) { + loop_service::link::create_link( + conn, + issue.space_id, + art.id, + task_id, + LinkKind::ResultsFrom, + None, + ) + .await?; + } + } + Ok(()) +} + +/// Record the dispatch tip and dispatch a result-stage agent to resolve the +/// in-progress fan-in merge. It runs in the **integrate** worktree (so its +/// `git commit` completes the merge there); its briefing (parallel finalize) tells +/// it to resolve conflicts and commit. The recorded `fan_in_resolver_tip` lets a +/// later tick tell "resolver ran and failed" from "crashed before dispatch". +#[allow(clippy::too_many_arguments)] +async fn dispatch_resolver_at( + db: &AppDatabase, + data_dir: &Path, + spawner: &dyn LoopAgentSpawner, + emitter: &EventEmitter, + issue: &loop_issue::Model, + config: &IssueConfig, + integrate_worktree_folder_id: i32, + tip: &str, +) -> Result { + set_fan_in_resolver_tip(&db.conn, issue.id, tip).await?; + let dispatched = dispatch_conflict_resolver( + db, + data_dir, + spawner, + emitter, + issue, + config, + integrate_worktree_folder_id, + ) + .await?; + Ok(if dispatched { + StepOutcome::Dispatched + } else { + StepOutcome::Idle + }) +} + +/// Dispatch a result-stage agent (finalize stage) into the integrate worktree. +async fn dispatch_conflict_resolver( + db: &AppDatabase, + data_dir: &Path, + spawner: &dyn LoopAgentSpawner, + emitter: &EventEmitter, + issue: &loop_issue::Model, + config: &IssueConfig, + integrate_worktree_folder_id: i32, +) -> Result { + let spec = resolve_agent_spec(config, Stage::Finalize); + let handle = dispatch_iteration( + db, + data_dir, + spawner, + emitter.clone(), + DispatchInput { + space_id: issue.space_id, + issue_id: issue.id, + stage: Stage::Finalize, + target_artifact_id: None, + slot_no: None, + attempt: 0, + agent_type: spec.agent, + mode_id: spec.mode_id, + config_values: spec.config_values, + worktree_folder_id: integrate_worktree_folder_id, + }, + ) + .await?; + Ok(handle.is_some()) +} + +/// Block the issue on a structural fan-in fault (unresolved conflict / failed +/// re-validation) with a deduped inbox card, and report `Advanced` so the driver +/// re-ticks and stops on the now-blocked issue. +async fn block_fan_in( + db: &AppDatabase, + emitter: &EventEmitter, + issue: &loop_issue::Model, + subject_prefix: &str, + reason: &str, +) -> Result { + cas_issue_status(&db.conn, issue.id, IssueStatus::Running, IssueStatus::Blocked).await?; + loop_service::inbox::upsert_inbox( + &db.conn, + issue.space_id, + issue.id, + None, + InboxKind::Blocked, + &format!("{subject_prefix}:{}", issue.id), + serde_json::json!({ "v": 1, "reason": reason }), + ) + .await?; + emit_changed(emitter, issue.space_id, issue.id, issue.id, "blocked"); + Ok(StepOutcome::Advanced) +} + +/// Remove the integrate worktree + force-delete its branch (best-effort — the +/// create path reconciles any leftover). +async fn cleanup_integrate(issue: &loop_issue::Model, repo_path: &Path, integrate_path: &Path) { + let _ = worktree::remove_worktree(repo_path, integrate_path).await; + let branch = format!("loop/{}/issue-{}-integrate", issue.space_id, issue.seq_no); + let _ = worktree::delete_branch(repo_path, &branch, true).await; +} + +/// Whether the issue has any queued/running iteration. +async fn issue_has_inflight(db: &AppDatabase, issue_id: i32) -> Result { + Ok(loop_iteration::Entity::find() + .filter(loop_iteration::Column::IssueId.eq(issue_id)) + .filter( + loop_iteration::Column::Status + .is_in([IterationStatus::Queued, IterationStatus::Running]), + ) + .one(&db.conn) + .await? + .is_some()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// D12: the provenance set is delta (merged) ∪ no-op (skipped) — every member, + /// so the result capstone never drops a no-op task from the lineage. + #[test] + fn all_member_task_ids_unions_delta_and_no_op() { + let m = FanInManifest { + v: 2, + issue_base_oid: "base".into(), + active_task_ids: vec![1, 2, 3, 4], + ordered: vec![ + FanInEntry { task_id: 1, sha: "a".into() }, + FanInEntry { task_id: 3, sha: "c".into() }, + ], + skipped_no_op_task_ids: vec![2, 4], + }; + let mut members = m.all_member_task_ids(); + members.sort_unstable(); + assert_eq!(members, vec![1, 2, 3, 4]); + // The merge order (ordered_pairs) carries ONLY delta commits. + assert_eq!( + m.ordered_pairs(), + vec![(1, "a".to_string()), (3, "c".to_string())] + ); + } + + /// A v2 manifest round-trips through the stored-JSON encode/parse path. + #[test] + fn manifest_v2_round_trips() { + let m = FanInManifest { + v: 2, + issue_base_oid: "deadbeef".into(), + active_task_ids: vec![5, 7], + ordered: vec![FanInEntry { task_id: 5, sha: "s5".into() }], + skipped_no_op_task_ids: vec![7], + }; + let json = serde_json::to_string(&m).unwrap(); + let back = parse_manifest(&json).unwrap(); + assert_eq!(back.v, 2); + assert_eq!(back.active_task_ids, vec![5, 7]); + assert_eq!(back.skipped_no_op_task_ids, vec![7]); + assert_eq!(back.all_member_task_ids(), vec![5, 7]); + } + + /// A pre-D12 (v1) manifest with no no-op fields still parses — the new fields + /// default to empty, so an all-delta manifest behaves exactly as before. + #[test] + fn manifest_v1_shape_parses_with_empty_defaults() { + let v1 = r#"{"v":1,"issue_base_oid":"base","ordered":[{"task_id":1,"sha":"a"}]}"#; + let m = parse_manifest(v1).unwrap(); + assert!(m.active_task_ids.is_empty()); + assert!(m.skipped_no_op_task_ids.is_empty()); + assert_eq!(m.all_member_task_ids(), vec![1]); + } + + /// Codex r1 regression: parse_manifest validates the v2 partition on resume. + /// A manifest whose `ordered ∪ skipped_no_op_task_ids` does not equal + /// `active_task_ids` (here task 9 is stranded by corruption / hand-edit) is + /// rejected rather than silently dropping that task from the merge. + #[test] + fn manifest_v2_partition_mismatch_rejected_on_parse() { + let bad = r#"{"v":2,"issue_base_oid":"base","active_task_ids":[5,7,9],"ordered":[{"task_id":5,"sha":"s5"}],"skipped_no_op_task_ids":[7]}"#; + let err = parse_manifest(bad).unwrap_err(); + assert!( + matches!(err, LoopError::Git(_)), + "partition mismatch should be rejected, got {err:?}" + ); + } + + /// Codex r2: the all-no_op gate (decided before the landed-recovery check) finishes + /// ONLY when `tip == base`; a moved tip blocks rather than synthesizing a result + /// against an unreviewed tip, and a manifest with any frozen commit is not all-no_op. + #[test] + fn no_op_gate_finishes_only_at_base() { + let all_no_op = FanInManifest { + v: 2, + issue_base_oid: "base".into(), + active_task_ids: vec![1, 2], + ordered: vec![], + skipped_no_op_task_ids: vec![1, 2], + }; + assert_eq!(no_op_gate(&all_no_op, "base"), NoOpGate::FinishAtBase); + assert_eq!(no_op_gate(&all_no_op, "moved"), NoOpGate::BlockMovedTip); + + // A manifest with a frozen commit is never the all-no_op path, regardless of tip. + let has_delta = FanInManifest { + v: 2, + issue_base_oid: "base".into(), + active_task_ids: vec![1, 2], + ordered: vec![FanInEntry { task_id: 1, sha: "a".into() }], + skipped_no_op_task_ids: vec![2], + }; + assert_eq!(no_op_gate(&has_delta, "base"), NoOpGate::NotAllNoOp); + assert_eq!(no_op_gate(&has_delta, "moved"), NoOpGate::NotAllNoOp); + } +} diff --git a/src-tauri/src/loop_engine/gates.rs b/src-tauri/src/loop_engine/gates.rs new file mode 100644 index 0000000000..41377ae9c6 --- /dev/null +++ b/src-tauri/src/loop_engine/gates.rs @@ -0,0 +1,4388 @@ +//! Write-pipeline stage gates (§4.5): implement → validate → review → finalize. +//! +//! **implement** is the first stage that changes code. It is unlike the read +//! stages in two ways: +//! +//! - **No submission.** The implement agent edits files in the worktree and +//! calls no `loop_submit_*` tool (its briefing tool-contract says so). The +//! engine measures progress by *checkpointing*: a non-empty diff that commits +//! is success; an empty diff is no progress. +//! - **Per-task isolation, concurrent across tasks.** There is no per-issue +//! write gate. A `parallel` issue drives every ready/in-review task at once, +//! each in its **own** worktree (so two tasks never race on one tree); the +//! `(issue, target)` / review-slot dispatch leases keep a repeated tick from +//! double-dispatching a task. A `serial` (or not-yet-decided) issue shares the +//! issue worktree, so it drives exactly one task at a time — a serial chain +//! yields ≤1 ready task anyway. +//! +//! Idempotency across ticks keys on `iteration.attempt == task.attempt`: a +//! settled implement iteration is only checkpointed once, because a no-progress +//! checkpoint bumps the task's rework counter and the next dispatch carries the +//! new attempt. + +use std::collections::HashMap; +use std::path::Path; +use std::sync::LazyLock; +use std::time::Duration; + +use chrono::Utc; +use regex::Regex; +use sea_orm::sea_query::Expr; +use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; + +use crate::db::entities::loop_artifact::{self, ArtifactKind, ArtifactStatus, ReviewVerdict}; +use crate::db::entities::loop_criterion_check::CheckVerdict; +use crate::db::entities::loop_gate_decision::GateOutcome; +use crate::db::entities::loop_inbox_item::InboxKind; +use crate::db::entities::loop_issue::{self, IssueStatus}; +use crate::db::entities::loop_iteration::{self, IterationOutcome, IterationStatus, Stage}; +use crate::db::entities::loop_link::LinkKind; +use crate::db::service::{folder_service, loop_service}; +use crate::db::AppDatabase; +use crate::models::loops::{ + IssueConfig, LoopArtifactRow, LoopCriterionCheckRow, LoopDagView, ReviewPassRule, ReviewerSpec, +}; +use crate::web::event_bridge::EventEmitter; + +use crate::loop_engine::dispatch::{ + dispatch_iteration, emit_changed, over_budget, pause_for_budget, DispatchInput, + LoopAgentSpawner, +}; +use crate::loop_engine::driver::resolve_agent_spec; +use crate::loop_engine::error::LoopError; +use crate::loop_engine::transitions::{ + self, cas_artifact_status, cas_issue_status, cas_iteration_status, + cas_task_done_with_contribution, TaskContribution, +}; +use crate::loop_engine::validation::{self, ValidationOutcome}; +use crate::loop_engine::worktree; + +/// Outcome of checkpointing + validating a settled implement iteration. +enum ImplementOutcome { + /// Non-empty diff committed and validation passed (or none configured) → task + /// promoted to `in_progress` (implemented, awaiting review). + Advanced, + /// Empty diff, or validation reported failures → rework counter bumped; the + /// caller re-dispatches implement at the next attempt. + NoProgress, + /// The task was blocked — either validation could not run (missing tool / + /// timeout) or a no-progress breaker tripped (max attempts / repeated + /// failure). An inbox card is filed; the caller idles until a human + /// intervenes. + Blocked, +} + +/// Result of one write-pipeline gate step — the driver uses it to decide whether +/// to re-tick immediately or park. +/// +/// * `Dispatched` — a new iteration was launched (now in flight). Park; its +/// settlement wakes the driver. +/// * `Advanced` — the engine's **durable** state moved forward (task promoted / +/// task gate released / rework counter bumped / issue blocked) but **nothing** +/// is in flight. The next tick must re-read state to dispatch the follow-on +/// step, or observe the issue leaving `running` and stop. The driver therefore +/// re-ticks immediately; otherwise it would park on the no-timeout wake and +/// wedge. **Invariant: returning `Advanced` requires a real durable change** — +/// otherwise a stale snapshot would re-enter the same arm and hot-spin. +/// * `Idle` — nothing to do: an iteration is still in flight (await its wake), a +/// human gate is open, or there is no pending work. Park. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum StepOutcome { + Dispatched, + Advanced, + Idle, +} + +impl StepOutcome { + /// Lift a raw "did it dispatch?" bool into a step outcome. + fn from_dispatched(dispatched: bool) -> Self { + if dispatched { + StepOutcome::Dispatched + } else { + StepOutcome::Idle + } + } + + /// Combine the outcomes of the several tasks driven in one tick. Priority: + /// `Advanced` > `Dispatched` > `Idle`. Any durable change (a task promoted / + /// done / blocked, a rework bump) forces a re-tick so the driver re-reads the + /// now-changed frontier (a done task may unblock a dependent or open finalize; + /// a blocked issue must stop). Else, if anything launched, park awaiting its + /// settlement; else idle. + fn merge(self, other: StepOutcome) -> StepOutcome { + use StepOutcome::*; + match (self, other) { + (Advanced, _) | (_, Advanced) => Advanced, + (Dispatched, _) | (_, Dispatched) => Dispatched, + _ => Idle, + } + } +} + +/// Consecutive per-task infrastructure failures (e.g. worktree creation) tolerated +/// before the task + issue are blocked. NOT a business cap — a pure safety net so a +/// genuinely broken environment surfaces as a `blocked` card instead of an infinite +/// retry/log loop. The count is driver-memory, per task, reset on any success and +/// pruned when the task leaves the drivable set, so unrelated transient failures +/// never accumulate into a false block. +const INFRA_RETRY_MAX: u32 = 5; + +/// Drive the issue's tasks through the write pipeline (implement → validate → +/// review) for one tick. See [`StepOutcome`] for how the driver reacts to the +/// return value. +/// +/// No per-issue write gate: a `parallel` issue fans out over **every** drivable +/// task (each in its own worktree, dispatch idempotent via the `(issue, target)` +/// / review-slot leases); a `serial`/undecided issue drives exactly one task at a +/// time (sharing the issue worktree). A no-op while no task exists yet (read +/// stages still in flight), so the driver can call it on every "read frontier +/// empty" tick. +/// +/// `infra_retries` is the driver's per-task infrastructure-failure counter (keyed +/// by task id). A worktree-ensure failure increments it and skips that task — +/// siblings still run — and `run_driver` keeps re-ticking (it arms a timer while +/// the map is non-empty) until the worktree succeeds or [`INFRA_RETRY_MAX`] trips. +#[allow(clippy::too_many_arguments)] +pub(crate) async fn drive_active_task( + db: &AppDatabase, + data_dir: &Path, + spawner: &dyn LoopAgentSpawner, + emitter: &EventEmitter, + issue: &loop_issue::Model, + dag: &LoopDagView, + config: &IssueConfig, + worktree_folder_id: i32, + infra_retries: &mut HashMap, +) -> Result { + // Budget pre-check (dispatch-time half of the double-check): refuse to start + // new task work once the issue has reached its budget. Parallel fan-out can + // otherwise launch several writes before any settles and trips the settle-time + // breaker. In-flight work still settles (and may mildly overspend — budget is + // not reserved); this only stops NEW dispatch and pauses so the driver halts + // next tick. + if over_budget(issue) { + if pause_for_budget(&db.conn, issue, None).await? { + emit_changed(emitter, issue.space_id, issue.id, issue.id, "budget"); + } + return Ok(StepOutcome::Advanced); + } + + // The tasks that can make progress this tick: those mid-review + // (`in_progress`) and those whose every dependency is `Done` (ready pending). + // `in_progress` first so a serial issue continues an in-flight task's review + // before starting a fresh one. + let mut drivable: Vec = dag + .artifacts + .iter() + .filter(|a| a.kind == ArtifactKind::Task && a.status == ArtifactStatus::InProgress) + .map(|a| a.id) + .collect(); + drivable.extend(ready_tasks(dag).into_iter().map(|t| t.id)); + + if drivable.is_empty() { + // Nothing drivable. If a pending task is wedged behind a Blocked / + // Cancelled dependency that can never become Done, block the issue + // (retry-reachable) instead of parking silently. + return detect_dead_dependency(db, emitter, issue, dag).await; + } + + // Parallel issues fan out — each drivable task runs in its OWN worktree, so + // concurrent dispatch is safe. Serial / undecided issues would share the issue + // worktree, so drive exactly one task (the safety floor for the not-yet-decided + // case; a serial chain yields ≤1 ready task regardless). + if issue.execution_mode.as_deref() != Some("parallel") { + drivable.truncate(1); + } + + let drivable_set: std::collections::HashSet = drivable.iter().copied().collect(); + let mut outcome = StepOutcome::Idle; + for task_id in &drivable { + let task_id = *task_id; + let wt = match task_worktree_folder(db, data_dir, issue, task_id, worktree_folder_id).await { + Ok(wt) => wt, + Err(e) => { + // Infra failure (e.g. worktree creation). Don't abort the tick or + // starve sibling tasks — count it and, after a bounded run of + // consecutive failures, block the task + issue (a real, persistent + // environment fault). Otherwise skip it this tick; `run_driver` + // re-ticks (it arms a timer while `infra_retries` is non-empty). + let n = infra_retries.entry(task_id).or_insert(0); + *n += 1; + tracing::warn!( + issue_id = issue.id, + task_id, + attempt = *n, + error = %e, + "drive: task worktree ensure failed" + ); + if *n >= INFRA_RETRY_MAX { + infra_retries.remove(&task_id); + block_task_infra(db, emitter, issue, task_id).await?; + outcome = outcome.merge(StepOutcome::Advanced); + } + continue; + } + }; + // Worktree is available → clear any prior failure streak for this task. + infra_retries.remove(&task_id); + let step = + advance_active_task(db, data_dir, spawner, emitter, issue, dag, config, wt, task_id) + .await?; + outcome = outcome.merge(step); + } + // Drop failure counts for tasks no longer drivable (reached a terminal state), + // so an unrelated transient failure can never accumulate into a false block. + infra_retries.retain(|tid, _| drivable_set.contains(tid)); + Ok(outcome) +} + +/// The worktree folder a task's write-pipeline iterations (implement / review / +/// checkpoint / validation) run in. Parallel-mode issues give each task its own +/// worktree — ensured idempotently here so two concurrently-driven tasks never +/// share a tree; serial-mode issues share the issue worktree. The ensure can fail +/// (a transient infra error); the caller treats that as a bounded-retry skip +/// rather than aborting the whole tick. +async fn task_worktree_folder( + db: &AppDatabase, + data_dir: &Path, + issue: &loop_issue::Model, + task_id: i32, + issue_worktree_folder_id: i32, +) -> Result { + if issue.execution_mode.as_deref() == Some("parallel") { + let ctx = worktree::ensure_task_worktree(&db.conn, data_dir, issue.id, task_id).await?; + Ok(ctx.worktree_folder_id) + } else { + Ok(issue_worktree_folder_id) + } +} + +/// Whether `task` (pending) transitively depends on a `Blocked` or `Cancelled` +/// task — a predecessor that can never become `Done`, so the task can never +/// start. Walks the `DependsOn` closure (from = successor, to = predecessor); +/// the submit-time acyclicity guard bounds the walk. +fn has_dead_dependency(dag: &LoopDagView, task_id: i32) -> bool { + let mut stack = vec![task_id]; + let mut seen = std::collections::HashSet::new(); + while let Some(cur) = stack.pop() { + if !seen.insert(cur) { + continue; + } + for l in dag + .links + .iter() + .filter(|l| l.kind == LinkKind::DependsOn && l.from_artifact_id == cur) + { + match dag.artifacts.iter().find(|a| a.id == l.to_artifact_id) { + Some(p) + if matches!( + p.status, + ArtifactStatus::Blocked | ArtifactStatus::Cancelled + ) => + { + return true; + } + Some(_) => stack.push(l.to_artifact_id), + None => {} + } + } + } + false +} + +/// Whether the issue has any queued/running iteration. +async fn issue_has_inflight(db: &AppDatabase, issue_id: i32) -> Result { + Ok(loop_iteration::Entity::find() + .filter(loop_iteration::Column::IssueId.eq(issue_id)) + .filter( + loop_iteration::Column::Status + .is_in([IterationStatus::Queued, IterationStatus::Running]), + ) + .one(&db.conn) + .await? + .is_some()) +} + +/// Called when the gate is free and no task is ready. If a pending task is wedged +/// behind a `Blocked`/`Cancelled` dependency and nothing is in flight, the issue +/// can never progress on its own — block it (retry-reachable) with an inbox card +/// rather than parking silently. Otherwise idle (all done → finalize handles it; +/// or work is still in flight that may yet open the frontier). +async fn detect_dead_dependency( + db: &AppDatabase, + emitter: &EventEmitter, + issue: &loop_issue::Model, + dag: &LoopDagView, +) -> Result { + let pending: Vec<&LoopArtifactRow> = dag + .artifacts + .iter() + .filter(|a| a.kind == ArtifactKind::Task && a.status == ArtifactStatus::Pending) + .collect(); + if pending.is_empty() { + return Ok(StepOutcome::Idle); // nothing pending → not a dead end (finalize path) + } + if issue_has_inflight(db, issue.id).await? { + return Ok(StepOutcome::Idle); // in-flight work may yet open the frontier + } + if pending.iter().any(|t| has_dead_dependency(dag, t.id)) { + if cas_issue_status(&db.conn, issue.id, IssueStatus::Running, IssueStatus::Blocked).await? { + loop_service::inbox::upsert_inbox( + &db.conn, + issue.space_id, + issue.id, + None, + InboxKind::Blocked, + &format!("dependency_unsatisfiable:{}", issue.id), + serde_json::json!({ "v": 1, "reason": "dependency_unsatisfiable" }), + ) + .await?; + emit_changed(emitter, issue.space_id, issue.id, issue.id, "blocked"); + } + // Issue now blocked → re-tick so the driver observes it and stops. + return Ok(StepOutcome::Advanced); + } + Ok(StepOutcome::Idle) +} + +/// Tasks whose every `DependsOn` predecessor is `Done` — the dependency-aware +/// ready frontier. Edge contract: a `DependsOn` link is `from = successor`, +/// `to = predecessor`, so a task is ready when all links whose `from` is the task +/// point to `Done` tasks. Deterministic order by `(sort, id)` so downstream +/// dispatch/topology is stable. A root task (no `DependsOn` edges) is ready as +/// soon as it is `pending`. (Serial/single-chain issues yield ≤1 ready task, so +/// taking the first preserves today's behavior; phase 2 dispatches the whole set.) +fn ready_tasks(dag: &LoopDagView) -> Vec<&LoopArtifactRow> { + let done: std::collections::HashSet = dag + .artifacts + .iter() + .filter(|a| a.status == ArtifactStatus::Done) + .map(|a| a.id) + .collect(); + let mut out: Vec<&LoopArtifactRow> = dag + .artifacts + .iter() + .filter(|a| a.kind == ArtifactKind::Task && a.status == ArtifactStatus::Pending) + .filter(|t| { + dag.links + .iter() + .filter(|l| l.kind == LinkKind::DependsOn && l.from_artifact_id == t.id) + .all(|l| done.contains(&l.to_artifact_id)) + }) + .collect(); + out.sort_by(|a, b| a.sort.cmp(&b.sort).then(a.id.cmp(&b.id))); + out +} + +/// Route one drivable task to its write-pipeline stage by status: `pending` +/// implements, `in_progress` (implemented + validated) reviews, terminal idles. +#[allow(clippy::too_many_arguments)] +async fn advance_active_task( + db: &AppDatabase, + data_dir: &Path, + spawner: &dyn LoopAgentSpawner, + emitter: &EventEmitter, + issue: &loop_issue::Model, + dag: &LoopDagView, + config: &IssueConfig, + worktree_folder_id: i32, + active_task_id: i32, +) -> Result { + let Some(task) = dag.artifacts.iter().find(|a| a.id == active_task_id) else { + // Gate points at a node not in this DAG — nothing to drive. + return Ok(StepOutcome::Idle); + }; + match task.status { + ArtifactStatus::Pending => { + advance_implement(db, data_dir, spawner, emitter, issue, config, worktree_folder_id, task) + .await + } + ArtifactStatus::InProgress => { + drive_reviews(db, data_dir, spawner, emitter, issue, config, worktree_folder_id, task) + .await + } + // Done (gate released on review pass), blocked (awaiting a human retry), + // cancelled, etc. → idle. + _ => Ok(StepOutcome::Idle), + } +} + +/// Advance a `pending` task's implement: wait while its iteration is in flight, +/// checkpoint + validate once settled, or (re)dispatch when nothing is live. +#[allow(clippy::too_many_arguments)] +async fn advance_implement( + db: &AppDatabase, + data_dir: &Path, + spawner: &dyn LoopAgentSpawner, + emitter: &EventEmitter, + issue: &loop_issue::Model, + config: &IssueConfig, + worktree_folder_id: i32, + task: &LoopArtifactRow, +) -> Result { + let impls = implement_iterations(db, issue.id, task.id).await?; + if impls + .iter() + .any(|it| matches!(it.status, IterationStatus::Queued | IterationStatus::Running)) + { + // Implement in flight — wait for its completion to wake us. + return Ok(StepOutcome::Idle); + } + + // A succeeded implement at the current attempt is awaiting its checkpoint + + // validation. + let settled = impls + .iter() + .find(|it| it.status == IterationStatus::Succeeded && it.attempt == task.attempt); + if let Some(settled) = settled { + match finish_implement(db, emitter, issue, config, worktree_folder_id, task, settled.id) + .await? + { + // Promoted to in_progress → re-tick to dispatch review. + ImplementOutcome::Advanced => Ok(StepOutcome::Advanced), + // Task (and possibly the issue) was blocked → re-tick: a blocked issue + // stops + deregisters the driver (so a human retry's respawn takes + // effect); a task-only block (issue still running) lands on + // `advance_active_task`'s idle arm and parks awaiting a human. + ImplementOutcome::Blocked => Ok(StepOutcome::Advanced), + ImplementOutcome::NoProgress => { + // The rework counter was bumped (durable progress); retry implement + // at the new attempt. If the write lease was momentarily busy and + // nothing launched, still Advanced so the next tick re-attempts (it + // lands on the in-flight idle arm if a retry is by then running). + let dispatched = dispatch_implement( + db, + data_dir, + spawner, + emitter, + issue, + config, + worktree_folder_id, + task.id, + task.attempt + 1, + ) + .await?; + Ok(if dispatched { + StepOutcome::Dispatched + } else { + StepOutcome::Advanced + }) + } + } + } else { + // Gate held but nothing live or freshly settled (just acquired, or a + // prior attempt already processed) → (re)dispatch implement. + let dispatched = dispatch_implement( + db, + data_dir, + spawner, + emitter, + issue, + config, + worktree_folder_id, + task.id, + task.attempt, + ) + .await?; + Ok(StepOutcome::from_dispatched(dispatched)) + } +} + +/// Checkpoint, then validate, a settled implement iteration. An empty diff is +/// discarded as no progress; a committed diff is handed to validation, whose +/// outcome decides advance / rework / block. +async fn finish_implement( + db: &AppDatabase, + emitter: &EventEmitter, + issue: &loop_issue::Model, + config: &IssueConfig, + worktree_folder_id: i32, + task: &LoopArtifactRow, + iteration_id: i32, +) -> Result { + let conn = &db.conn; + let folder = folder_service::get_folder_by_id(conn, worktree_folder_id) + .await? + .ok_or_else(|| LoopError::NotFound(format!("worktree folder {worktree_folder_id}")))?; + let worktree_path = Path::new(&folder.path); + + let message = format!("loop: implement #{} (issue #{})", task.id, issue.seq_no); + match worktree::checkpoint(worktree_path, &message).await? { + Some(_sha) => { + validate_after_implement(db, emitter, issue, config, worktree_path, task, iteration_id) + .await + } + None => { + // No diff to accept. Discard any stray uncommitted state either way. + worktree::reset_to_head(worktree_path).await?; + // D12: did the agent explicitly declare the task already satisfied + // (loop_task_complete)? If so this empty result is intentional — route + // to review (the review gate still verifies the criteria against HEAD), + // NOT a no-progress rework. + let declared = loop_service::iteration::get_iteration(&db.conn, iteration_id) + .await? + .and_then(|it| it.agent_completion_reason) + .filter(|r| !r.trim().is_empty()); + if declared.is_some() { + set_task_status_cas( + db, + task.id, + ArtifactStatus::Pending, + ArtifactStatus::InProgress, + ) + .await?; + loop_service::iteration::set_iteration_outcome( + &db.conn, + iteration_id, + IterationOutcome::DeclaredComplete, + ) + .await?; + Ok(ImplementOutcome::Advanced) + } else { + // D11: a genuine empty diff is no progress; record it and let the + // breaker decide retry vs. block. + loop_service::iteration::set_iteration_outcome( + &db.conn, + iteration_id, + IterationOutcome::EmptyDiff, + ) + .await?; + match record_rework( + db, + emitter, + issue, + config, + task, + Some(iteration_id), + "empty_diff:implement", + ) + .await? + { + ReworkOutcome::Retry => Ok(ImplementOutcome::NoProgress), + ReworkOutcome::Blocked => Ok(ImplementOutcome::Blocked), + } + } + } + } +} + +/// Run the issue's `validation_commands` against the freshly committed checkpoint +/// and map the result onto an [`ImplementOutcome`]: +/// +/// - no commands configured → straight to `in_progress` (nothing to check); +/// - passed → `in_progress` (implemented, awaiting review); +/// - failed → rework (bump attempt; the recorded output feeds the next briefing); +/// - unrunnable → block the task + file a `blocked` inbox card. +/// +/// The worktree is reset to HEAD afterward so build artifacts the commands +/// produced don't leak into the next attempt — the checkpoint commit stays, as +/// `reset_to_head` only clears uncommitted side-effects. +async fn validate_after_implement( + db: &AppDatabase, + emitter: &EventEmitter, + issue: &loop_issue::Model, + config: &IssueConfig, + worktree_path: &Path, + task: &LoopArtifactRow, + iteration_id: i32, +) -> Result { + let commands = &config.validation_commands; + if commands.is_empty() { + set_task_status_cas(db, task.id, ArtifactStatus::Pending, ArtifactStatus::InProgress).await?; + // No commands to run → the committed implement is the outcome (D11). + loop_service::iteration::set_iteration_outcome( + &db.conn, + iteration_id, + IterationOutcome::Succeeded, + ) + .await?; + // D14: a committed checkpoint advancing to review is real forward progress. + transitions::clear_oscillation(&db.conn, task.id).await?; + return Ok(ImplementOutcome::Advanced); + } + + let timeout = config.iteration_timeout_secs.map(Duration::from_secs); + let report = validation::run_validation(worktree_path, commands, timeout).await?; + worktree::reset_to_head(worktree_path).await?; + loop_service::validation::record_validation_run( + &db.conn, + issue.space_id, + issue.id, + task.id, + Some(iteration_id), + commands, + &report.exit_codes, + &report.output, + report.passed(), + ) + .await?; + + match report.outcome { + ValidationOutcome::Passed => { + set_task_status_cas(db, task.id, ArtifactStatus::Pending, ArtifactStatus::InProgress).await?; + // Validation passed → the implement iteration succeeded (D11). + loop_service::iteration::set_iteration_outcome( + &db.conn, + iteration_id, + IterationOutcome::Succeeded, + ) + .await?; + // D14: passing validation is real forward progress on the task. + transitions::clear_oscillation(&db.conn, task.id).await?; + Ok(ImplementOutcome::Advanced) + } + ValidationOutcome::Failed => { + // Fingerprint the failure so the breaker can tell "the same failure + // again" from a genuinely new one. + let sig = format!( + "validation_failed:{}", + sig_hash(&format!( + "{:?}\n{}", + report.exit_codes, + normalize_failure_output(&report.output) + )) + ); + loop_service::iteration::set_iteration_outcome( + &db.conn, + iteration_id, + IterationOutcome::ValidationFailed, + ) + .await?; + match record_rework(db, emitter, issue, config, task, Some(iteration_id), &sig).await? { + ReworkOutcome::Retry => Ok(ImplementOutcome::NoProgress), + ReworkOutcome::Blocked => Ok(ImplementOutcome::Blocked), + } + } + ValidationOutcome::Unrunnable => { + set_task_status_cas(db, task.id, ArtifactStatus::Pending, ArtifactStatus::Blocked) + .await?; + // The checkpoint ran but validation couldn't execute — the implement + // did not pass its gate, so record it (keeps the NULL invariant: a + // settled+checkpointed implement always has an outcome). + loop_service::iteration::set_iteration_outcome( + &db.conn, + iteration_id, + IterationOutcome::ValidationFailed, + ) + .await?; + // Block the issue too (consistent with the no-progress breaker's + // `mark_blocked`), so the human `retry` escape hatch — which requires a + // `blocked` issue — can reach this stall and re-arm the task. Without + // this the issue would sit `running` with a blocked task: the driver + // parks and `retry_issue` rejects it as not-blocked, an unrecoverable + // dead end. + cas_issue_status(&db.conn, issue.id, IssueStatus::Running, IssueStatus::Blocked) + .await?; + let upsert = loop_service::inbox::upsert_inbox( + &db.conn, + issue.space_id, + issue.id, + Some(iteration_id), + InboxKind::Blocked, + &format!("validation_blocked:{}", task.id), + serde_json::json!({ + "task_artifact_id": task.id, + "reason": "validation_unrunnable", + "commands": commands, + "exit_codes": report.exit_codes, + }), + ) + .await?; + if upsert.changed() { + emit_changed(emitter, issue.space_id, issue.id, issue.id, "blocked"); + } + Ok(ImplementOutcome::Blocked) + } + } +} + +/// Dispatch an implement iteration for `task_id` at `attempt`. Returns `true` +/// when a new iteration was actually launched (the lease was free). +#[allow(clippy::too_many_arguments)] +async fn dispatch_implement( + db: &AppDatabase, + data_dir: &Path, + spawner: &dyn LoopAgentSpawner, + emitter: &EventEmitter, + issue: &loop_issue::Model, + config: &IssueConfig, + worktree_folder_id: i32, + task_id: i32, + attempt: i32, +) -> Result { + let spec = resolve_agent_spec(config, Stage::Implement); + let handle = dispatch_iteration( + db, + data_dir, + spawner, + emitter.clone(), + DispatchInput { + space_id: issue.space_id, + issue_id: issue.id, + stage: Stage::Implement, + target_artifact_id: Some(task_id), + slot_no: None, + attempt, + agent_type: spec.agent, + mode_id: spec.mode_id, + config_values: spec.config_values, + worktree_folder_id, + }, + ) + .await?; + Ok(handle.is_some()) +} + +/// All implement iterations for **one task** — keyed by `(issue, target)`, never +/// "the issue's single write". With several tasks implementing concurrently +/// (phase 2 dropped the per-issue write lease), this still resolves exactly this +/// task's iterations. (No in-flight-write lookup assumes a per-issue singleton — +/// they key on `(issue, target)` here / `(issue, finalize)` for the issue-level +/// finalize, or iterate all in-flight rows.) +async fn implement_iterations( + db: &AppDatabase, + issue_id: i32, + task_id: i32, +) -> Result, LoopError> { + Ok(loop_iteration::Entity::find() + .filter(loop_iteration::Column::IssueId.eq(issue_id)) + .filter(loop_iteration::Column::Stage.eq(Stage::Implement)) + .filter(loop_iteration::Column::TargetArtifactId.eq(task_id)) + .all(&db.conn) + .await?) +} + +/// CAS a task artifact's status `from → to` — the artifact analogue of the +/// `cas_*_status` discipline used for issues and iterations. Returns whether the +/// transition applied. A single per-issue driver advances its tasks (they fan +/// out within a tick but are stepped one at a time), so a `false` here means the +/// expected `from` was wrong — a logic bug — and is logged rather than silently +/// swallowed. +async fn set_task_status_cas( + db: &AppDatabase, + task_id: i32, + from: ArtifactStatus, + to: ArtifactStatus, +) -> Result { + let applied = cas_artifact_status(&db.conn, task_id, from, to).await?; + if !applied { + tracing::warn!( + task_id, + from = ?from, + to = ?to, + "task status CAS did not apply (unexpected current status)" + ); + } + Ok(applied) +} + +/// Read the task's accepted tip — HEAD of the worktree it ran in (the task branch +/// in parallel mode, the issue branch in serial mode) — and atomically mark the +/// task `Done` with its contribution kind (D12). In parallel mode the kind is +/// `NoOp` when HEAD == the pinned integration base (the agent declared the task +/// already satisfied; no commit) and `Delta` otherwise (freezing HEAD as +/// `fan_in_commit`); serial tasks always record `Delta` (the column is unused for +/// serial fan-in). The single CAS guarantees no "Done but unfrozen" window the +/// fan-in could observe. On Done, clears the task's oscillation epoch (D14). +/// Returns whether the CAS applied. +async fn freeze_and_done( + db: &AppDatabase, + issue: &loop_issue::Model, + worktree_folder_id: i32, + task_id: i32, +) -> Result { + let folder = folder_service::get_folder_by_id(&db.conn, worktree_folder_id) + .await? + .ok_or_else(|| LoopError::NotFound(format!("worktree folder {worktree_folder_id}")))?; + let head = worktree::head_commit(Path::new(&folder.path)).await?; + // Base stability holds because the parallel fan-in runs strictly after ALL + // tasks are Done (the run_finalize gate): at this task's done the issue tip is + // unmoved and every predecessor's frozen commit is fixed, so re-resolving the + // base here matches what `ensure_task_worktree` branched from. + let contribution = if issue.execution_mode.as_deref() == Some("parallel") { + let base = worktree::task_base_oid(&db.conn, issue, task_id).await?; + if head == base { + TaskContribution::NoOp + } else { + TaskContribution::Delta(head) + } + } else { + TaskContribution::Delta(head) + }; + let applied = cas_task_done_with_contribution(&db.conn, task_id, contribution).await?; + if applied { + // D14: reaching Done is real forward progress — clear the oscillation epoch. + transitions::clear_oscillation(&db.conn, task_id).await?; + } + Ok(applied) +} + +async fn bump_rework(db: &AppDatabase, task_id: i32, sig: &str) -> Result<(), LoopError> { + loop_artifact::Entity::update_many() + .col_expr( + loop_artifact::Column::Attempt, + Expr::col(loop_artifact::Column::Attempt).add(1), + ) + .col_expr( + loop_artifact::Column::LastFailureSig, + Expr::value(sig.to_string()), + ) + .col_expr(loop_artifact::Column::UpdatedAt, Expr::value(Utc::now())) + .filter(loop_artifact::Column::Id.eq(task_id)) + .exec(&db.conn) + .await?; + Ok(()) +} + +// ---- Circuit breakers (§4.10): no-progress + max-attempts ---- + +/// Whether a recorded rework should retry or has tripped a breaker. +enum ReworkOutcome { + /// The rework counter advanced; the caller may re-dispatch at the new attempt. + Retry, + /// A breaker tripped — the task + issue are now `blocked` and an inbox card is + /// filed. The caller must not re-dispatch. + Blocked, +} + +/// Record one failed attempt against `task` and evaluate the no-progress +/// breakers. Bumps the rework counter + failure signature, then blocks (task + +/// issue → `blocked`, inbox card) when either: +/// +/// - the task has exhausted `max_attempts` (`attempt >= max_attempts` after the +/// bump; `0` = unlimited), or +/// - this failure repeats the immediately-preceding signature — the agent is +/// producing the identical failure, so further attempts won't help. +/// +/// Returns [`ReworkOutcome::Retry`] otherwise. +async fn record_rework( + db: &AppDatabase, + emitter: &EventEmitter, + issue: &loop_issue::Model, + config: &IssueConfig, + task: &LoopArtifactRow, + iteration_id: Option, + sig: &str, +) -> Result { + let prev_sig = loop_artifact::Entity::find_by_id(task.id) + .one(&db.conn) + .await? + .and_then(|m| m.last_failure_sig); + let repeated = prev_sig.as_deref() == Some(sig); + + bump_rework(db, task.id, sig).await?; + let attempt = task.attempt + 1; + + let max = config.max_attempts as i32; + let exhausted = max > 0 && attempt >= max; + if exhausted || repeated { + let reason = if exhausted { + "max_attempts" + } else { + "repeated_failure" + }; + mark_blocked(db, emitter, issue, config, task.id, iteration_id, reason, sig, attempt) + .await?; + Ok(ReworkOutcome::Blocked) + } else { + Ok(ReworkOutcome::Retry) + } +} + +/// Block a stalled node: set the task `blocked`, CAS the issue `running → +/// blocked` (so the driver stops on its next tick), and file a `blocked` inbox +/// card keyed on the task. D14: when the same failure recurs across enough block +/// epochs (`oscillation_limit`), promote the ordinary `no_progress` card to an +/// `oscillation` card — a deterministic failure a plain retry can't fix, needing +/// an explicit human exit. A human resolves it via the inbox. +#[allow(clippy::too_many_arguments)] +async fn mark_blocked( + db: &AppDatabase, + emitter: &EventEmitter, + issue: &loop_issue::Model, + config: &IssueConfig, + task_id: i32, + iteration_id: Option, + reason: &str, + sig: &str, + attempt: i32, +) -> Result<(), LoopError> { + // Whether THIS call actually blocked the node — a genuine new block epoch. An + // idempotent replay (task already blocked) must NOT inflate the oscillation + // count. The issue CAS is independent (it may miss when a sibling already + // blocked the issue) and must NOT gate the task's own epoch. + let blocked_now = crate::loop_engine::transitions::cas_artifact_status_from( + &db.conn, + task_id, + &[ArtifactStatus::Pending, ArtifactStatus::InProgress], + ArtifactStatus::Blocked, + ) + .await?; + cas_issue_status(&db.conn, issue.id, IssueStatus::Running, IssueStatus::Blocked).await?; + + // D14: step the epoch only on a genuine new block; on a replay read the + // existing count without stepping. + let limit = config.oscillation_limit as i32; + let osc = if blocked_now { + transitions::step_oscillation(&db.conn, task_id, sig).await? + } else { + loop_artifact::Entity::find_by_id(task_id) + .one(&db.conn) + .await? + .map(|m| m.oscillation_count) + .unwrap_or(0) + }; + + if limit > 0 && osc >= limit { + // Deterministic failure → promote. Upsert the oscillation card FIRST so a + // crash before the resolve still leaves an actionable card (never zero + // cards); a stale `no_progress` card briefly coexisting is harmless — retry + // exclusion keys on the artifact's `oscillation_count`, not on the card. + let upsert = loop_service::inbox::upsert_inbox( + &db.conn, + issue.space_id, + issue.id, + iteration_id, + InboxKind::Blocked, + &format!("oscillation:{task_id}"), + serde_json::json!({ + "task_artifact_id": task_id, + "reason": "oscillation", + "failure_sig": sig, + "count": osc, + "attempt": attempt, + }), + ) + .await?; + if upsert.changed() { + emit_changed(emitter, issue.space_id, issue.id, issue.id, "blocked"); + } + // Supersede the ordinary task-level blocker cards (NOT `oscillation:`, which + // clears only via override / force-complete). + loop_service::inbox::resolve_task_blocker_cards( + &db.conn, + issue.id, + task_id, + &["no_progress", "validation_blocked", "infra_failure"], + serde_json::json!({ "action": "superseded_by_oscillation" }), + ) + .await?; + return Ok(()); + } + + // Below the limit (or limit=0, breaker off): ordinary retryable no_progress + // card. Surface it live (D6): emit on a new/changed card, stay silent on a + // no-op recurrence so a parked breaker does not spam every tick. + let upsert = loop_service::inbox::upsert_inbox( + &db.conn, + issue.space_id, + issue.id, + iteration_id, + InboxKind::Blocked, + &format!("no_progress:{task_id}"), + serde_json::json!({ + "task_artifact_id": task_id, + "reason": reason, + "failure_sig": sig, + "attempt": attempt, + }), + ) + .await?; + if upsert.changed() { + emit_changed(emitter, issue.space_id, issue.id, issue.id, "blocked"); + } + Ok(()) +} + +/// Block a task whose worktree could not be provisioned after +/// [`INFRA_RETRY_MAX`] consecutive attempts: set the task `blocked`, CAS the issue +/// `running → blocked` (driver stops next tick), and file an `infra_failure:{task}` +/// card for a human. Distinct subject from the no-progress breaker — this is an +/// environment fault (disk, git), not a stuck agent. +async fn block_task_infra( + db: &AppDatabase, + emitter: &EventEmitter, + issue: &loop_issue::Model, + task_id: i32, +) -> Result<(), LoopError> { + crate::loop_engine::transitions::cas_artifact_status_from( + &db.conn, + task_id, + &[ArtifactStatus::Pending, ArtifactStatus::InProgress], + ArtifactStatus::Blocked, + ) + .await?; + cas_issue_status(&db.conn, issue.id, IssueStatus::Running, IssueStatus::Blocked).await?; + loop_service::inbox::upsert_inbox( + &db.conn, + issue.space_id, + issue.id, + None, + InboxKind::Blocked, + &format!("infra_failure:{task_id}"), + serde_json::json!({ + "task_artifact_id": task_id, + "reason": "worktree_unavailable", + }), + ) + .await?; + emit_changed(emitter, issue.space_id, issue.id, issue.id, "blocked"); + Ok(()) +} + +/// ISO-8601 timestamps (`2026-06-18T12:34:56.789Z`, with or without fractional +/// seconds / timezone) — differ run-to-run for the same failure. +static TS_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})?").unwrap() +}); +/// Absolute / temp / worktree paths (each run gets a fresh temp dir, so the path +/// segment that follows `tmp`/`temp`/`loop-worktrees`/`var/folders` is volatile). +static TMP_PATH_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"(/[\w.\-]+)*/(tmp|temp|loop-worktrees|var/folders)/[\w./\-]+").unwrap() +}); +/// Full-length git object ids (40 hex = SHA-1, 64 hex = SHA-256). Deliberately +/// NOT 7–39 char hex: those collide with real, distinguishing failure content — +/// asserted addresses, expected/got hashes, generated ids — that must stay +/// distinct so two genuinely different failures keep different signatures. +static OID_RE: LazyLock = + LazyLock::new(|| Regex::new(r"\b[0-9a-f]{40}\b|\b[0-9a-f]{64}\b").unwrap()); +/// Elapsed-time tokens (`12ms`, `1.3s`, `400µs`, `7ns`). +static DUR_RE: LazyLock = + LazyLock::new(|| Regex::new(r"\b\d+(\.\d+)?(ms|s|µs|ns)\b").unwrap()); + +/// Strip volatile substrings from validation output before fingerprinting, so the +/// oscillation breaker (D14) can recognise "the same failure again" across +/// retries. Removes ISO timestamps, absolute/temp/worktree paths, full-length git +/// oids, and durations — the parts that differ run-to-run for an otherwise +/// identical failure — while leaving the failure's distinguishing content (error +/// messages, assertion expected/got values, short hex) untouched, so genuinely +/// different failures still hash differently. Operates line-by-line and trims +/// trailing whitespace (another run-to-run wobble). +fn normalize_failure_output(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for line in s.lines() { + // Order matters: paths before oids so a temp dir's hex tail goes with the + // path rather than being independently rewritten. + let l = TS_RE.replace_all(line, ""); + let l = TMP_PATH_RE.replace_all(&l, ""); + let l = OID_RE.replace_all(&l, ""); + let l = DUR_RE.replace_all(&l, ""); + out.push_str(l.trim_end()); + out.push('\n'); + } + out +} + +/// Stable 64-bit FNV-1a fingerprint (hex) of a failure's specifics, so the +/// repeated-failure breaker can compare "same failure" without storing the full +/// output in the signature column. +fn sig_hash(s: &str) -> String { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for b in s.as_bytes() { + h ^= *b as u64; + h = h.wrapping_mul(0x0000_0100_0000_01b3); + } + format!("{h:016x}") +} + +// ---- Review stage (§4.7) ---- + +/// The outcome of a review round under the configured pass rule. +enum ReviewDecision { + /// Enough passes to accept the implementation — task is done. + Pass, + /// A reviewer rejected (or a passing quorum is no longer reachable) → rework. + Fail, + /// Not enough verdicts in yet — dispatch / await more reviewers. + Undecided, +} + +/// Drive an `in_progress` (implemented + validated) task through its review +/// round: ensure `reviewer_count` review slots run, aggregate their verdicts, +/// then accept (task `done`, freezing its integration commit) or reject (rework + +/// cancel the remaining reviewers). See [`StepOutcome`] for the return semantics. +/// The gate-decision stage label for a task review (D5: integration review uses +/// `finalize`). Stored in `loop_gate_decision.stage` and used as the replay key. +const REVIEW_STAGE: &str = "review"; + +/// The gate-decision stage label for the integration review (target = result). +/// `merge_issue` reads it; `count_fail(issue, FINALIZE_GATE_STAGE)` bounds the +/// integration loop-back. +pub(crate) const FINALIZE_GATE_STAGE: &str = "finalize"; + +/// The issue's live (non-superseded/cancelled) result artifact, if any — the one +/// `uniq_result_per_issue` admits. The integration gate and merge gate both reason +/// about THIS result, never a superseded one a prior loop-back left behind. +pub(crate) fn live_result(dag: &LoopDagView) -> Option<&LoopArtifactRow> { + dag.artifacts.iter().find(|a| { + a.kind == ArtifactKind::Result + && !matches!(a.status, ArtifactStatus::Superseded | ArtifactStatus::Cancelled) + }) +} + +/// Whether the issue's live result has passed integration (a recorded +/// `gate_decision(result, finalize, attempt) == Pass`). The merge gate's +/// precondition (D6) and the driver's auto-merge trigger both consult this, so a +/// result can land only after the whole-issue closure is verified. +pub(crate) async fn integration_passed( + conn: &sea_orm::DatabaseConnection, + dag: &LoopDagView, +) -> Result { + let Some(result) = live_result(dag) else { + return Ok(false); + }; + Ok(loop_service::gate_decision::outcome_for( + conn, + result.id, + FINALIZE_GATE_STAGE, + result.attempt, + ) + .await? + == Some(GateOutcome::Pass)) +} + +#[allow(clippy::too_many_arguments)] +async fn drive_reviews( + db: &AppDatabase, + data_dir: &Path, + spawner: &dyn LoopAgentSpawner, + emitter: &EventEmitter, + issue: &loop_issue::Model, + config: &IssueConfig, + worktree_folder_id: i32, + task: &LoopArtifactRow, +) -> Result { + let reviewer_specs = config.effective_reviewers(); + let reviewers = reviewer_specs.len() as i32; + let iters = review_iterations(db, issue.id, task.id, task.attempt).await?; + + // Replay-safe pivot (D4): a decision already recorded for (task, review, + // attempt) drives the side-effects idempotently — so a crash after recording + // but before the freeze/rework completed is finished by this tick from the + // recorded outcome (never recomputed). The key advances with the attempt, so a + // completed rework (attempt bumped) is never re-entered here. + if let Some(outcome) = + loop_service::gate_decision::outcome_for(&db.conn, task.id, REVIEW_STAGE, task.attempt) + .await? + { + return drive_review_outcome( + db, spawner, emitter, issue, config, worktree_folder_id, task, &iters, outcome, + ) + .await; + } + + // Slot accounting: a slot is "decided" once it has a submitted (succeeded) + // review; "missing" when it has no active and no submitted iteration. The + // display verdict isn't the gate input — it only tells us WHICH iterations + // submitted, so we know whose checks to aggregate and which slots to dispatch. + let verdicts = review_verdicts(db, &iters).await?; + let decided_iter_ids: Vec = verdicts.keys().copied().collect(); + let mut missing_slots: Vec = Vec::new(); + for slot in 0..reviewers { + let slot_iters: Vec<&loop_iteration::Model> = + iters.iter().filter(|it| it.slot_no == Some(slot)).collect(); + let decided = slot_iters.iter().any(|it| verdicts.contains_key(&it.id)); + let in_flight = slot_iters + .iter() + .any(|it| matches!(it.status, IterationStatus::Queued | IterationStatus::Running)); + if !decided && !in_flight { + missing_slots.push(slot); + } + } + + // Canonical per-criterion decision over the submitted checks (D8) — NOT an + // aggregation of per-reviewer verdicts. + let injected_ids = injected_criterion_ids(&iters); + let checks = + loop_service::criterion_check::for_scope_iterations(&db.conn, task.id, &decided_iter_ids) + .await?; + let outcome = aggregate_checks(config.review_pass_rule, reviewers, &checks, &injected_ids); + + match outcome { + GateOutcome::Pass | GateOutcome::Fail => { + // Record the immutable decision FIRST (the durable pivot), THEN drive + // side-effects. A divergent recompute at the same key (different + // inputs) is a Conflict → re-tick against fresh state, never overwrite. + let policy = review_policy_json(config); + match loop_service::gate_decision::record_decision( + &db.conn, + issue.space_id, + issue.id, + task.id, + REVIEW_STAGE, + task.attempt, + &checks, + &injected_ids, + &policy, + outcome, + ) + .await? + { + loop_service::gate_decision::RecordedDecision::Settled(_) => {} + loop_service::gate_decision::RecordedDecision::Conflict(_) => { + return Err(LoopError::Conflict) + } + } + drive_review_outcome( + db, spawner, emitter, issue, config, worktree_folder_id, task, &iters, outcome, + ) + .await + } + GateOutcome::Undecided => { + let mut dispatched = false; + for slot in missing_slots { + if dispatch_review( + db, + data_dir, + spawner, + emitter, + issue, + worktree_folder_id, + task.id, + slot, + task.attempt, + &reviewer_specs[slot as usize], + ) + .await? + { + dispatched = true; + } + } + Ok(StepOutcome::from_dispatched(dispatched)) + } + } +} + +/// Drive a settled review decision's side-effects (shared by the fresh decision +/// and the replay pivot). Pass → freeze the accepted tip + mark Done; Fail → +/// cancel remaining reviewers, reset the tree, and rework (retry or breaker). +/// Idempotent: Pass is a CAS (InProgress→Done); Fail is only reached while the +/// task is still InProgress at the deciding attempt (a completed rework bumped the +/// attempt, so the decision key no longer resolves here). +#[allow(clippy::too_many_arguments)] +async fn drive_review_outcome( + db: &AppDatabase, + spawner: &dyn LoopAgentSpawner, + emitter: &EventEmitter, + issue: &loop_issue::Model, + config: &IssueConfig, + worktree_folder_id: i32, + task: &LoopArtifactRow, + iters: &[loop_iteration::Model], + outcome: GateOutcome, +) -> Result { + match outcome { + GateOutcome::Pass => { + cancel_active_reviews(db, spawner, iters).await?; + if freeze_and_done(db, issue, worktree_folder_id, task.id).await? { + Ok(StepOutcome::Advanced) + } else { + Ok(StepOutcome::Idle) + } + } + GateOutcome::Fail => { + cancel_active_reviews(db, spawner, iters).await?; + // D12: review rejected — drop any declared-completion claim for this + // task so the next empty implement attempt is treated as genuine + // no-progress, not silently routed back to review on a stale claim. + loop_service::iteration::clear_declared_completion(&db.conn, issue.id, task.id).await?; + // Defensive: clear any reviewer side-effects before re-implementing. + let folder = folder_service::get_folder_by_id(&db.conn, worktree_folder_id) + .await? + .ok_or_else(|| { + LoopError::NotFound(format!("worktree folder {worktree_folder_id}")) + })?; + worktree::reset_to_head(Path::new(&folder.path)).await?; + // Fingerprint the rejecting findings so the breaker can tell "the same + // objection again" from a genuinely new one. + let findings = + loop_service::artifact::latest_failed_review_findings(&db.conn, task.id).await?; + let sig = format!("review_rejected:{}", sig_hash(&findings.join("\n---\n"))); + match record_rework(db, emitter, issue, config, task, None, &sig).await? { + ReworkOutcome::Retry => { + set_task_status_cas( + db, + task.id, + ArtifactStatus::InProgress, + ArtifactStatus::Pending, + ) + .await?; + } + ReworkOutcome::Blocked => {} + } + Ok(StepOutcome::Advanced) + } + // Undecided is never recorded as a decision; defensive no-op. + GateOutcome::Undecided => Ok(StepOutcome::Idle), + } +} + +/// The injected criterion-id set for a review round = the union of the criterion +/// ids in the dispatched iterations' persisted manifests (D10). Every slot at one +/// attempt was shown the same frozen manifest, so this is the canonical "what must +/// be checked" set the gate aggregates against. +fn injected_criterion_ids(iters: &[loop_iteration::Model]) -> Vec { + let mut set: std::collections::BTreeSet = std::collections::BTreeSet::new(); + for it in iters { + if let Some(raw) = it.context_manifest.as_deref() { + if let Ok(v) = serde_json::from_str::(raw) { + if let Some(obj) = v.get("criteria").and_then(|c| c.as_object()) { + for val in obj.values() { + if let Some(id) = val.as_i64() { + set.insert(id as i32); + } + } + } + } + } + } + set.into_iter().collect() +} + +/// The gate's policy fingerprint, recorded with the decision so a config change +/// (rule / reviewer count) is detectable as a different decision input. +fn review_policy_json(config: &IssueConfig) -> String { + let rule = match config.review_pass_rule { + ReviewPassRule::Unanimous => "unanimous", + ReviewPassRule::Majority => "majority", + }; + serde_json::json!({ + "rule": rule, + "reviewers": config.effective_reviewers().len(), + "v": 1, + }) + .to_string() +} + +/// Aggregate review verdicts under the pass rule. `unanimous` fails fast on any +/// fail and accepts only when all `n` slots pass; `majority` accepts on +/// `pass*2 > n` and rejects once a passing majority is unreachable (an even +/// split rejects). +fn aggregate(rule: ReviewPassRule, n: i32, verdicts: &[ReviewVerdict]) -> ReviewDecision { + let pass = verdicts + .iter() + .filter(|v| matches!(v, ReviewVerdict::Pass)) + .count() as i32; + let fail = verdicts.len() as i32 - pass; + if rule == ReviewPassRule::Majority { + if pass * 2 > n { + ReviewDecision::Pass + } else if fail * 2 >= n { + ReviewDecision::Fail + } else { + ReviewDecision::Undecided + } + } else if fail >= 1 { + // "unanimous" (default): any fail rejects; all-pass accepts. + ReviewDecision::Fail + } else if pass >= n { + ReviewDecision::Pass + } else { + ReviewDecision::Undecided + } +} + +/// Per-criterion aggregation (D8) — the canonical gate decision. Group the +/// submitted checks by criterion, apply the SAME quorum as [`aggregate`] to each +/// criterion's reviewer checks, then: the gate is `Fail` iff ANY injected +/// criterion failed, `Pass` iff EVERY injected criterion passed, else `Undecided` +/// (more reviewer checks needed). This is NOT the same as aggregating per-reviewer +/// verdicts — under Majority the two can diverge (see the counterexample test): +/// reviewers can split such that no reviewer is in the majority yet every +/// criterion individually clears quorum. An empty injected set is `Undecided` +/// (no criteria dispatched yet, or a degenerate task) — never a vacuous pass. +fn aggregate_checks( + rule: ReviewPassRule, + n: i32, + checks: &[LoopCriterionCheckRow], + injected_ids: &[i32], +) -> GateOutcome { + if injected_ids.is_empty() { + return GateOutcome::Undecided; + } + let mut any_fail = false; + let mut all_pass = true; + for &cid in injected_ids { + let verdicts: Vec = checks + .iter() + .filter(|c| c.criterion_id == cid) + .map(|c| match c.verdict { + CheckVerdict::Pass => ReviewVerdict::Pass, + CheckVerdict::Fail => ReviewVerdict::Fail, + }) + .collect(); + match aggregate(rule, n, &verdicts) { + ReviewDecision::Fail => any_fail = true, + ReviewDecision::Pass => {} + ReviewDecision::Undecided => all_pass = false, + } + } + if any_fail { + GateOutcome::Fail + } else if all_pass { + GateOutcome::Pass + } else { + GateOutcome::Undecided + } +} + +/// Dispatch one review slot. Returns `true` when a new iteration launched (the +/// review-slot lease was free). +#[allow(clippy::too_many_arguments)] +async fn dispatch_review( + db: &AppDatabase, + data_dir: &Path, + spawner: &dyn LoopAgentSpawner, + emitter: &EventEmitter, + issue: &loop_issue::Model, + worktree_folder_id: i32, + task_id: i32, + slot: i32, + attempt: i32, + spec: &ReviewerSpec, +) -> Result { + let handle = dispatch_iteration( + db, + data_dir, + spawner, + emitter.clone(), + DispatchInput { + space_id: issue.space_id, + issue_id: issue.id, + stage: Stage::Review, + target_artifact_id: Some(task_id), + slot_no: Some(slot), + attempt, + agent_type: spec.agent, + mode_id: spec.mode_id.clone(), + config_values: spec.config_values.clone(), + worktree_folder_id, + }, + ) + .await?; + Ok(handle.is_some()) +} + +/// Invalidate any still-active reviewers (a decision was reached without them). +/// CAS to `cancelled` voids the capability token — `ingest` rejects a submit from +/// a non-running iteration — so a late verdict can't change the outcome. It then +/// reaps the reviewer's agent *process*: voiding the token only blocks a late +/// submit, but the process itself could keep mutating the shared worktree right up +/// until it's disconnected (and the caller resets the tree immediately after). +/// Best-effort kill — a reviewer whose connection already exited just isn't found. +async fn cancel_active_reviews( + db: &AppDatabase, + spawner: &dyn LoopAgentSpawner, + iters: &[loop_iteration::Model], +) -> Result<(), LoopError> { + for it in iters { + if matches!(it.status, IterationStatus::Queued | IterationStatus::Running) + && cas_iteration_status(&db.conn, it.id, it.status, IterationStatus::Cancelled).await? + { + loop_iteration::Entity::update_many() + .col_expr(loop_iteration::Column::EndedAt, Expr::value(Utc::now())) + .filter(loop_iteration::Column::Id.eq(it.id)) + .exec(&db.conn) + .await?; + // D11: a cancelled losing reviewer is `abandoned`. Only after the CAS + // wins, and write-once anyway, so a stale call can never clobber a real + // outcome (Codex r2 C2). + loop_service::iteration::set_iteration_outcome( + &db.conn, + it.id, + IterationOutcome::Abandoned, + ) + .await?; + if let Some(conv_id) = it.conversation_id { + if let Some(conn_id) = spawner.find_loop_connection(conv_id).await { + spawner.disconnect_loop_agent(&conn_id).await; + } + } + } + } + Ok(()) +} + +async fn review_iterations( + db: &AppDatabase, + issue_id: i32, + task_id: i32, + attempt: i32, +) -> Result, LoopError> { + Ok(loop_iteration::Entity::find() + .filter(loop_iteration::Column::IssueId.eq(issue_id)) + .filter(loop_iteration::Column::Stage.eq(Stage::Review)) + .filter(loop_iteration::Column::TargetArtifactId.eq(task_id)) + .filter(loop_iteration::Column::Attempt.eq(attempt)) + .all(&db.conn) + .await?) +} + +/// Map each succeeded review iteration to the verdict of the review artifact it +/// produced. +async fn review_verdicts( + db: &AppDatabase, + iters: &[loop_iteration::Model], +) -> Result, LoopError> { + let succeeded: Vec = iters + .iter() + .filter(|it| it.status == IterationStatus::Succeeded) + .map(|it| it.id) + .collect(); + if succeeded.is_empty() { + return Ok(HashMap::new()); + } + let mut map = HashMap::new(); + for art in loop_artifact::Entity::find() + .filter(loop_artifact::Column::Kind.eq(ArtifactKind::Review)) + .filter(loop_artifact::Column::ProducedByIterationId.is_in(succeeded)) + .all(&db.conn) + .await? + { + if let (Some(iter_id), Some(verdict)) = (art.produced_by_iteration_id, art.verdict) { + map.insert(iter_id, verdict); + } + } + Ok(map) +} + +// ---- Finalize stage (§4.6): produce the result artifact ---- + +/// Finalize the issue once the write pipeline is fully drained (every task `done`, +/// gate free): assert the worktree is clean (all checkpoints committed), dispatch +/// a finalize iteration — which submits the `result` artifact via ingest, fanning +/// `results_from` edges to each task — and commit any finalize worktree changes as +/// the final checkpoint. A dirty tree blocks the issue (a structural fault a human +/// must resolve). A no-op until the pipeline is drained. See [`StepOutcome`] for +/// the return semantics (a dirty-tree block reports `Advanced` so the driver +/// re-ticks and stops). +#[allow(clippy::too_many_arguments)] +pub(crate) async fn run_finalize( + db: &AppDatabase, + data_dir: &Path, + spawner: &dyn LoopAgentSpawner, + emitter: &EventEmitter, + issue: &loop_issue::Model, + dag: &LoopDagView, + config: &IssueConfig, + worktree_folder_id: i32, +) -> Result { + // Only finalize once every LIVE task is done and no task holds the gate. + // Excludes superseded/cancelled tasks: an integration (or coverage) loop-back + // supersedes the prior plan's tasks, and a stale `Superseded` task must not + // wedge the `all done` precondition forever (the new plan's tasks are what + // must complete). + let tasks: Vec<&LoopArtifactRow> = dag + .artifacts + .iter() + .filter(|a| { + a.kind == ArtifactKind::Task + && !matches!(a.status, ArtifactStatus::Superseded | ArtifactStatus::Cancelled) + }) + .collect(); + if tasks.is_empty() || !tasks.iter().all(|t| t.status == ArtifactStatus::Done) { + return Ok(StepOutcome::Idle); + } + // The issue must be fully quiescent before finalizing — every task `Done` is + // not enough on its own: a losing review slot (or any stray write) could still + // be settling. With no per-issue write gate, "no in-flight iteration of any + // stage" is the precondition. (The parallel fan-in path below re-checks this + // internally so a conflict resolver it dispatches can still settle.) + if issue_has_inflight(db, issue.id).await? { + return Ok(StepOutcome::Idle); + } + + // Parallel issues integrate their per-task branches via the result-stage + // fan-in (engine-synthesized result), not an agent-submitted finalize. Only + // once the result exists do they rejoin the shared "result ready → merge gate" + // tail below. + if issue.execution_mode.as_deref() == Some("parallel") && live_result(dag).is_none() { + return crate::loop_engine::fan_in::run_parallel_finalize( + db, data_dir, spawner, emitter, issue, dag, config, worktree_folder_id, + ) + .await; + } + + let fins = finalize_iterations(db, issue.id).await?; + if fins + .iter() + .any(|it| matches!(it.status, IterationStatus::Queued | IterationStatus::Running)) + { + // Finalize in flight — wait for its completion. + return Ok(StepOutcome::Idle); + } + + let folder = folder_service::get_folder_by_id(&db.conn, worktree_folder_id) + .await? + .ok_or_else(|| LoopError::NotFound(format!("worktree folder {worktree_folder_id}")))?; + let worktree_path = Path::new(&folder.path); + + // Result already produced → commit any finalize worktree changes as the final + // checkpoint, then run the INTEGRATION gate (§3.6) before the merge gate. The + // checkpoint commits FIRST so integration reviewers read the committed combined + // tree; the gate verifies the whole-issue closure against the assembled result. + if let Some(result) = live_result(dag) { + let message = format!("loop: finalize (issue #{})", issue.seq_no); + worktree::checkpoint(worktree_path, &message).await?; + + return match drive_integration_review( + db, + data_dir, + spawner, + emitter, + issue, + config, + worktree_folder_id, + result, + ) + .await? + { + // Whole-issue closure verified → open the merge gate. With a human gate + // (auto_merge off) keep the approval card filed; auto_merge lands via + // the driver, which only triggers once `integration_passed` holds. + IntegrationGate::Pass => { + if !config.auto_merge { + // First filing emits (the merge gate now needs a human); the + // per-tick recurrence has an identical `{gate}` payload → merge + // is a no-op → Unchanged → no event (no per-tick spam). + let upsert = loop_service::inbox::upsert_inbox( + &db.conn, + issue.space_id, + issue.id, + None, + InboxKind::Approval, + &format!("merge:{}", issue.id), + serde_json::json!({ "v": 1, "gate": "merge" }), + ) + .await?; + if upsert.changed() { + emit_changed(emitter, issue.space_id, issue.id, issue.id, "approval"); + } + } + Ok(StepOutcome::Idle) + } + // The assembled result fails a requirement/obligation → bounded loop-back. + IntegrationGate::Fail => { + maybe_integration_loopback( + db, emitter, issue, config, dag, result, worktree_folder_id, + ) + .await + } + // Nothing to verify (empty closure) → issue already blocked; re-tick to stop. + IntegrationGate::Blocked => Ok(StepOutcome::Advanced), + // More integration reviewer checks needed. + IntegrationGate::Pending(dispatched) => Ok(StepOutcome::from_dispatched(dispatched)), + }; + } + + // No result yet. Assert the tree is clean (every task's checkpoint committed, + // no stray state) before launching finalize; a dirty tree is a structural + // fault a human must resolve, not something an agent should build a result on. + if !worktree::is_clean(worktree_path).await? { + cas_issue_status(&db.conn, issue.id, IssueStatus::Running, IssueStatus::Blocked).await?; + let upsert = loop_service::inbox::upsert_inbox( + &db.conn, + issue.space_id, + issue.id, + None, + InboxKind::Blocked, + &format!("finalize_dirty:{}", issue.id), + serde_json::json!({ "reason": "worktree_dirty_before_finalize" }), + ) + .await?; + if upsert.changed() { + emit_changed(emitter, issue.space_id, issue.id, issue.id, "blocked"); + } + // Issue is now blocked → re-tick so the driver observes it and stops (a + // human retry then respawns the driver). + return Ok(StepOutcome::Advanced); + } + + let dispatched = + dispatch_finalize(db, data_dir, spawner, emitter, issue, config, worktree_folder_id).await?; + Ok(StepOutcome::from_dispatched(dispatched)) +} + +/// Outcome of the integration gate (target = the assembled result). +enum IntegrationGate { + /// Whole-issue closure verified → caller opens the merge gate. + Pass, + /// A requirement / obligation is unmet by the assembled result → caller loops back. + Fail, + /// Nothing to verify (empty closure, D11) → issue blocked + inbox(unverifiable). + Blocked, + /// More integration reviewer checks needed (`true` = a slot was dispatched). + Pending(bool), +} + +/// The integration gate (§3.6): the result-targeted analogue of [`drive_reviews`]. +/// Same per-criterion machinery (D2/D3/D8) — reviewers run as `Stage::Review` slots +/// on the result, each submitting one check per injected `integration_ordinals` +/// handle — but the decision is recorded under `FINALIZE_GATE_STAGE` and drives no +/// freeze/rework: the caller acts on the returned gate (merge / loop-back). A +/// recorded decision is the replay pivot. An empty closure blocks the issue (D11). +#[allow(clippy::too_many_arguments)] +async fn drive_integration_review( + db: &AppDatabase, + data_dir: &Path, + spawner: &dyn LoopAgentSpawner, + emitter: &EventEmitter, + issue: &loop_issue::Model, + config: &IssueConfig, + worktree_folder_id: i32, + result: &LoopArtifactRow, +) -> Result { + // Replay-safe pivot (D4): a recorded finalize decision drives the outcome. + if let Some(outcome) = + loop_service::gate_decision::outcome_for(&db.conn, result.id, FINALIZE_GATE_STAGE, result.attempt) + .await? + { + return Ok(match outcome { + GateOutcome::Pass => IntegrationGate::Pass, + GateOutcome::Fail => IntegrationGate::Fail, + GateOutcome::Undecided => IntegrationGate::Pending(false), + }); + } + + // Empty-closure guard (D11): nothing to verify → block, never a vacuous pass. + let ordinals = + loop_service::criterion_ordinals::integration_ordinals(&db.conn, issue.id).await?; + if ordinals.is_empty() { + cas_issue_status(&db.conn, issue.id, IssueStatus::Running, IssueStatus::Blocked).await?; + loop_service::inbox::upsert_inbox( + &db.conn, + issue.space_id, + issue.id, + None, + InboxKind::Blocked, + &format!("unverifiable:{}", issue.id), + serde_json::json!({ "v": 1, "reason": "no_integration_criteria" }), + ) + .await?; + emit_changed(emitter, issue.space_id, issue.id, issue.id, "blocked"); + return Ok(IntegrationGate::Blocked); + } + + let reviewer_specs = config.effective_reviewers(); + let reviewers = reviewer_specs.len() as i32; + let iters = review_iterations(db, issue.id, result.id, result.attempt).await?; + let verdicts = review_verdicts(db, &iters).await?; + let decided_iter_ids: Vec = verdicts.keys().copied().collect(); + let mut missing_slots: Vec = Vec::new(); + for slot in 0..reviewers { + let slot_iters: Vec<&loop_iteration::Model> = + iters.iter().filter(|it| it.slot_no == Some(slot)).collect(); + let decided = slot_iters.iter().any(|it| verdicts.contains_key(&it.id)); + let in_flight = slot_iters + .iter() + .any(|it| matches!(it.status, IterationStatus::Queued | IterationStatus::Running)); + if !decided && !in_flight { + missing_slots.push(slot); + } + } + + let injected_ids = injected_criterion_ids(&iters); + let checks = + loop_service::criterion_check::for_scope_iterations(&db.conn, result.id, &decided_iter_ids) + .await?; + let outcome = aggregate_checks(config.review_pass_rule, reviewers, &checks, &injected_ids); + + match outcome { + GateOutcome::Pass | GateOutcome::Fail => { + let policy = review_policy_json(config); + match loop_service::gate_decision::record_decision( + &db.conn, + issue.space_id, + issue.id, + result.id, + FINALIZE_GATE_STAGE, + result.attempt, + &checks, + &injected_ids, + &policy, + outcome, + ) + .await? + { + loop_service::gate_decision::RecordedDecision::Settled(_) => {} + loop_service::gate_decision::RecordedDecision::Conflict(_) => { + return Err(LoopError::Conflict) + } + } + Ok(if outcome == GateOutcome::Pass { + IntegrationGate::Pass + } else { + IntegrationGate::Fail + }) + } + GateOutcome::Undecided => { + let mut dispatched = false; + for slot in missing_slots { + if dispatch_review( + db, + data_dir, + spawner, + emitter, + issue, + worktree_folder_id, + result.id, + slot, + result.attempt, + &reviewer_specs[slot as usize], + ) + .await? + { + dispatched = true; + } + } + Ok(IntegrationGate::Pending(dispatched)) + } + } +} + +/// Integration failure → bounded loop-back to plan (D7). Supersede the live result +/// (frees `uniq_result_per_issue`) and the live tasks so the read frontier re-emits +/// Plan next tick; the committed task work stays in the worktree (integration +/// faults are usually missing glue — a full reset would discard all task work and +/// likely re-fail). Bounded by the count of failed finalize decisions vs +/// `max_attempts` (0 = unlimited); exhaustion → block + inbox(integration_gap). +/// Parallel issues also restore their per-task worktrees from the integrated HEAD +/// so the new plan's tasks branch cleanly. Returns `Advanced` (re-tick). +#[allow(clippy::too_many_arguments)] +async fn maybe_integration_loopback( + db: &AppDatabase, + emitter: &EventEmitter, + issue: &loop_issue::Model, + config: &IssueConfig, + dag: &LoopDagView, + result: &LoopArtifactRow, + worktree_folder_id: i32, +) -> Result { + let conn = &db.conn; + let fails = loop_service::gate_decision::count_fail(conn, issue.id, FINALIZE_GATE_STAGE).await?; + let exhausted = config.max_attempts != 0 && fails >= config.max_attempts; + if exhausted { + cas_issue_status(conn, issue.id, IssueStatus::Running, IssueStatus::Blocked).await?; + loop_service::inbox::upsert_inbox( + conn, + issue.space_id, + issue.id, + None, + InboxKind::Blocked, + &format!("integration_gap:{}", issue.id), + serde_json::json!({ "v": 1, "reason": "integration_gap_exhausted", "fails": fails }), + ) + .await?; + emit_changed(emitter, issue.space_id, issue.id, issue.id, "blocked"); + return Ok(StepOutcome::Advanced); + } + + // Supersede the live result, then the live tasks, so the next tick's read + // frontier sees no live tasks and re-emits Plan (whose briefing carries the + // failing requirement feedback via the standard channels). + crate::loop_engine::transitions::cas_artifact_status_from( + conn, + result.id, + &[ArtifactStatus::Done, ArtifactStatus::AwaitingApproval], + ArtifactStatus::Superseded, + ) + .await?; + // Every live task is Done here (the finalize precondition), and only + // (Done, Superseded) is a legal supersede edge for an implemented task. + for t in dag.artifacts.iter().filter(|a| { + a.kind == ArtifactKind::Task + && !matches!(a.status, ArtifactStatus::Superseded | ArtifactStatus::Cancelled) + }) { + crate::loop_engine::transitions::cas_artifact_status_from( + conn, + t.id, + &[ArtifactStatus::Done], + ArtifactStatus::Superseded, + ) + .await?; + } + + // Parallel: restore the per-task worktrees from the integrated HEAD before the + // re-plan creates fresh tasks (serial shares the issue worktree, nothing to do). + if issue.execution_mode.as_deref() == Some("parallel") { + if let Some(issue_wt) = + folder_service::get_folder_by_id(conn, worktree_folder_id).await? + { + if let Some(space) = loop_service::space::get_space(conn, issue.space_id).await? { + if let Some(repo) = folder_service::get_folder_by_id(conn, space.folder_id).await? { + worktree::reset_issue_subtree( + Path::new(&repo.path), + Path::new(&issue_wt.path), + ) + .await?; + } + } + } + } + + tracing::info!( + issue_id = issue.id, + fails, + result_id = result.id, + "integration gap: superseding result + tasks and replanning" + ); + emit_changed(emitter, issue.space_id, issue.id, issue.id, "issue"); + Ok(StepOutcome::Advanced) +} + +/// Dispatch the finalize iteration (issue-level: `target = None`; the +/// `uniq_active_finalize` lease admits one finalize per issue). +async fn dispatch_finalize( + db: &AppDatabase, + data_dir: &Path, + spawner: &dyn LoopAgentSpawner, + emitter: &EventEmitter, + issue: &loop_issue::Model, + config: &IssueConfig, + worktree_folder_id: i32, +) -> Result { + let spec = resolve_agent_spec(config, Stage::Finalize); + let handle = dispatch_iteration( + db, + data_dir, + spawner, + emitter.clone(), + DispatchInput { + space_id: issue.space_id, + issue_id: issue.id, + stage: Stage::Finalize, + target_artifact_id: None, + slot_no: None, + attempt: 0, + agent_type: spec.agent, + mode_id: spec.mode_id, + config_values: spec.config_values, + worktree_folder_id, + }, + ) + .await?; + Ok(handle.is_some()) +} + +/// All finalize iterations for the issue — keyed by `(issue, finalize)`. Finalize +/// is issue-level (`target = None`) and stays singular under the parallel model +/// (`uniq_active_finalize` admits one), so no target key is needed. +async fn finalize_iterations( + db: &AppDatabase, + issue_id: i32, +) -> Result, LoopError> { + Ok(loop_iteration::Entity::find() + .filter(loop_iteration::Column::IssueId.eq(issue_id)) + .filter(loop_iteration::Column::Stage.eq(Stage::Finalize)) + .all(&db.conn) + .await?) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::acp::error::AcpError; + use crate::models::loops::{ReviewerEntry, ReviewerInherit}; + use sea_orm::ActiveEnum; // for `*.to_value()` in the test helpers below + use crate::db::entities::loop_artifact_revision::ActorKind; + use crate::db::entities::loop_issue::{IssuePriority, IssueStatus}; + use crate::db::service::loop_service::{artifact, issue, link, space}; + use crate::models::loops::LoopLinkRow; + use crate::db::test_helpers::{fresh_disk_db, seed_folder}; + use crate::loop_engine::dispatch::settle_iteration; + use crate::models::agent::AgentType; + use async_trait::async_trait; + use std::path::{Path, PathBuf}; + use std::process::Command as StdCommand; + + /// Build the validation signature the way `validate_after_implement` does, so + /// the tests exercise the real fingerprint pipeline (normalize → hash). + fn validation_sig(exit_codes: &[i32], output: &str) -> String { + format!( + "validation_failed:{}", + sig_hash(&format!( + "{exit_codes:?}\n{}", + normalize_failure_output(output) + )) + ) + } + + #[test] + fn normalize_collapses_volatile_substrings_to_one_sig() { + // Two runs of the SAME failure: only timestamp, temp/worktree path, full + // git oid, and duration differ. They must fingerprint identically (D14 — + // otherwise the oscillation breaker never recognises a repeat). + let run_a = "\ +2026-06-18T12:34:56.789Z FAIL tests::auth at /var/folders/xy/abc123/loop-worktrees/issue-1/src/auth.rs +assertion failed: expected Ok got Err at commit 0123456789abcdef0123456789abcdef01234567 +test result: FAILED. 1 failed in 12.4s"; + let run_b = "\ +2026-06-19T01:02:03Z FAIL tests::auth at /var/folders/zz/def999/loop-worktrees/issue-1/src/auth.rs +assertion failed: expected Ok got Err at commit fedcba9876543210fedcba9876543210fedcba98 +test result: FAILED. 1 failed in 0.9s"; + assert_eq!( + validation_sig(&[1], run_a), + validation_sig(&[1], run_b), + "same failure with only volatile parts differing must share a sig" + ); + } + + #[test] + fn normalize_keeps_genuinely_different_failures_distinct() { + let base = "assertion failed at tests::math\nexpected 0xdeadbeef got 0xcafef00d"; + // Different exit code → different sig. + assert_ne!(validation_sig(&[1], base), validation_sig(&[2], base)); + // Different assertion expected/got values (short hex must NOT be stripped). + let other_values = "assertion failed at tests::math\nexpected 0x12345678 got 0x000000ff"; + assert_ne!(validation_sig(&[1], base), validation_sig(&[1], other_values)); + // Different error message. + let other_msg = "panic: index out of bounds\nexpected 0xdeadbeef got 0xcafef00d"; + assert_ne!(validation_sig(&[1], base), validation_sig(&[1], other_msg)); + // Different test name. + let other_test = "assertion failed at tests::geometry\nexpected 0xdeadbeef got 0xcafef00d"; + assert_ne!(validation_sig(&[1], base), validation_sig(&[1], other_test)); + } + + /// Minimal spawner: the "agent" is simulated by the test mutating the + /// worktree directly, so the stub only needs to hand back a connection id. + struct StubSpawner; + + #[async_trait] + impl LoopAgentSpawner for StubSpawner { + async fn spawn_loop_agent( + &self, + _db: &AppDatabase, + _data_dir: &Path, + _agent_type: AgentType, + _working_dir: String, + _emitter: EventEmitter, + _preferred_mode_id: Option, + _preferred_config_values: std::collections::BTreeMap, + _capability_token: String, + ) -> Result { + Ok("loop-conn".to_string()) + } + async fn send_loop_prompt( + &self, + _db: &AppDatabase, + _conn_id: &str, + _text: String, + _folder_id: i32, + _conversation_id: i32, + ) -> Result<(), AcpError> { + Ok(()) + } + async fn disconnect_loop_agent(&self, _conn_id: &str) {} + async fn find_loop_connection(&self, _conversation_id: i32) -> Option { + None + } + } + + fn git(dir: &Path, args: &[&str]) { + let st = StdCommand::new("git") + .args(args) + .current_dir(dir) + .status() + .expect("spawn git"); + assert!(st.success(), "git {args:?} failed"); + } + + fn init_repo(dir: &Path) { + git(dir, &["init", "-q"]); + git(dir, &["config", "user.email", "t@example.com"]); + git(dir, &["config", "user.name", "tester"]); + std::fs::write(dir.join("README.md"), "hello\n").unwrap(); + git(dir, &["add", "-A"]); + git(dir, &["commit", "-q", "-m", "init"]); + } + + struct Harness { + db: AppDatabase, + data: tempfile::TempDir, + _repo: tempfile::TempDir, + issue_id: i32, + space_id: i32, + worktree_folder_id: i32, + worktree_path: PathBuf, + } + + /// Real git repo + worktree + a running issue. Returns a harness whose + /// tempdirs stay alive for the test's duration. + async fn setup() -> Harness { + let repo = tempfile::tempdir().unwrap(); + init_repo(repo.path()); + let data = tempfile::tempdir().unwrap(); + let db = fresh_disk_db(data.path()).await; + let folder_id = seed_folder(&db, &repo.path().to_string_lossy()).await; + let space = space::create_space(&db.conn, "S", folder_id).await.unwrap(); + let issue = issue::create_issue( + &db.conn, + space.id, + "Build", + "do the thing", + IssuePriority::Medium, + Some(&IssueConfig::default()), + ) + .await + .unwrap(); + let ctx = worktree::ensure_worktree(&db.conn, data.path(), issue.row.id) + .await + .unwrap(); + // Mark the issue running (trigger would do this). + loop_issue::Entity::update_many() + .col_expr( + loop_issue::Column::Status, + Expr::value(IssueStatus::Running.to_value()), + ) + .filter(loop_issue::Column::Id.eq(issue.row.id)) + .exec(&db.conn) + .await + .unwrap(); + Harness { + db, + data, + _repo: repo, + issue_id: issue.row.id, + space_id: space.id, + worktree_folder_id: ctx.worktree_folder_id, + worktree_path: ctx.worktree_path, + } + } + + /// Mint a pending task node linked to the issue root (as the plan stage does). + async fn add_task(h: &Harness, title: &str) -> i32 { + let dag = artifact::list_dag(&h.db.conn, h.issue_id).await.unwrap(); + let root = dag + .artifacts + .iter() + .find(|a| a.kind == ArtifactKind::Issue) + .unwrap() + .id; + let task = artifact::create_artifact( + &h.db.conn, + h.space_id, + h.issue_id, + ArtifactKind::Task, + title, + ArtifactStatus::Pending, + ActorKind::Agent, + None, + ) + .await + .unwrap(); + // A real plan attaches the task's own acceptance criterion; the review + // gate injects it as the `T1` check handle. + artifact::add_criterion( + &h.db.conn, + task.id, + crate::db::entities::loop_criterion::CriterionKind::Acceptance, + "the task is implemented correctly", + ) + .await + .unwrap(); + link::create_link(&h.db.conn, h.space_id, task.id, root, LinkKind::DerivesFrom, None) + .await + .unwrap(); + task.id + } + + async fn load_issue(h: &Harness) -> loop_issue::Model { + loop_issue::Entity::find_by_id(h.issue_id) + .one(&h.db.conn) + .await + .unwrap() + .unwrap() + } + + async fn drive(h: &Harness) -> StepOutcome { + drive_tracking(h, &IssueConfig::default(), &mut HashMap::new()).await + } + + /// Drive with an explicit infra-retry counter that persists across calls (for + /// the bounded-retry test); the convenience `drive`/`drive_with` discard it. + async fn drive_tracking( + h: &Harness, + config: &IssueConfig, + infra_retries: &mut HashMap, + ) -> StepOutcome { + let issue = load_issue(h).await; + let dag = artifact::list_dag(&h.db.conn, h.issue_id).await.unwrap(); + drive_active_task( + &h.db, + h.data.path(), + &StubSpawner, + &EventEmitter::Noop, + &issue, + &dag, + config, + h.worktree_folder_id, + infra_retries, + ) + .await + .unwrap() + } + + async fn running_implement_id(h: &Harness) -> i32 { + loop_iteration::Entity::find() + .filter(loop_iteration::Column::Stage.eq(Stage::Implement)) + .filter(loop_iteration::Column::Status.eq(IterationStatus::Running)) + .one(&h.db.conn) + .await + .unwrap() + .expect("a running implement iteration") + .id + } + + async fn task_node(h: &Harness, id: i32) -> LoopArtifactRow { + artifact::list_dag(&h.db.conn, h.issue_id) + .await + .unwrap() + .artifacts + .into_iter() + .find(|a| a.id == id) + .unwrap() + } + + /// The raw artifact row — for fields the DAG DTO omits (e.g. last_failure_sig). + async fn task_model(h: &Harness, id: i32) -> loop_artifact::Model { + loop_artifact::Entity::find_by_id(id) + .one(&h.db.conn) + .await + .unwrap() + .unwrap() + } + + /// Pure `Task` row for the dependency-frontier tests (`ready_tasks`). + fn ready_task_row(id: i32, status: ArtifactStatus) -> LoopArtifactRow { + LoopArtifactRow { + id, + issue_id: 1, + issue_seq: 1, + kind: ArtifactKind::Task, + title: format!("T{id}"), + status, + origin: ActorKind::Agent, + produced_by_iteration_id: None, + verdict: None, + attempt: 0, + contribution_kind: loop_artifact::ContributionKind::Delta, + sort: id, + updated_at: Utc::now(), + } + } + + /// Build a task-only DAG. `edges` are `(successor, predecessor)` pairs — the + /// `DependsOn` direction (from = successor, to = predecessor). + fn depends_dag(tasks: &[(i32, ArtifactStatus)], edges: &[(i32, i32)]) -> LoopDagView { + LoopDagView { + artifacts: tasks.iter().map(|&(id, st)| ready_task_row(id, st)).collect(), + links: edges + .iter() + .enumerate() + .map(|(i, &(succ, pred))| LoopLinkRow { + id: i as i32 + 1, + from_artifact_id: succ, + to_artifact_id: pred, + kind: LinkKind::DependsOn, + source_revision_id: None, + }) + .collect(), + coverage: Vec::new(), + criterion_checks: Vec::new(), + gate_decisions: Vec::new(), + live_iterations: Vec::new(), + artifact_iteration_refs: Vec::new(), + } + } + + #[test] + fn ready_tasks_chain() { + use ArtifactStatus::{Done, Pending}; + // A→B→C, all pending: only the root A is ready. + let dag = depends_dag(&[(1, Pending), (2, Pending), (3, Pending)], &[(2, 1), (3, 2)]); + assert_eq!( + ready_tasks(&dag).iter().map(|t| t.id).collect::>(), + vec![1] + ); + // A done → B becomes ready; C is still blocked behind B. + let dag = depends_dag(&[(1, Done), (2, Pending), (3, Pending)], &[(2, 1), (3, 2)]); + assert_eq!( + ready_tasks(&dag).iter().map(|t| t.id).collect::>(), + vec![2] + ); + } + + #[test] + fn ready_tasks_fanout() { + use ArtifactStatus::{Done, Pending}; + // A→B, A→C. While A is pending, neither successor is ready. + let dag = depends_dag(&[(1, Pending), (2, Pending), (3, Pending)], &[(2, 1), (3, 1)]); + assert_eq!( + ready_tasks(&dag).iter().map(|t| t.id).collect::>(), + vec![1] + ); + // A done → B and C are BOTH ready at once (true parallelism). + let dag = depends_dag(&[(1, Done), (2, Pending), (3, Pending)], &[(2, 1), (3, 1)]); + assert_eq!( + ready_tasks(&dag).iter().map(|t| t.id).collect::>(), + vec![2, 3] + ); + } + + #[test] + fn ready_tasks_edge_direction_contract() { + use ArtifactStatus::{Done, Pending}; + // B depends_on A ⇒ edge (from=B, to=A). B's readiness is gated on A being + // Done, never the reverse. + let blocked = depends_dag(&[(1, Pending), (2, Pending)], &[(2, 1)]); + assert_eq!( + ready_tasks(&blocked).iter().map(|t| t.id).collect::>(), + vec![1] + ); + let unblocked = depends_dag(&[(1, Done), (2, Pending)], &[(2, 1)]); + assert_eq!( + ready_tasks(&unblocked).iter().map(|t| t.id).collect::>(), + vec![2] + ); + } + + #[tokio::test] + async fn blocked_task_does_not_strand_other_ready_tasks() { + let h = setup().await; + set_execution_mode(&h, "parallel").await; + let a = add_task(&h, "A").await; + let b = add_task(&h, "B").await; + // A is blocked; B is independent and ready. With no per-issue write gate, a + // blocked task must not strand B — the drive still dispatches B's implement. + cas_artifact_status(&h.db.conn, a, ArtifactStatus::Pending, ArtifactStatus::Blocked) + .await + .unwrap(); + assert_eq!(drive(&h).await, StepOutcome::Dispatched); + assert_eq!( + implement_iterations(&h.db, h.issue_id, b).await.unwrap().len(), + 1, + "ready task B dispatched despite blocked A" + ); + assert!( + implement_iterations(&h.db, h.issue_id, a) + .await + .unwrap() + .is_empty(), + "blocked A never dispatched" + ); + } + + #[tokio::test] + async fn parallel_dispatches_all_ready_tasks_concurrently() { + let h = setup().await; + set_execution_mode(&h, "parallel").await; + add_task(&h, "A").await; + add_task(&h, "B").await; + + // One drive launches BOTH independent tasks' implement iterations at once — + // true concurrency, not the old one-at-a-time gate. + assert_eq!(drive(&h).await, StepOutcome::Dispatched); + let running = loop_iteration::Entity::find() + .filter(loop_iteration::Column::Stage.eq(Stage::Implement)) + .filter(loop_iteration::Column::Status.eq(IterationStatus::Running)) + .all(&h.db.conn) + .await + .unwrap(); + assert_eq!( + running.len(), + 2, + "both ready tasks have a running implement in the same tick" + ); + } + + #[tokio::test] + async fn worktree_add_failure_retries_then_blocks() { + let h = setup().await; + set_execution_mode(&h, "parallel").await; + let a = add_task(&h, "A").await; + let b = add_task(&h, "B").await; + // B depends on A. Mark A Done but freeze it at a BOGUS commit, so + // ensure_task_worktree(B) cannot resolve its base ref — a deterministic + // infra failure on every attempt. + link::create_link(&h.db.conn, h.space_id, b, a, LinkKind::DependsOn, None) + .await + .unwrap(); + cas_artifact_status(&h.db.conn, a, ArtifactStatus::Pending, ArtifactStatus::InProgress) + .await + .unwrap(); + crate::loop_engine::transitions::cas_task_done_with_contribution( + &h.db.conn, + a, + crate::loop_engine::transitions::TaskContribution::Delta("dead".repeat(10)), + ) + .await + .unwrap(); + assert_eq!(task_node(&h, a).await.status, ArtifactStatus::Done); + + let mut retries: HashMap = HashMap::new(); + // Each of the first INFRA_RETRY_MAX-1 drives skips B (retry pending) without + // blocking; the failure streak is counted in driver memory. + for i in 1..INFRA_RETRY_MAX { + let out = drive_tracking(&h, &IssueConfig::default(), &mut retries).await; + assert_eq!(out, StepOutcome::Idle, "skip, awaiting retry"); + assert_eq!(retries.get(&b), Some(&i), "failure streak counted in memory"); + assert_eq!(load_issue(&h).await.status, IssueStatus::Running); + } + // The next failure trips the breaker: B + issue blocked, card filed, streak cleared. + let out = drive_tracking(&h, &IssueConfig::default(), &mut retries).await; + assert_eq!(out, StepOutcome::Advanced, "block is durable progress"); + assert_eq!(task_node(&h, b).await.status, ArtifactStatus::Blocked); + assert_eq!(load_issue(&h).await.status, IssueStatus::Blocked); + assert!(!retries.contains_key(&b), "streak cleared once blocked"); + let inbox = loop_service::inbox::list_inbox(&h.db.conn, h.space_id, None) + .await + .unwrap(); + assert!( + inbox.iter().any(|i| i.kind == InboxKind::Blocked + && i.subject_key == format!("infra_failure:{b}")), + "infra_failure card filed for the task" + ); + } + + #[tokio::test] + async fn dead_dependency_blocks_issue() { + let h = setup().await; + let t1 = add_task(&h, "T1").await; + let t2 = add_task(&h, "T2").await; + // T2 depends on T1; T1 is blocked → T2 can never start. + link::create_link(&h.db.conn, h.space_id, t2, t1, LinkKind::DependsOn, None) + .await + .unwrap(); + cas_artifact_status(&h.db.conn, t1, ArtifactStatus::Pending, ArtifactStatus::Blocked) + .await + .unwrap(); + // No gate, no in-flight, no ready task → detect the dead dependency and + // block the issue (retry-reachable) with a clear card, never park silently. + assert_eq!(drive(&h).await, StepOutcome::Advanced); + assert_eq!(load_issue(&h).await.status, IssueStatus::Blocked); + let inbox = loop_service::inbox::list_inbox(&h.db.conn, h.space_id, None) + .await + .unwrap(); + assert!( + inbox.iter().any(|i| i.kind == InboxKind::Blocked + && i.subject_key == format!("dependency_unsatisfiable:{}", h.issue_id)), + "files a dependency_unsatisfiable card" + ); + } + + async fn set_execution_mode(h: &Harness, mode: &str) { + loop_issue::Entity::update_many() + .col_expr(loop_issue::Column::ExecutionMode, Expr::value(mode)) + .filter(loop_issue::Column::Id.eq(h.issue_id)) + .exec(&h.db.conn) + .await + .unwrap(); + } + + #[tokio::test] + async fn parallel_task_implements_in_its_own_worktree() { + let h = setup().await; + set_execution_mode(&h, "parallel").await; + let task = add_task(&h, "T").await; + + // Parallel mode: drive ensures the task's own worktree and dispatches + // implement there. + assert_eq!(drive(&h).await, StepOutcome::Dispatched); + let impl_id = running_implement_id(&h).await; + + // The task worktree is distinct from the issue worktree. + let task_wt = worktree::ensure_task_worktree(&h.db.conn, h.data.path(), h.issue_id, task) + .await + .unwrap(); + assert_ne!(task_wt.worktree_path, h.worktree_path); + + // The agent edits the TASK worktree; settle; drive → checkpoint commits + // there (not the issue worktree). + std::fs::write(task_wt.worktree_path.join("feature.txt"), "work\n").unwrap(); + settle_iteration(&h.db, &EventEmitter::Noop, impl_id) + .await + .unwrap(); + assert_eq!(drive(&h).await, StepOutcome::Advanced); + + assert_eq!(task_node(&h, task).await.status, ArtifactStatus::InProgress); + assert!(task_wt.worktree_path.join("feature.txt").exists()); + assert!( + !h.worktree_path.join("feature.txt").exists(), + "parallel work stays off the issue worktree until fan-in" + ); + } + + /// Regression for the driver wedge: an implement settle must report `Advanced` + /// (not the old `Ok(false)` that folded into Idle and parked forever), promote + /// the task, and let the very next drive dispatch review — no manual tick. + #[tokio::test] + async fn implement_settle_advances_then_next_drive_dispatches_review() { + let h = setup().await; + let task = add_task(&h, "T").await; + + // A round: gate free → claim + dispatch implement (in flight). + assert_eq!(drive(&h).await, StepOutcome::Dispatched); + let impl_id = running_implement_id(&h).await; + + // Simulate the agent editing the tree, then the turn settling. + std::fs::write(h.worktree_path.join("feature.txt"), "work\n").unwrap(); + settle_iteration(&h.db, &EventEmitter::Noop, impl_id) + .await + .unwrap(); + + // The post-settle drive must report Advanced (was wedged as Idle before), + // with the task promoted to in_progress (checkpoint + validate ran; the + // default config has no validation_commands). + assert_eq!(drive(&h).await, StepOutcome::Advanced); + assert_eq!(task_node(&h, task).await.status, ArtifactStatus::InProgress); + + // The next drive dispatches review immediately — no external tick / resume. + assert_eq!(drive(&h).await, StepOutcome::Dispatched); + let has_review = loop_iteration::Entity::find() + .filter(loop_iteration::Column::Stage.eq(Stage::Review)) + .one(&h.db.conn) + .await + .unwrap() + .is_some(); + assert!(has_review, "review dispatched right after implement advanced"); + } + + /// Simulate `run_driver`'s fixpoint: keep driving while it reports `Advanced`. + /// **Bounded** — a non-converging chain panics (fail-fast in CI) rather than + /// hanging, which a real fix never reaches. + async fn drive_to_quiescence(h: &Harness) -> StepOutcome { + for _ in 0..256 { + match drive(h).await { + StepOutcome::Advanced => continue, + other => return other, + } + } + panic!("driver did not reach quiescence within bound — non-progressing Advanced"); + } + + /// End-to-end fixpoint: a single implement settle, then the fixpoint loop must + /// reach a *running* review with no external tick / manual resume. + #[tokio::test] + async fn settle_then_fixpoint_reaches_review_without_manual_tick() { + let h = setup().await; + add_task(&h, "T").await; + + assert_eq!(drive(&h).await, StepOutcome::Dispatched); + let impl_id = running_implement_id(&h).await; + std::fs::write(h.worktree_path.join("feature.txt"), "work\n").unwrap(); + settle_iteration(&h.db, &EventEmitter::Noop, impl_id) + .await + .unwrap(); + + // One settle, then run the fixpoint: it should stop at "review dispatched" + // (Dispatched), entirely without external intervention. + assert_eq!(drive_to_quiescence(&h).await, StepOutcome::Dispatched); + let review = loop_iteration::Entity::find() + .filter(loop_iteration::Column::Stage.eq(Stage::Review)) + .filter(loop_iteration::Column::Status.eq(IterationStatus::Running)) + .one(&h.db.conn) + .await + .unwrap(); + assert!(review.is_some(), "review running after a single implement settle"); + } + + #[tokio::test] + async fn undecided_mode_drives_one_task_at_a_time() { + let h = setup().await; + let t1 = add_task(&h, "Task 1").await; + let t2 = add_task(&h, "Task 2").await; + + // execution_mode is unset (not `parallel`): the two tasks would share the + // issue worktree, so the drive serializes to the lowest-ordered task. + assert_eq!( + drive(&h).await, + StepOutcome::Dispatched, + "first tick dispatches an implement" + ); + + // A second tick (no completion yet) must not start the other task. + assert_eq!( + drive(&h).await, + StepOutcome::Idle, + "no second implement while the first is in flight" + ); + let iters = loop_iteration::Entity::find() + .filter(loop_iteration::Column::Stage.eq(Stage::Implement)) + .all(&h.db.conn) + .await + .unwrap(); + assert_eq!(iters.len(), 1, "exactly one implement iteration"); + assert_eq!(iters[0].target_artifact_id, Some(t1)); + // Task 2 never got an iteration. + assert!(implement_iterations(&h.db, h.issue_id, t2) + .await + .unwrap() + .is_empty()); + } + + #[tokio::test] + async fn repeated_tick_no_duplicate_dispatch_per_task() { + let h = setup().await; + set_execution_mode(&h, "parallel").await; + let a = add_task(&h, "A").await; + let b = add_task(&h, "B").await; + + // First tick fans out implement to both independent tasks. + assert_eq!(drive(&h).await, StepOutcome::Dispatched); + // A second tick (nothing settled) must not start a duplicate for either — + // the `(issue, target)` implement lease makes the re-dispatch a no-op. + assert_eq!(drive(&h).await, StepOutcome::Idle); + for t in [a, b] { + assert_eq!( + implement_iterations(&h.db, h.issue_id, t) + .await + .unwrap() + .len(), + 1, + "exactly one implement iteration per task across repeated ticks" + ); + } + } + + #[tokio::test] + async fn implement_success_checkpoints_and_advances() { + let h = setup().await; + let task = add_task(&h, "Task 1").await; + + // Tick 1: dispatch implement for the task. + assert_eq!(drive(&h).await, StepOutcome::Dispatched); + let iter_id = running_implement_id(&h).await; + + // The agent makes a change in the worktree (non-empty diff), then the + // turn settles. + std::fs::write(h.worktree_path.join("feature.txt"), "new code\n").unwrap(); + settle_iteration(&h.db, &EventEmitter::Noop, iter_id) + .await + .unwrap(); + // Settlement must not bump the task's rework counter for implement. + assert_eq!(task_node(&h, task).await.attempt, 0); + + // Tick 2: checkpoint the diff → commit + promote the task. + assert_eq!( + drive(&h).await, + StepOutcome::Advanced, + "checkpoint/advance is an advance, not a dispatch" + ); + let node = task_node(&h, task).await; + assert_eq!(node.status, ArtifactStatus::InProgress, "task implemented"); + assert_eq!(node.attempt, 0, "successful implement does not bump attempt"); + + // The change was committed onto the issue branch (HEAD advanced) and the + // tree is clean. + let log = StdCommand::new("git") + .args(["log", "--oneline"]) + .current_dir(&h.worktree_path) + .output() + .unwrap(); + let log = String::from_utf8_lossy(&log.stdout); + assert!(log.contains("implement"), "checkpoint commit present:\n{log}"); + let status = StdCommand::new("git") + .args(["status", "--porcelain"]) + .current_dir(&h.worktree_path) + .output() + .unwrap(); + assert!(status.stdout.is_empty(), "worktree clean after checkpoint"); + } + + #[tokio::test] + async fn implement_empty_diff_counts_no_progress() { + let h = setup().await; + let task = add_task(&h, "Task 1").await; + + assert_eq!(drive(&h).await, StepOutcome::Dispatched); + let iter_id = running_implement_id(&h).await; + // Agent produced no change. Settle, then drive: empty diff → no progress. + settle_iteration(&h.db, &EventEmitter::Noop, iter_id) + .await + .unwrap(); + + // C3 invariant: a settled implement before its checkpoint has NO outcome yet. + assert_eq!( + loop_iteration::Entity::find_by_id(iter_id) + .one(&h.db.conn) + .await + .unwrap() + .unwrap() + .outcome, + None, + "implement outcome stays NULL until the checkpoint runs (C3)" + ); + + // Tick 2: checkpoint finds nothing → rework bump + retry dispatch. + assert_eq!( + drive(&h).await, + StepOutcome::Dispatched, + "no-progress retries implement" + ); + // D11: the checkpoint recorded the implement's outcome as empty_diff. + assert_eq!( + loop_iteration::Entity::find_by_id(iter_id) + .one(&h.db.conn) + .await + .unwrap() + .unwrap() + .outcome, + Some(IterationOutcome::EmptyDiff) + ); + let node = task_node(&h, task).await; + assert_eq!(node.attempt, 1, "rework counter bumped"); + assert_eq!(node.status, ArtifactStatus::Pending, "still awaiting implement"); + assert_eq!( + task_model(&h, task).await.last_failure_sig.as_deref(), + Some("empty_diff:implement") + ); + // The retry is a fresh implement iteration at the new attempt. + let running = loop_iteration::Entity::find() + .filter(loop_iteration::Column::Stage.eq(Stage::Implement)) + .filter(loop_iteration::Column::Status.eq(IterationStatus::Running)) + .one(&h.db.conn) + .await + .unwrap() + .unwrap(); + assert_eq!(running.attempt, 1); + } + + #[tokio::test] + async fn implement_declared_complete_routes_to_review_without_rework() { + let h = setup().await; + let task = add_task(&h, "Task 1").await; + + assert_eq!(drive(&h).await, StepOutcome::Dispatched); + let iter_id = running_implement_id(&h).await; + // Agent makes NO change but declares the task already satisfied (D12). + settle_iteration(&h.db, &EventEmitter::Noop, iter_id) + .await + .unwrap(); + loop_iteration::Entity::update_many() + .col_expr( + loop_iteration::Column::AgentCompletionReason, + Expr::value("dependency #2 already delivered this"), + ) + .filter(loop_iteration::Column::Id.eq(iter_id)) + .exec(&h.db.conn) + .await + .unwrap(); + + // Checkpoint: empty diff BUT declared → route to review, NOT a no-progress + // rework. (The driver advances the now-in_progress task into its review + // round in the same tick.) + let _ = drive(&h).await; + + let node = task_node(&h, task).await; + assert_eq!( + node.status, + ArtifactStatus::InProgress, + "declared no-op routes to review, not blocked/pending" + ); + assert_eq!(node.attempt, 0, "a declared no-op is NOT a rework — attempt unchanged"); + assert_eq!( + loop_iteration::Entity::find_by_id(iter_id) + .one(&h.db.conn) + .await + .unwrap() + .unwrap() + .outcome, + Some(IterationOutcome::DeclaredComplete), + ); + assert!( + task_model(&h, task).await.last_failure_sig.is_none(), + "no empty_diff failure is recorded for a declared no-op" + ); + } + + #[tokio::test] + async fn oscillation_breaker_promotes_after_repeated_block_epochs() { + use crate::db::entities::loop_inbox_item::{self, InboxStatus}; + + async fn card_status(h: &Harness, subject: &str) -> Option { + loop_inbox_item::Entity::find() + .filter(loop_inbox_item::Column::IssueId.eq(h.issue_id)) + .filter(loop_inbox_item::Column::SubjectKey.eq(subject.to_string())) + .one(&h.db.conn) + .await + .unwrap() + .map(|c| c.status) + } + async fn rearm(h: &Harness, task: i32) { + cas_artifact_status(&h.db.conn, task, ArtifactStatus::Blocked, ArtifactStatus::Pending) + .await + .unwrap(); + } + + let h = setup().await; + let task = add_task(&h, "T").await; + let cfg = IssueConfig { oscillation_limit: 2, ..IssueConfig::default() }; + let issue = load_issue(&h).await; + let np = format!("no_progress:{task}"); + let osc = format!("oscillation:{task}"); + + // Epoch 1: first block → ordinary no_progress card, count=1, no promotion. + mark_blocked(&h.db, &EventEmitter::Noop, &issue, &cfg, task, None, "repeated_failure", "S", 1) + .await + .unwrap(); + assert_eq!(task_model(&h, task).await.oscillation_count, 1); + assert_eq!(card_status(&h, &np).await, Some(InboxStatus::Pending)); + assert_eq!(card_status(&h, &osc).await, None, "no oscillation card below limit"); + + // Epoch 2 (same sig, after a re-arm): count hits the limit → promote. + rearm(&h, task).await; + mark_blocked(&h.db, &EventEmitter::Noop, &issue, &cfg, task, None, "repeated_failure", "S", 2) + .await + .unwrap(); + assert_eq!(task_model(&h, task).await.oscillation_count, 2); + assert_eq!( + card_status(&h, &osc).await, + Some(InboxStatus::Pending), + "promoted to an oscillation card" + ); + assert_eq!( + card_status(&h, &np).await, + Some(InboxStatus::Handled), + "the ordinary no_progress card is superseded" + ); + + // Replay WITHOUT a re-arm (task already blocked) must NOT inflate the count. + mark_blocked(&h.db, &EventEmitter::Noop, &issue, &cfg, task, None, "repeated_failure", "S", 3) + .await + .unwrap(); + assert_eq!( + task_model(&h, task).await.oscillation_count, + 2, + "an idempotent replay does not step the epoch" + ); + } + + #[tokio::test] + async fn oscillation_breaker_off_when_limit_zero() { + use crate::db::entities::loop_inbox_item::{self, InboxStatus}; + let h = setup().await; + let task = add_task(&h, "T").await; + let cfg = IssueConfig { oscillation_limit: 0, ..IssueConfig::default() }; + let issue = load_issue(&h).await; + + // Many same-sig blocks with the breaker off → never an oscillation card. + for attempt in 1..=4 { + cas_artifact_status(&h.db.conn, task, ArtifactStatus::Blocked, ArtifactStatus::Pending) + .await + .ok(); + mark_blocked( + &h.db, &EventEmitter::Noop, &issue, &cfg, task, None, "repeated_failure", "S", + attempt, + ) + .await + .unwrap(); + } + let osc = loop_inbox_item::Entity::find() + .filter(loop_inbox_item::Column::IssueId.eq(h.issue_id)) + .filter(loop_inbox_item::Column::SubjectKey.eq(format!("oscillation:{task}"))) + .one(&h.db.conn) + .await + .unwrap(); + assert!(osc.is_none(), "limit=0 disables the oscillation breaker"); + assert!( + card_status_pending(&h, &format!("no_progress:{task}")).await, + "ordinary no_progress card still filed" + ); + // local helper kept inline to avoid a module-level addition + async fn card_status_pending(h: &Harness, subject: &str) -> bool { + loop_inbox_item::Entity::find() + .filter(loop_inbox_item::Column::IssueId.eq(h.issue_id)) + .filter(loop_inbox_item::Column::SubjectKey.eq(subject.to_string())) + .filter(loop_inbox_item::Column::Status.eq(InboxStatus::Pending)) + .one(&h.db.conn) + .await + .unwrap() + .is_some() + } + } + + // ---- Task 2.2: deterministic validation after implement ---- + + fn config_with_validation(cmds: &[&str]) -> IssueConfig { + IssueConfig { + validation_commands: cmds.iter().map(|s| s.to_string()).collect(), + ..IssueConfig::default() + } + } + + async fn drive_with(h: &Harness, config: &IssueConfig) -> StepOutcome { + drive_tracking(h, config, &mut HashMap::new()).await + } + + /// Implement → checkpoint → validation passes → task implemented (in_progress). + #[cfg(unix)] + #[tokio::test] + async fn implement_passing_validation_advances() { + let h = setup().await; + let task = add_task(&h, "Task 1").await; + let cfg = config_with_validation(&["true"]); + + assert_eq!( + drive_with(&h, &cfg).await, + StepOutcome::Dispatched, + "tick 1 dispatches implement" + ); + let iter_id = running_implement_id(&h).await; + std::fs::write(h.worktree_path.join("feature.txt"), "code\n").unwrap(); + settle_iteration(&h.db, &EventEmitter::Noop, iter_id) + .await + .unwrap(); + + // Tick 2: checkpoint + validation(pass) → advance (not a dispatch). + assert_eq!(drive_with(&h, &cfg).await, StepOutcome::Advanced); + assert_eq!(task_node(&h, task).await.status, ArtifactStatus::InProgress); + let runs = loop_service::validation::list_for_task(&h.db.conn, task) + .await + .unwrap(); + assert_eq!(runs.len(), 1, "one validation run recorded"); + assert!(runs[0].passed, "run passed"); + // D11: a validated implement records `succeeded`. + assert_eq!( + loop_iteration::Entity::find_by_id(iter_id) + .one(&h.db.conn) + .await + .unwrap() + .unwrap() + .outcome, + Some(IterationOutcome::Succeeded) + ); + } + + /// Implement → checkpoint → validation fails → rework (attempt++, re-dispatch). + #[cfg(unix)] + #[tokio::test] + async fn implement_failing_validation_reworks() { + let h = setup().await; + let task = add_task(&h, "Task 1").await; + let cfg = config_with_validation(&["false"]); + + assert_eq!(drive_with(&h, &cfg).await, StepOutcome::Dispatched); + let iter_id = running_implement_id(&h).await; + std::fs::write(h.worktree_path.join("feature.txt"), "code\n").unwrap(); + settle_iteration(&h.db, &EventEmitter::Noop, iter_id) + .await + .unwrap(); + + // Tick 2: checkpoint + validation(fail) → rework + re-dispatch implement. + assert_eq!( + drive_with(&h, &cfg).await, + StepOutcome::Dispatched, + "validation failure retries implement" + ); + let node = task_node(&h, task).await; + assert_eq!(node.attempt, 1, "rework counter bumped"); + assert_eq!( + node.status, + ArtifactStatus::Pending, + "back to awaiting implement" + ); + assert!( + task_model(&h, task) + .await + .last_failure_sig + .as_deref() + .unwrap() + .starts_with("validation_failed:"), + "failure signature records a validation failure" + ); + let runs = loop_service::validation::list_for_task(&h.db.conn, task) + .await + .unwrap(); + assert!(!runs[0].passed, "failing run recorded"); + // D11: a failed-validation implement records `validation_failed`. + assert_eq!( + loop_iteration::Entity::find_by_id(iter_id) + .one(&h.db.conn) + .await + .unwrap() + .unwrap() + .outcome, + Some(IterationOutcome::ValidationFailed) + ); + // The retry is a fresh implement iteration at the new attempt. + let running = loop_iteration::Entity::find() + .filter(loop_iteration::Column::Stage.eq(Stage::Implement)) + .filter(loop_iteration::Column::Status.eq(IterationStatus::Running)) + .one(&h.db.conn) + .await + .unwrap() + .unwrap(); + assert_eq!(running.attempt, 1); + } + + /// Implement → checkpoint → validation can't run (missing tool) → task blocked + /// + inbox card; no rework (not the agent's fault), no further dispatch. + #[cfg(unix)] + #[tokio::test] + async fn implement_unrunnable_validation_blocks() { + let h = setup().await; + let task = add_task(&h, "Task 1").await; + let cfg = config_with_validation(&["codeg-no-such-tool-xyzzy"]); + + assert_eq!(drive_with(&h, &cfg).await, StepOutcome::Dispatched); + let iter_id = running_implement_id(&h).await; + std::fs::write(h.worktree_path.join("feature.txt"), "code\n").unwrap(); + settle_iteration(&h.db, &EventEmitter::Noop, iter_id) + .await + .unwrap(); + + // Tick 2: checkpoint + validation(unrunnable) → block the task AND the + // issue (an advance, not a dispatch); the driver then re-ticks and stops, + // and the now-blocked issue is reachable by the human `retry`. + assert_eq!( + drive_with(&h, &cfg).await, + StepOutcome::Advanced, + "unrunnable validation blocks the task" + ); + let node = task_node(&h, task).await; + assert_eq!(node.status, ArtifactStatus::Blocked); + assert_eq!(node.attempt, 0, "config error does not consume a rework"); + assert_eq!( + load_issue(&h).await.status, + IssueStatus::Blocked, + "issue blocked too, so the human retry can reach it" + ); + // A blocked inbox card was filed for the task. + let inbox = loop_service::inbox::list_inbox(&h.db.conn, h.space_id, None) + .await + .unwrap(); + assert!( + inbox.iter().any(|i| i.kind == InboxKind::Blocked + && i.subject_key == format!("validation_blocked:{task}")), + "blocked inbox card filed" + ); + } + + // ---- Task 2.3: review stage ---- + + fn config_reviewers(n: u32, rule: ReviewPassRule) -> IssueConfig { + IssueConfig { + reviewers: (0..n) + .map(|_| ReviewerEntry::Inherit(ReviewerInherit { inherit: true })) + .collect(), + review_pass_rule: rule, + ..IssueConfig::default() + } + } + + /// Drive a fresh task from pending to `in_progress` (implemented + validated) + /// so review tests can start at the review stage. + async fn implement_to_in_progress(h: &Harness, cfg: &IssueConfig, marker: &str) { + assert_eq!( + drive_with(h, cfg).await, + StepOutcome::Dispatched, + "dispatch implement" + ); + let iter_id = running_implement_id(h).await; + std::fs::write(h.worktree_path.join(marker), "code\n").unwrap(); + settle_iteration(&h.db, &EventEmitter::Noop, iter_id) + .await + .unwrap(); + assert_eq!( + drive_with(h, cfg).await, + StepOutcome::Advanced, + "checkpoint + validate → in_progress" + ); + } + + async fn running_review(h: &Harness) -> i32 { + loop_iteration::Entity::find() + .filter(loop_iteration::Column::Stage.eq(Stage::Review)) + .filter(loop_iteration::Column::Status.eq(IterationStatus::Running)) + .one(&h.db.conn) + .await + .unwrap() + .expect("a running review iteration") + .id + } + + async fn review_iters_of(h: &Harness, task: i32) -> Vec { + let mut v = loop_iteration::Entity::find() + .filter(loop_iteration::Column::Stage.eq(Stage::Review)) + .filter(loop_iteration::Column::TargetArtifactId.eq(task)) + .all(&h.db.conn) + .await + .unwrap(); + v.sort_by_key(|it| it.slot_no); + v + } + + /// A reviewer submits its per-criterion checks through the real ingest path + /// (token → running iteration → review artifact + checks + link). Submits one + /// check with `verdict` for EACH handle in the iteration's injected manifest + /// (what dispatch stashed), so the reviewed task's whole checklist is answered. + async fn submit_verdict(h: &Harness, review_iter_id: i32, verdict: &str, findings: &str) { + let it = loop_iteration::Entity::find_by_id(review_iter_id) + .one(&h.db.conn) + .await + .unwrap() + .unwrap(); + let manifest: serde_json::Value = it + .context_manifest + .as_deref() + .map(|s| serde_json::from_str(s).unwrap()) + .unwrap_or(serde_json::Value::Null); + let criteria = manifest + .get("criteria") + .and_then(|v| v.as_object()) + .cloned() + .unwrap_or_default(); + let evidence = if verdict == "fail" { + if findings.is_empty() { "defect found" } else { findings } + } else { + "verified" + }; + let checks: Vec = criteria + .keys() + .map(|handle| serde_json::json!({ "criterion": handle, "verdict": verdict, "evidence": evidence })) + .collect(); + crate::loop_engine::ingest::ingest( + &h.db.conn, + &it.capability_token, + "loop_submit_review", + &serde_json::json!({ "checks": checks, "findings": findings }), + ) + .await + .unwrap(); + } + + /// Review passes → task done (its accepted tip frozen as the integration + /// commit), dropping out of the next tick's drivable set. + #[tokio::test] + async fn review_pass_marks_done() { + let h = setup().await; + let task = add_task(&h, "Task 1").await; + let cfg = config_reviewers(1, ReviewPassRule::Unanimous); + implement_to_in_progress(&h, &cfg, "feature.txt").await; + assert_eq!(task_node(&h, task).await.status, ArtifactStatus::InProgress); + + // Dispatch the reviewer, who passes. + assert_eq!( + drive_with(&h, &cfg).await, + StepOutcome::Dispatched, + "dispatches a reviewer" + ); + let review = running_review(&h).await; + submit_verdict(&h, review, "pass", "looks good").await; + settle_iteration(&h.db, &EventEmitter::Noop, review) + .await + .unwrap(); + + // Aggregate → pass → task done. + assert_eq!(drive_with(&h, &cfg).await, StepOutcome::Advanced); + assert_eq!(task_node(&h, task).await.status, ArtifactStatus::Done); + + // The pass is recorded as an immutable gate decision over the reviewer's + // checks (the durable replay pivot). + let decisions = loop_service::gate_decision::list_for_issue(&h.db.conn, h.issue_id) + .await + .unwrap(); + assert_eq!(decisions.len(), 1); + assert_eq!(decisions[0].outcome, GateOutcome::Pass); + assert_eq!(decisions[0].stage, "review"); + assert!(!decisions[0].input_check_ids.is_empty(), "decision records its check ids"); + } + + /// Per-criterion aggregation (D8) is canonical: it can PASS where aggregating + /// per-reviewer verdicts would FAIL. 3 reviewers × 2 criteria, each criterion + /// clears a 2/3 majority, but only 1 of 3 reviewers is all-pass — so a verdict + /// majority rejects while the gate (correctly) accepts. + #[test] + fn aggregate_checks_per_criterion_diverges_from_verdict_majority() { + fn chk(criterion: i32, iteration: i32, v: CheckVerdict) -> LoopCriterionCheckRow { + LoopCriterionCheckRow { + id: 0, + criterion_id: criterion, + iteration_id: iteration, + scope_artifact_id: 0, + verdict: v, + evidence: String::new(), + } + } + // criteria 10 (A) & 11 (B); reviewers = iterations 100/101/102. + let checks = vec![ + chk(10, 100, CheckVerdict::Pass), chk(11, 100, CheckVerdict::Fail), // r0 + chk(10, 101, CheckVerdict::Fail), chk(11, 101, CheckVerdict::Pass), // r1 + chk(10, 102, CheckVerdict::Pass), chk(11, 102, CheckVerdict::Pass), // r2 + ]; + // Per criterion (Majority, n=3): A passes 2/3, B passes 2/3 → gate PASS. + assert_eq!( + aggregate_checks(ReviewPassRule::Majority, 3, &checks, &[10, 11]), + GateOutcome::Pass + ); + // Verdict-majority would reject: r0 & r1 each have a failing check (display + // verdict Fail), only r2 is all-pass → 2 of 3 fail. + let per_reviewer = [ReviewVerdict::Fail, ReviewVerdict::Fail, ReviewVerdict::Pass]; + assert!( + matches!(aggregate(ReviewPassRule::Majority, 3, &per_reviewer), ReviewDecision::Fail), + "aggregating per-reviewer verdicts would (wrongly) reject" + ); + // An empty injected set is never a vacuous pass. + assert_eq!( + aggregate_checks(ReviewPassRule::Unanimous, 1, &[], &[]), + GateOutcome::Undecided + ); + } + + /// One failing criterion → gate Fail recorded + rework (task back to pending at + /// the next attempt). The decision is keyed at the DECIDING attempt (0). + #[tokio::test] + async fn review_fail_records_gate_decision() { + let h = setup().await; + let task = add_task(&h, "Task 1").await; + let cfg = config_reviewers(1, ReviewPassRule::Unanimous); + implement_to_in_progress(&h, &cfg, "feature.txt").await; + + assert_eq!(drive_with(&h, &cfg).await, StepOutcome::Dispatched); + let review = running_review(&h).await; + submit_verdict(&h, review, "fail", "missing error handling").await; + settle_iteration(&h.db, &EventEmitter::Noop, review).await.unwrap(); + + assert_eq!(drive_with(&h, &cfg).await, StepOutcome::Advanced); + // Decision recorded Fail at attempt 0; task reworked to pending at attempt 1. + assert_eq!( + loop_service::gate_decision::outcome_for(&h.db.conn, task, "review", 0).await.unwrap(), + Some(GateOutcome::Fail) + ); + let node = task_node(&h, task).await; + assert_eq!(node.status, ArtifactStatus::Pending); + assert_eq!(node.attempt, 1); + } + + /// Replay pivot (D4): a recorded decision drives the side-effects on the next + /// tick without re-running reviewers — a crash after recording but before the + /// freeze is completed from the decision. Recording a Pass for an InProgress + /// task and driving freezes it Done with NO reviewer dispatched. + #[tokio::test] + async fn review_replay_freezes_from_recorded_decision() { + let h = setup().await; + let task = add_task(&h, "Task 1").await; + let cfg = config_reviewers(1, ReviewPassRule::Unanimous); + implement_to_in_progress(&h, &cfg, "feature.txt").await; + assert_eq!(task_node(&h, task).await.status, ArtifactStatus::InProgress); + + // Simulate "decision recorded, crash before freeze": persist a Pass decision + // at the current attempt with no reviewers run. + loop_service::gate_decision::record_decision( + &h.db.conn, h.space_id, h.issue_id, task, "review", 0, &[], &[], "{}", GateOutcome::Pass, + ) + .await + .unwrap(); + + // The next drive resolves the pivot → freeze the task Done, dispatching no + // reviewer. + assert_eq!(drive_with(&h, &cfg).await, StepOutcome::Advanced); + assert_eq!(task_node(&h, task).await.status, ArtifactStatus::Done); + assert!( + review_iters_of(&h, task).await.is_empty(), + "replay drove from the decision without dispatching a reviewer" + ); + } + + /// Each configured reviewer runs as its own slot with its own agent. + #[tokio::test] + async fn reviews_dispatch_per_reviewer_agent() { + use crate::db::entities::conversation; + let h = setup().await; + let task = add_task(&h, "Task 1").await; + // Two heterogeneous reviewers → two slots, each with its own agent. + let cfg = IssueConfig { + reviewers: vec![ + ReviewerEntry::Spec(ReviewerSpec { + agent: AgentType::ClaudeCode, + mode_id: None, + config_values: Default::default(), + }), + ReviewerEntry::Spec(ReviewerSpec { + agent: AgentType::Codex, + mode_id: None, + config_values: Default::default(), + }), + ], + ..IssueConfig::default() + }; + implement_to_in_progress(&h, &cfg, "feature.txt").await; + + // One drive dispatches both review slots. + assert_eq!( + drive_with(&h, &cfg).await, + StepOutcome::Dispatched, + "dispatches reviewers" + ); + let reviews = review_iters_of(&h, task).await; // sorted by slot_no + assert_eq!(reviews.len(), 2, "one iteration per configured reviewer"); + + // Slot 0 → claude_code, slot 1 → codex (the conversation records the agent). + let mut agents = Vec::new(); + for r in &reviews { + let conv = conversation::Entity::find_by_id(r.conversation_id.unwrap()) + .one(&h.db.conn) + .await + .unwrap() + .unwrap(); + agents.push(conv.agent_type); + } + assert_eq!( + agents, + vec!["claude_code".to_string(), "codex".to_string()] + ); + } + + /// Review fails → rework (task pending, attempt++, findings recorded); the + /// findings surface for the next implement briefing. + #[tokio::test] + async fn review_fail_reworks_with_findings() { + let h = setup().await; + let task = add_task(&h, "Task 1").await; + let cfg = config_reviewers(1, ReviewPassRule::Unanimous); + implement_to_in_progress(&h, &cfg, "feature.txt").await; + + assert_eq!(drive_with(&h, &cfg).await, StepOutcome::Dispatched); + let review = running_review(&h).await; + submit_verdict(&h, review, "fail", "missing error handling").await; + settle_iteration(&h.db, &EventEmitter::Noop, review) + .await + .unwrap(); + + // Aggregate → fail → rework (an advance, not a dispatch). + assert_eq!( + drive_with(&h, &cfg).await, + StepOutcome::Advanced, + "review fail reworks, not a dispatch" + ); + let node = task_node(&h, task).await; + assert_eq!(node.status, ArtifactStatus::Pending); + assert_eq!(node.attempt, 1); + assert!( + task_model(&h, task) + .await + .last_failure_sig + .as_deref() + .unwrap() + .starts_with("review_rejected:"), + "failure signature records a review rejection" + ); + let findings = loop_service::artifact::latest_failed_review_findings(&h.db.conn, task) + .await + .unwrap(); + assert_eq!(findings, vec!["missing error handling".to_string()]); + } + + /// Unanimous rule: one fail rejects immediately and cancels the still-running + /// reviewers (their late verdicts can no longer change the outcome). + #[tokio::test] + async fn unanimous_fail_fast_cancels_other_reviewers() { + let h = setup().await; + let task = add_task(&h, "Task 1").await; + let cfg = config_reviewers(2, ReviewPassRule::Unanimous); + implement_to_in_progress(&h, &cfg, "feature.txt").await; + + // Dispatch both review slots. + assert_eq!( + drive_with(&h, &cfg).await, + StepOutcome::Dispatched, + "dispatches reviewers" + ); + let reviews = review_iters_of(&h, task).await; + assert_eq!(reviews.len(), 2, "two review slots"); + + // Slot 0 fails; slot 1 is still running → unanimous fail-fast. + submit_verdict(&h, reviews[0].id, "fail", "regression").await; + settle_iteration(&h.db, &EventEmitter::Noop, reviews[0].id) + .await + .unwrap(); + + assert_eq!( + drive_with(&h, &cfg).await, + StepOutcome::Advanced, + "fail-fast reworks" + ); + assert_eq!(task_node(&h, task).await.status, ArtifactStatus::Pending); + let slot1 = loop_iteration::Entity::find_by_id(reviews[1].id) + .one(&h.db.conn) + .await + .unwrap() + .unwrap(); + assert_eq!( + slot1.status, + IterationStatus::Cancelled, + "the other reviewer was cancelled" + ); + } + + // ---- Task 2.4: circuit breakers ---- + + /// `max_attempts` exhausted → the task and its issue are blocked and a card + /// is filed. With `max_attempts = 1` the first failure trips it immediately. + #[cfg(unix)] + #[tokio::test] + async fn breaker_max_attempts_blocks_task_and_issue() { + let h = setup().await; + let task = add_task(&h, "Task 1").await; + let cfg = IssueConfig { + max_attempts: 1, + ..config_with_validation(&["false"]) + }; + + assert_eq!( + drive_with(&h, &cfg).await, + StepOutcome::Dispatched, + "tick 1 dispatches implement" + ); + let iter_id = running_implement_id(&h).await; + std::fs::write(h.worktree_path.join("feature.txt"), "code\n").unwrap(); + settle_iteration(&h.db, &EventEmitter::Noop, iter_id) + .await + .unwrap(); + + // Tick 2: validation fails at attempt 0 → bump→1 ≥ max(1) → block (an + // advance: the issue is now blocked, so the driver re-ticks then stops). + assert_eq!( + drive_with(&h, &cfg).await, + StepOutcome::Advanced, + "a breaker block advances (then stops), not a dispatch" + ); + let node = task_node(&h, task).await; + assert_eq!(node.status, ArtifactStatus::Blocked, "task blocked"); + assert_eq!(node.attempt, 1); + assert_eq!(load_issue(&h).await.status, IssueStatus::Blocked, "issue blocked"); + let inbox = loop_service::inbox::list_inbox(&h.db.conn, h.space_id, None) + .await + .unwrap(); + assert!( + inbox + .iter() + .any(|i| i.kind == InboxKind::Blocked + && i.subject_key == format!("no_progress:{task}")), + "no-progress inbox card filed" + ); + } + + /// Two consecutive identical failures trip the repeated-failure breaker even + /// though `max_attempts` (default 6) is far from exhausted. + #[cfg(unix)] + #[tokio::test] + async fn breaker_repeated_failure_blocks() { + let h = setup().await; + let task = add_task(&h, "Task 1").await; + let cfg = config_with_validation(&["false"]); // default max_attempts = 6 + + // Attempt 0: implement → validation fails → retry (not yet blocked). + assert_eq!(drive_with(&h, &cfg).await, StepOutcome::Dispatched); + let iter0 = running_implement_id(&h).await; + std::fs::write(h.worktree_path.join("feature.txt"), "code\n").unwrap(); + settle_iteration(&h.db, &EventEmitter::Noop, iter0) + .await + .unwrap(); + assert_eq!( + drive_with(&h, &cfg).await, + StepOutcome::Dispatched, + "attempt 0 failure retries" + ); + assert_eq!(task_node(&h, task).await.attempt, 1); + assert_eq!(task_node(&h, task).await.status, ArtifactStatus::Pending); + + // Attempt 1: an identical validation failure → repeated-failure breaker. + let iter1 = running_implement_id(&h).await; + std::fs::write(h.worktree_path.join("feature.txt"), "code2\n").unwrap(); + settle_iteration(&h.db, &EventEmitter::Noop, iter1) + .await + .unwrap(); + assert_eq!( + drive_with(&h, &cfg).await, + StepOutcome::Advanced, + "the repeat trips the breaker (advance, then stop)" + ); + + let node = task_node(&h, task).await; + assert_eq!(node.status, ArtifactStatus::Blocked, "task blocked on repeat"); + assert_eq!(node.attempt, 2); + assert_eq!(load_issue(&h).await.status, IssueStatus::Blocked); + } + + /// Settle-time budget breaker: once accumulated `token_used` crosses + /// `token_budget`, settling the iteration pauses the issue (`pause_reason = + /// budget`) and files a `budget_exhausted` card. (Complements the dispatch-time + /// pre-check below — here the overspend lands *during* an in-flight iteration.) + #[tokio::test] + async fn breaker_budget_pause_on_exhaustion() { + let h = setup().await; + let _task = add_task(&h, "Task 1").await; + + // Under budget at dispatch time so the pre-check admits the implement. + loop_issue::Entity::update_many() + .col_expr(loop_issue::Column::TokenUsed, Expr::value(0_i64)) + .col_expr(loop_issue::Column::TokenBudget, Expr::value(500_i64)) + .filter(loop_issue::Column::Id.eq(h.issue_id)) + .exec(&h.db.conn) + .await + .unwrap(); + assert_eq!(drive(&h).await, StepOutcome::Dispatched, "dispatch implement"); + let iter_id = running_implement_id(&h).await; + + // The iteration's usage lands over budget; settling re-evaluates the breaker. + loop_issue::Entity::update_many() + .col_expr(loop_issue::Column::TokenUsed, Expr::value(1000_i64)) + .filter(loop_issue::Column::Id.eq(h.issue_id)) + .exec(&h.db.conn) + .await + .unwrap(); + settle_iteration(&h.db, &EventEmitter::Noop, iter_id) + .await + .unwrap(); + + let issue = load_issue(&h).await; + assert_eq!(issue.status, IssueStatus::Paused, "issue paused on budget"); + assert_eq!(issue.pause_reason, Some(loop_issue::PauseReason::Budget)); + let inbox = loop_service::inbox::list_inbox(&h.db.conn, h.space_id, None) + .await + .unwrap(); + assert!( + inbox + .iter() + .any(|i| i.kind == InboxKind::BudgetExhausted + && i.subject_key == format!("budget:{}", h.issue_id)), + "budget_exhausted card filed" + ); + } + + /// Dispatch-time budget pre-check: when the issue is already at/over budget, a + /// drive must NOT start new task work — it pauses the issue instead. Bounds the + /// overspend a parallel fan-out could otherwise cause by launching many writes + /// before any settles. + #[tokio::test] + async fn budget_exhausted_skips_dispatch() { + let h = setup().await; + set_execution_mode(&h, "parallel").await; + add_task(&h, "A").await; + add_task(&h, "B").await; + + // Budget already fully consumed. + loop_issue::Entity::update_many() + .col_expr(loop_issue::Column::TokenUsed, Expr::value(500_i64)) + .col_expr(loop_issue::Column::TokenBudget, Expr::value(500_i64)) + .filter(loop_issue::Column::Id.eq(h.issue_id)) + .exec(&h.db.conn) + .await + .unwrap(); + + // Drive: over budget → pause, nothing dispatched. + assert_eq!(drive(&h).await, StepOutcome::Advanced); + let issue = load_issue(&h).await; + assert_eq!(issue.status, IssueStatus::Paused, "issue paused, not driven"); + assert_eq!(issue.pause_reason, Some(loop_issue::PauseReason::Budget)); + let any_impl = loop_iteration::Entity::find() + .filter(loop_iteration::Column::Stage.eq(Stage::Implement)) + .one(&h.db.conn) + .await + .unwrap(); + assert!(any_impl.is_none(), "no implement dispatched when over budget"); + } + + // ---- Task 2.5: finalize → result ---- + + async fn drive_finalize(h: &Harness, cfg: &IssueConfig) -> StepOutcome { + let issue = load_issue(h).await; + let dag = artifact::list_dag(&h.db.conn, h.issue_id).await.unwrap(); + run_finalize( + &h.db, + h.data.path(), + &StubSpawner, + &EventEmitter::Noop, + &issue, + &dag, + cfg, + h.worktree_folder_id, + ) + .await + .unwrap() + } + + async fn running_finalize(h: &Harness) -> loop_iteration::Model { + loop_iteration::Entity::find() + .filter(loop_iteration::Column::Stage.eq(Stage::Finalize)) + .filter(loop_iteration::Column::Status.eq(IterationStatus::Running)) + .one(&h.db.conn) + .await + .unwrap() + .expect("a running finalize iteration") + } + + /// Drive a fresh task all the way to `done` via a passing review, so the issue + /// is ready to finalize. + async fn complete_task(h: &Harness, cfg: &IssueConfig, marker: &str, task: i32) { + implement_to_in_progress(h, cfg, marker).await; + assert_eq!( + drive_with(h, cfg).await, + StepOutcome::Dispatched, + "dispatch reviewer" + ); + let review = running_review(h).await; + submit_verdict(h, review, "pass", "ok").await; + settle_iteration(&h.db, &EventEmitter::Noop, review) + .await + .unwrap(); + assert_eq!( + drive_with(h, cfg).await, + StepOutcome::Advanced, + "review pass → task done" + ); + assert_eq!(task_node(h, task).await.status, ArtifactStatus::Done); + } + + fn git_head(dir: &Path) -> String { + let out = StdCommand::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(dir) + .output() + .expect("git rev-parse"); + String::from_utf8_lossy(&out.stdout).trim().to_string() + } + + #[tokio::test] + async fn task_done_records_frozen_commit() { + let h = setup().await; + let task = add_task(&h, "T").await; + let cfg = config_reviewers(1, ReviewPassRule::Unanimous); + complete_task(&h, &cfg, "feature.txt", task).await; + + // Done ⟹ fan_in_commit set, equal to the accepted worktree tip (serial + // mode → the issue branch tip the checkpoint landed on). + let model = task_model(&h, task).await; + assert_eq!(model.status, ArtifactStatus::Done); + let frozen = model + .fan_in_commit + .expect("a Done task carries a frozen integration commit"); + assert_eq!( + frozen, + git_head(&h.worktree_path), + "frozen commit == accepted worktree tip" + ); + } + + // ---- Parallel result-stage fan-in (Phase 1) ---- + + /// The running iteration of `stage` targeting `task`. Scoped by target so a + /// parallel issue's concurrent sibling tasks (each with its own in-flight + /// implement/review) don't collide — `running_implement_id`/`running_review` + /// assume a single in-flight write, which only holds in serial mode. + async fn running_iter_for(h: &Harness, stage: Stage, task: i32) -> i32 { + loop_iteration::Entity::find() + .filter(loop_iteration::Column::Stage.eq(stage)) + .filter(loop_iteration::Column::TargetArtifactId.eq(task)) + .filter(loop_iteration::Column::Status.eq(IterationStatus::Running)) + .one(&h.db.conn) + .await + .unwrap() + .expect("a running iteration for the task") + .id + } + + /// Drive one parallel task through its full implement→review→pass lifecycle in + /// its OWN worktree, leaving it Done with a frozen commit. Robust to Phase-2 + /// concurrency: a prior tick's fan-out may already have dispatched this task's + /// implement (and a sibling's), so this scopes every lookup to `task` and + /// asserts the task's own state rather than the whole-issue drive outcome. + async fn complete_parallel_task( + h: &Harness, + cfg: &IssueConfig, + marker: &str, + body: &str, + task: i32, + ) { + // Ensure the task's implement is running (dispatched now, or already in + // flight from an earlier fan-out tick — driving is idempotent). + drive_with(h, cfg).await; + let impl_id = running_iter_for(h, Stage::Implement, task).await; + let task_wt = worktree::ensure_task_worktree(&h.db.conn, h.data.path(), h.issue_id, task) + .await + .unwrap(); + std::fs::write(task_wt.worktree_path.join(marker), body).unwrap(); + settle_iteration(&h.db, &EventEmitter::Noop, impl_id) + .await + .unwrap(); + // Checkpoint + validate → in_progress. + drive_with(h, cfg).await; + assert_eq!(task_node(h, task).await.status, ArtifactStatus::InProgress); + // Dispatch this task's review. + drive_with(h, cfg).await; + let review = running_iter_for(h, Stage::Review, task).await; + submit_verdict(h, review, "pass", "ok").await; + settle_iteration(&h.db, &EventEmitter::Noop, review) + .await + .unwrap(); + // Review pass → done + freeze. + drive_with(h, cfg).await; + assert_eq!(task_node(h, task).await.status, ArtifactStatus::Done); + } + + async fn integrate_path(h: &Harness) -> std::path::PathBuf { + let seq = load_issue(h).await.seq_no; + h.data + .path() + .join("loop-worktrees") + .join(h.space_id.to_string()) + .join(format!("issue-{seq}-integrate")) + } + + async fn set_fan_in_manifest(h: &Harness, json: &str) { + loop_issue::Entity::update_many() + .col_expr( + loop_issue::Column::FanInManifest, + Expr::value(json.to_string()), + ) + .filter(loop_issue::Column::Id.eq(h.issue_id)) + .exec(&h.db.conn) + .await + .unwrap(); + } + + #[tokio::test] + async fn parallel_two_independent_tasks_clean_fan_in_to_result() { + let h = setup().await; + set_execution_mode(&h, "parallel").await; + let cfg = config_reviewers(1, ReviewPassRule::Unanimous); + let t1 = add_task(&h, "T1").await; + let t2 = add_task(&h, "T2").await; + + complete_parallel_task(&h, &cfg, "a.txt", "A\n", t1).await; + complete_parallel_task(&h, &cfg, "b.txt", "B\n", t2).await; + assert!(task_model(&h, t1).await.fan_in_commit.is_some()); + assert!(task_model(&h, t2).await.fan_in_commit.is_some()); + + // One drive lands the whole fan-in: integrate both task branches, CAS onto + // the issue branch, synthesize the result. + assert_eq!( + drive_finalize(&h, &cfg).await, + StepOutcome::Advanced, + "fan-in lands + produces result" + ); + + let dag = artifact::list_dag(&h.db.conn, h.issue_id).await.unwrap(); + assert!(dag.artifacts.iter().any(|a| a.kind == ArtifactKind::Result)); + assert!( + load_issue(&h).await.fan_in_manifest.is_none(), + "session lock cleared after landing" + ); + assert!( + h.worktree_path.join("a.txt").exists() && h.worktree_path.join("b.txt").exists(), + "both tasks landed on the issue branch (worktree synced to the new tip)" + ); + } + + #[tokio::test] + async fn parallel_conflict_dispatches_resolution_then_lands() { + let h = setup().await; + set_execution_mode(&h, "parallel").await; + let cfg = config_reviewers(1, ReviewPassRule::Unanimous); + let t1 = add_task(&h, "T1").await; + let t2 = add_task(&h, "T2").await; + // Both tasks add the SAME file with different content → fan-in conflict. + complete_parallel_task(&h, &cfg, "shared.txt", "A\n", t1).await; + complete_parallel_task(&h, &cfg, "shared.txt", "B\n", t2).await; + + // Fan-in conflicts on the second task → dispatches a result-stage resolver. + assert_eq!( + drive_finalize(&h, &cfg).await, + StepOutcome::Dispatched, + "conflict dispatches a resolver" + ); + let resolver = running_finalize(&h).await; + let integ = integrate_path(&h).await; + assert!( + worktree::integrate_in_progress(&integ).await, + "the in-progress merge is left for the resolver" + ); + + // Simulate the resolver: resolve the conflict + complete the merge. + std::fs::write(integ.join("shared.txt"), "A+B\n").unwrap(); + git(&integ, &["add", "-A"]); + git(&integ, &["commit", "--no-edit"]); + settle_iteration(&h.db, &EventEmitter::Noop, resolver.id) + .await + .unwrap(); + + // Re-drive: resume (resolved task now an ancestor) → land + result. + assert_eq!( + drive_finalize(&h, &cfg).await, + StepOutcome::Advanced, + "resumes the fan-in and lands" + ); + let dag = artifact::list_dag(&h.db.conn, h.issue_id).await.unwrap(); + assert!(dag.artifacts.iter().any(|a| a.kind == ArtifactKind::Result)); + assert!(load_issue(&h).await.fan_in_manifest.is_none()); + assert!( + h.worktree_path.join("shared.txt").exists(), + "the resolved merge landed on the issue branch" + ); + } + + #[tokio::test] + async fn fan_in_cas_fail_restarts() { + let h = setup().await; + set_execution_mode(&h, "parallel").await; + let cfg = config_reviewers(1, ReviewPassRule::Unanimous); + let t1 = add_task(&h, "T1").await; + complete_parallel_task(&h, &cfg, "a.txt", "A\n", t1).await; + + // Advance the issue branch, then inject a manifest whose issue_base_oid is + // the STALE (pre-advance) tip → the CAS landing must miss and restart, + // never land. + let stale_base = git_head(&h.worktree_path); + std::fs::write(h.worktree_path.join("drift.txt"), "drift\n").unwrap(); + git(&h.worktree_path, &["add", "-A"]); + git(&h.worktree_path, &["commit", "-m", "issue branch drift"]); + let frozen = task_model(&h, t1).await.fan_in_commit.unwrap(); + let manifest = format!( + r#"{{"v":1,"issue_base_oid":"{stale_base}","ordered":[{{"task_id":{t1},"sha":"{frozen}"}}]}}"# + ); + set_fan_in_manifest(&h, &manifest).await; + + assert_eq!( + drive_finalize(&h, &cfg).await, + StepOutcome::Advanced, + "stale-base CAS miss → restart" + ); + assert!( + load_issue(&h).await.fan_in_manifest.is_none(), + "stale session cleared for a fresh retry" + ); + let dag = artifact::list_dag(&h.db.conn, h.issue_id).await.unwrap(); + assert!( + !dag.artifacts.iter().any(|a| a.kind == ArtifactKind::Result), + "nothing landed → no result row stranded" + ); + } + + #[tokio::test] + async fn parallel_resolver_left_unresolved_blocks() { + let h = setup().await; + set_execution_mode(&h, "parallel").await; + let cfg = config_reviewers(1, ReviewPassRule::Unanimous); + let t1 = add_task(&h, "T1").await; + let t2 = add_task(&h, "T2").await; + complete_parallel_task(&h, &cfg, "shared.txt", "A\n", t1).await; + complete_parallel_task(&h, &cfg, "shared.txt", "B\n", t2).await; + + // Conflict → resolver dispatched (records fan_in_resolver_tip). + assert_eq!(drive_finalize(&h, &cfg).await, StepOutcome::Dispatched); + let resolver = running_finalize(&h).await; + let integ = integrate_path(&h).await; + assert!(worktree::integrate_in_progress(&integ).await); + + // The resolver ends WITHOUT completing the merge — MERGE_HEAD still set at + // the recorded tip. + settle_iteration(&h.db, &EventEmitter::Noop, resolver.id) + .await + .unwrap(); + + // Re-drive: an unresolved MERGE_HEAD at the recorded resolver tip blocks the + // issue — NOT a re-dispatch loop, NOT a phantom finish. (Distinguishes this + // from a crash-before-dispatch, which would re-dispatch instead.) + assert_eq!(drive_finalize(&h, &cfg).await, StepOutcome::Advanced); + assert_eq!(load_issue(&h).await.status, IssueStatus::Blocked); + assert!( + worktree::integrate_in_progress(&integ).await, + "the in-progress merge is preserved for human diagnosis" + ); + } + + #[tokio::test] + async fn parallel_already_landed_recovers_without_revalidation() { + let h = setup().await; + set_execution_mode(&h, "parallel").await; + // A validation command that FAILS — proves the recovery path does NOT + // re-validate (else it would block instead of finishing). + let mut cfg = config_reviewers(1, ReviewPassRule::Unanimous); + let t1 = add_task(&h, "T1").await; + complete_parallel_task(&h, &cfg, "a.txt", "A\n", t1).await; + + let base_old = git_head(&h.worktree_path); + let f1 = task_model(&h, t1).await.fan_in_commit.unwrap(); + let seq = load_issue(&h).await.seq_no; + let issue_branch = format!("loop/{}/issue-{}", h.space_id, seq); + + // Manually land the frozen commit onto the issue branch — simulating a + // fan-in that landed but crashed before synthesizing the result + clearing + // the session lock. + let integ = worktree::ensure_integrate_worktree(&h.db.conn, h.data.path(), h.issue_id, &base_old) + .await + .unwrap(); + let landed = match worktree::fan_in_tasks(&integ.worktree_path, &[(t1, f1.clone())], &[], None) + .await + .unwrap() + { + worktree::FanInOutcome::Integrated { tip } => tip, + o => panic!("expected Integrated, got {o:?}"), + }; + assert!( + worktree::cas_advance_branch(h._repo.path(), &issue_branch, &landed, &base_old) + .await + .unwrap(), + "manual land applied" + ); + + // Arm the session lock as if mid-flight, and make any re-validation FAIL. + cfg.validation_commands = vec!["git rev-parse --verify refs/heads/no-such-ref".to_string()]; + let manifest = format!( + r#"{{"v":1,"issue_base_oid":"{base_old}","ordered":[{{"task_id":{t1},"sha":"{f1}"}}]}}"# + ); + set_fan_in_manifest(&h, &manifest).await; + + // Drive: already-landed detection finishes idempotently WITHOUT re-running + // the (now-failing) validation → result synthesized, issue not blocked. + assert_eq!(drive_finalize(&h, &cfg).await, StepOutcome::Advanced); + let dag = artifact::list_dag(&h.db.conn, h.issue_id).await.unwrap(); + assert!( + dag.artifacts.iter().any(|a| a.kind == ArtifactKind::Result), + "result synthesized on already-landed recovery" + ); + assert_eq!( + load_issue(&h).await.status, + IssueStatus::Running, + "not blocked by stale re-validation" + ); + assert!( + load_issue(&h).await.fan_in_manifest.is_none(), + "session lock cleared" + ); + assert!( + h.worktree_path.join("a.txt").exists(), + "issue worktree synced to the landed tip" + ); + } + + /// All tasks done → finalize dispatches; the agent submits a result; the DAG + /// gains a `result` artifact with a `results_from` edge to each task. + #[tokio::test] + async fn finalize_produces_result_and_results_from_edges() { + let h = setup().await; + let task = add_task(&h, "Task 1").await; + let cfg = config_reviewers(1, ReviewPassRule::Unanimous); + complete_task(&h, &cfg, "feature.txt", task).await; + + // Finalize dispatches (issue-level, target = None). + assert_eq!( + drive_finalize(&h, &cfg).await, + StepOutcome::Dispatched, + "finalize dispatched" + ); + let fin = running_finalize(&h).await; + + // Simulate the finalize agent submitting the result summary via ingest. + crate::loop_engine::ingest::ingest( + &h.db.conn, + &fin.capability_token, + "loop_submit_artifacts", + &serde_json::json!({ "artifacts": [{ "title": "Result", "content": "shipped" }] }), + ) + .await + .unwrap(); + settle_iteration(&h.db, &EventEmitter::Noop, fin.id) + .await + .unwrap(); + + // Next tick: result exists → the INTEGRATION gate dispatches a reviewer on + // the result (the whole-issue closure must be verified before merge). + assert_eq!( + drive_finalize(&h, &cfg).await, + StepOutcome::Dispatched, + "result exists → integration reviewer dispatched" + ); + + let dag = artifact::list_dag(&h.db.conn, h.issue_id).await.unwrap(); + let results: Vec<_> = dag + .artifacts + .iter() + .filter(|a| a.kind == ArtifactKind::Result) + .collect(); + assert_eq!(results.len(), 1, "one result artifact"); + let result_id = results[0].id; + let edges = dag + .links + .iter() + .filter(|l| { + l.kind == LinkKind::ResultsFrom + && l.from_artifact_id == result_id + && l.to_artifact_id == task + }) + .count(); + assert_eq!(edges, 1, "results_from edge from result to the task"); + } + + /// Drive a single-task issue to a produced `result` sitting at the integration + /// gate (the finalize agent has submitted it). Returns the result artifact id. + async fn finalize_to_result(h: &Harness, cfg: &IssueConfig, task: i32) -> i32 { + complete_task(h, cfg, "feature.txt", task).await; + assert_eq!( + drive_finalize(h, cfg).await, + StepOutcome::Dispatched, + "finalize dispatched" + ); + let fin = running_finalize(h).await; + crate::loop_engine::ingest::ingest( + &h.db.conn, + &fin.capability_token, + "loop_submit_artifacts", + &serde_json::json!({ "artifacts": [{ "title": "Result", "content": "shipped" }] }), + ) + .await + .unwrap(); + settle_iteration(&h.db, &EventEmitter::Noop, fin.id).await.unwrap(); + let dag = artifact::list_dag(&h.db.conn, h.issue_id).await.unwrap(); + live_result(&dag).expect("result produced").id + } + + /// Integration gate PASS opens the merge gate: the result exists, an integration + /// reviewer is dispatched on it, and once it passes the whole-issue closure the + /// finalize decision is recorded and `integration_passed` holds (merge allowed). + #[tokio::test] + async fn integration_pass_opens_merge_gate() { + let h = setup().await; + let task = add_task(&h, "Task 1").await; + let cfg = config_reviewers(1, ReviewPassRule::Unanimous); + let result_id = finalize_to_result(&h, &cfg, task).await; + + // Result exists → integration reviewer dispatched (target = result), and the + // gate is not yet passed. + assert_eq!(drive_finalize(&h, &cfg).await, StepOutcome::Dispatched); + let dag = artifact::list_dag(&h.db.conn, h.issue_id).await.unwrap(); + assert!(!integration_passed(&h.db.conn, &dag).await.unwrap()); + let review = running_review(&h).await; // the only running Review = integration + submit_verdict(&h, review, "pass", "closure holds").await; + settle_iteration(&h.db, &EventEmitter::Noop, review).await.unwrap(); + + // Next tick: integration Pass → merge gate open (no further dispatch). + assert_eq!(drive_finalize(&h, &cfg).await, StepOutcome::Idle); + assert_eq!( + loop_service::gate_decision::outcome_for(&h.db.conn, result_id, "finalize", 0) + .await + .unwrap(), + Some(GateOutcome::Pass) + ); + let dag = artifact::list_dag(&h.db.conn, h.issue_id).await.unwrap(); + assert!(integration_passed(&h.db.conn, &dag).await.unwrap(), "merge gate open"); + } + + /// Integration gate FAIL loops back: the live result and tasks are superseded + /// (freeing `uniq_result_per_issue`), the fail decision is recorded, and the + /// next plan can produce a fresh result. The integration reviewer failing a + /// closure criterion is exactly the cross-task-violation path. + #[tokio::test] + async fn integration_fail_supersedes_result_and_tasks() { + let h = setup().await; + let task = add_task(&h, "Task 1").await; + let cfg = IssueConfig { max_attempts: 3, ..config_reviewers(1, ReviewPassRule::Unanimous) }; + let result_id = finalize_to_result(&h, &cfg, task).await; + + assert_eq!(drive_finalize(&h, &cfg).await, StepOutcome::Dispatched); + let review = running_review(&h).await; + submit_verdict(&h, review, "fail", "requirement unmet by the combined result").await; + settle_iteration(&h.db, &EventEmitter::Noop, review).await.unwrap(); + + // Integration Fail → bounded loop-back (advance + re-plan), result+task superseded. + assert_eq!(drive_finalize(&h, &cfg).await, StepOutcome::Advanced); + assert_eq!( + loop_service::gate_decision::outcome_for(&h.db.conn, result_id, "finalize", 0) + .await + .unwrap(), + Some(GateOutcome::Fail) + ); + let dag = artifact::list_dag(&h.db.conn, h.issue_id).await.unwrap(); + assert_eq!( + dag.artifacts.iter().find(|a| a.id == result_id).unwrap().status, + ArtifactStatus::Superseded, + "live result superseded" + ); + assert_eq!(task_node(&h, task).await.status, ArtifactStatus::Superseded, "task superseded"); + assert!(live_result(&dag).is_none(), "uniq_result_per_issue freed for a fresh result"); + assert_eq!(load_issue(&h).await.status, IssueStatus::Running, "still running (bounded retry)"); + } + + /// Integration loop-back is bounded: with `max_attempts = 1` the first integration + /// failure exhausts the bound → issue blocked + `integration_gap` card. + #[tokio::test] + async fn integration_gap_exhausts_to_block() { + let h = setup().await; + let task = add_task(&h, "Task 1").await; + let cfg = IssueConfig { max_attempts: 1, ..config_reviewers(1, ReviewPassRule::Unanimous) }; + let _ = finalize_to_result(&h, &cfg, task).await; + + assert_eq!(drive_finalize(&h, &cfg).await, StepOutcome::Dispatched); + let review = running_review(&h).await; + submit_verdict(&h, review, "fail", "unmet").await; + settle_iteration(&h.db, &EventEmitter::Noop, review).await.unwrap(); + + // count_fail(finalize) reaches max_attempts(1) → block, not loop-back. + assert_eq!(drive_finalize(&h, &cfg).await, StepOutcome::Advanced); + assert_eq!(load_issue(&h).await.status, IssueStatus::Blocked); + let inbox = loop_service::inbox::list_inbox(&h.db.conn, h.space_id, None).await.unwrap(); + assert!( + inbox.iter().any(|i| i.kind == InboxKind::Blocked + && i.subject_key == format!("integration_gap:{}", h.issue_id)), + "integration_gap card filed" + ); + } + + /// Empty integration closure (D11): a result with NOTHING to verify — no + /// requirements, and the (direct-route) tasks carry no acceptance criteria — + /// blocks the issue with an `unverifiable` card rather than a vacuous pass. + #[tokio::test] + async fn integration_empty_closure_blocks_unverifiable() { + let h = setup().await; + let dag0 = artifact::list_dag(&h.db.conn, h.issue_id).await.unwrap(); + let root = dag0.artifacts.iter().find(|a| a.kind == ArtifactKind::Issue).unwrap().id; + // A Done task with NO acceptance criteria + a Done result. No requirements. + let task = artifact::create_artifact(&h.db.conn, h.space_id, h.issue_id, ArtifactKind::Task, "T", ArtifactStatus::Done, ActorKind::Agent, None).await.unwrap(); + link::create_link(&h.db.conn, h.space_id, task.id, root, LinkKind::DerivesFrom, None).await.unwrap(); + artifact::create_artifact(&h.db.conn, h.space_id, h.issue_id, ArtifactKind::Result, "R", ArtifactStatus::Done, ActorKind::Agent, None).await.unwrap(); + + let cfg = config_reviewers(1, ReviewPassRule::Unanimous); + // Result exists, all live tasks Done, quiescent → integration gate → empty + // closure → block (advance + stop), never a vacuous pass. + assert_eq!(drive_finalize(&h, &cfg).await, StepOutcome::Advanced); + assert_eq!(load_issue(&h).await.status, IssueStatus::Blocked); + let inbox = loop_service::inbox::list_inbox(&h.db.conn, h.space_id, None).await.unwrap(); + assert!( + inbox.iter().any(|i| i.kind == InboxKind::Blocked + && i.subject_key == format!("unverifiable:{}", h.issue_id)), + "unverifiable card filed" + ); + } + + /// Finalize must wait for the issue to be **fully quiescent** — every task + /// `Done` is not enough if any iteration is still in flight (e.g. a losing + /// review slot mid-settle). The old per-issue write gate proxied this; the + /// replacement is an explicit "no in-flight iteration of any stage" check. + #[tokio::test] + async fn finalize_waits_for_all_inflight_including_losing_review_slots() { + use crate::loop_engine::transitions::{try_claim_iteration, IterationClaim}; + let h = setup().await; + let task = add_task(&h, "T").await; + let cfg = config_reviewers(1, ReviewPassRule::Unanimous); + complete_task(&h, &cfg, "feature.txt", task).await; + + // Inject a still-running review iteration (a losing slot that has not yet + // settled): every task is Done, but the issue is NOT quiescent. + let lingering = try_claim_iteration( + &h.db.conn, + IterationClaim { + space_id: h.space_id, + issue_id: h.issue_id, + stage: Stage::Review, + target_artifact_id: Some(task), + slot_no: Some(7), + capability_token: "lingering".into(), + attempt: 99, + }, + ) + .await + .unwrap() + .expect("claim a spare review slot"); + cas_iteration_status( + &h.db.conn, + lingering.id, + IterationStatus::Queued, + IterationStatus::Running, + ) + .await + .unwrap(); + + // Finalize waits while the review slot is in flight. + assert_eq!( + drive_finalize(&h, &cfg).await, + StepOutcome::Idle, + "finalize waits for the in-flight review slot" + ); + let fins = loop_iteration::Entity::find() + .filter(loop_iteration::Column::Stage.eq(Stage::Finalize)) + .all(&h.db.conn) + .await + .unwrap(); + assert!(fins.is_empty(), "no finalize dispatched while not quiescent"); + + // Once the slot settles, finalize proceeds. + cas_iteration_status( + &h.db.conn, + lingering.id, + IterationStatus::Running, + IterationStatus::Cancelled, + ) + .await + .unwrap(); + assert_eq!( + drive_finalize(&h, &cfg).await, + StepOutcome::Dispatched, + "finalize proceeds once the issue is quiescent" + ); + } + + /// A dirty worktree at finalize time (stray uncommitted state) blocks the + /// issue + files a card, and dispatches no finalize iteration. + #[tokio::test] + async fn finalize_dirty_tree_blocks() { + let h = setup().await; + let task = add_task(&h, "Task 1").await; + let cfg = config_reviewers(1, ReviewPassRule::Unanimous); + complete_task(&h, &cfg, "feature.txt", task).await; + + // Stray uncommitted file in the worktree. + std::fs::write(h.worktree_path.join("stray.txt"), "uncommitted\n").unwrap(); + + assert_eq!( + drive_finalize(&h, &cfg).await, + StepOutcome::Advanced, + "dirty tree blocks (advance), not a dispatch" + ); + assert_eq!( + load_issue(&h).await.status, + IssueStatus::Blocked, + "issue blocked on a dirty tree" + ); + let inbox = loop_service::inbox::list_inbox(&h.db.conn, h.space_id, None) + .await + .unwrap(); + assert!( + inbox.iter().any(|i| i.kind == InboxKind::Blocked + && i.subject_key == format!("finalize_dirty:{}", h.issue_id)), + "finalize_dirty inbox card filed" + ); + let fins = loop_iteration::Entity::find() + .filter(loop_iteration::Column::Stage.eq(Stage::Finalize)) + .all(&h.db.conn) + .await + .unwrap(); + assert!(fins.is_empty(), "no finalize iteration on a dirty tree"); + } + + #[tokio::test] + async fn set_task_status_cas_rejects_wrong_from() { + let h = setup().await; + let task = add_task(&h, "T").await; // pending + + // Wrong `from` (task is Pending, not InProgress) → no-op, returns false. + let applied = + set_task_status_cas(&h.db, task, ArtifactStatus::InProgress, ArtifactStatus::Done) + .await + .unwrap(); + assert!(!applied, "CAS with the wrong from does not apply"); + let row = loop_artifact::Entity::find_by_id(task) + .one(&h.db.conn) + .await + .unwrap() + .unwrap(); + assert_eq!(row.status, ArtifactStatus::Pending, "status unchanged"); + + // Correct `from` applies. + let applied = + set_task_status_cas(&h.db, task, ArtifactStatus::Pending, ArtifactStatus::InProgress) + .await + .unwrap(); + assert!(applied, "CAS with the right from applies"); + } + + /// Records disconnects and maps conversation ids to connection ids, so the + /// reviewer-kill path is observable without a live connection manager. + struct RecordingSpawner { + conn_for: std::collections::HashMap, + disconnected: std::sync::Mutex>, + } + + #[async_trait] + impl LoopAgentSpawner for RecordingSpawner { + async fn spawn_loop_agent( + &self, + _db: &AppDatabase, + _data_dir: &Path, + _agent_type: AgentType, + _working_dir: String, + _emitter: EventEmitter, + _preferred_mode_id: Option, + _preferred_config_values: std::collections::BTreeMap, + _capability_token: String, + ) -> Result { + Ok("conn".to_string()) + } + async fn send_loop_prompt( + &self, + _db: &AppDatabase, + _conn_id: &str, + _text: String, + _folder_id: i32, + _conversation_id: i32, + ) -> Result<(), AcpError> { + Ok(()) + } + async fn disconnect_loop_agent(&self, conn_id: &str) { + self.disconnected.lock().unwrap().push(conn_id.to_string()); + } + async fn find_loop_connection(&self, conversation_id: i32) -> Option { + self.conn_for.get(&conversation_id).cloned() + } + } + + #[tokio::test] + async fn cancel_active_reviews_kills_live_reviewer_agent() { + use crate::loop_engine::transitions::{try_claim_iteration, IterationClaim}; + let h = setup().await; + let task = add_task(&h, "T").await; + + // A running reviewer iteration backed by conversation 7777. + let claimed = try_claim_iteration( + &h.db.conn, + IterationClaim { + space_id: h.space_id, + issue_id: h.issue_id, + stage: Stage::Review, + target_artifact_id: Some(task), + slot_no: Some(0), + capability_token: "tok-review".into(), + attempt: 0, + }, + ) + .await + .unwrap() + .expect("claimed review iteration"); + loop_iteration::Entity::update_many() + .col_expr( + loop_iteration::Column::Status, + Expr::value(IterationStatus::Running.to_value()), + ) + .col_expr(loop_iteration::Column::ConversationId, Expr::value(7777)) + .filter(loop_iteration::Column::Id.eq(claimed.id)) + .exec(&h.db.conn) + .await + .unwrap(); + let running = loop_iteration::Entity::find_by_id(claimed.id) + .one(&h.db.conn) + .await + .unwrap() + .unwrap(); + + let spawner = RecordingSpawner { + conn_for: std::collections::HashMap::from([(7777, "conn-7777".to_string())]), + disconnected: std::sync::Mutex::new(Vec::new()), + }; + cancel_active_reviews(&h.db, &spawner, &[running]) + .await + .unwrap(); + + // The reviewer iteration is voided AND its live agent process reaped. + let row = loop_iteration::Entity::find_by_id(claimed.id) + .one(&h.db.conn) + .await + .unwrap() + .unwrap(); + assert_eq!(row.status, IterationStatus::Cancelled); + assert_eq!( + *spawner.disconnected.lock().unwrap(), + vec!["conn-7777".to_string()], + "the cancelled reviewer's live agent is disconnected" + ); + } +} diff --git a/src-tauri/src/loop_engine/health.rs b/src-tauri/src/loop_engine/health.rs new file mode 100644 index 0000000000..cdd2416f6d --- /dev/null +++ b/src-tauri/src/loop_engine/health.rs @@ -0,0 +1,23 @@ +//! Engine health snapshot for the workbench badge + ops (§2.10b). + +use crate::loop_engine::metrics::MetricsSnapshot; + +/// Live engine health. The counts are authoritative *now*: issues/iterations +/// from the DB, drivers from the in-process registry. `metrics` is this +/// process's since-boot tally. An operator reads this to spot trouble at a +/// glance — drivers lagging the running-issue count, token settlements piling up +/// `pending`, or repeated lag-sweep recoveries. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct LoopEngineHealth { + /// Issues currently in `running` (DB). + pub running_issues: u64, + /// Iterations currently `queued` or `running` (DB). + pub in_flight_iterations: u64, + /// Settled iterations whose token total is still `pending` a backfill (DB). + pub pending_token_iterations: u64, + /// Live per-issue driver tasks in the registry (in-process). + pub active_drivers: u64, + /// Process-since-boot counters. + pub metrics: MetricsSnapshot, +} diff --git a/src-tauri/src/loop_engine/ingest.rs b/src-tauri/src/loop_engine/ingest.rs new file mode 100644 index 0000000000..cc9f2a79b7 --- /dev/null +++ b/src-tauri/src/loop_engine/ingest.rs @@ -0,0 +1,2543 @@ +//! Host-side trust boundary for codeg-mcp loop submissions. +//! +//! A loop agent is handed ONLY an opaque `capability_token`; it never sends ids +//! the host would trust. Every submission is reverse-looked-up to its iteration +//! by that token (rejecting unknown / non-running tokens — so stale, cancelled, +//! or already-settled iterations can't write), checked against a strict +//! stage→tool allow-table, validated to target the iteration's own issue, and +//! written idempotently (a replay from a retry or crash recovery produces the +//! same rows, never duplicates). +//! +//! This module is the authority for what an agent may persist; the companion / +//! transport / listener layers only ferry the `(token, tool, payload)` triple +//! here. + +use std::collections::{HashMap, HashSet}; + +use async_trait::async_trait; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, IntoActiveModel, QueryFilter, + QueryOrder, Set, TransactionTrait, +}; +use serde_json::{json, Value}; + +use crate::acp::delegation::listener::LoopIngestAccess; +use crate::db::entities::loop_artifact::{self, ArtifactKind, ArtifactStatus, ReviewVerdict}; +use crate::db::entities::loop_artifact_revision::{self, ActorKind}; +use crate::db::entities::loop_criterion::CriterionKind; +use crate::db::entities::loop_criterion_check::CheckVerdict; +use crate::db::entities::loop_inbox_item::InboxKind; +use crate::db::entities::loop_issue::{self, IssuePriority, IssueRoute}; +use crate::db::entities::loop_iteration::{self, IterationStatus, Stage}; +use crate::db::entities::loop_link::LinkKind; +use crate::db::entities::loop_memory::{MemoryKind, TrustTier}; +use crate::db::service::loop_service; +use crate::loop_engine::transitions; +use crate::loop_engine::LoopError; + +/// Hard ceiling on any single persisted text field (defense against a runaway +/// agent flooding the DB). Counted in characters. +const MAX_CONTENT: usize = 200_000; + +/// Per-iteration write safety thresholds (§2.9). Generous — these defend the DB +/// against a runaway agent, not against legitimate large runs ("no artificial +/// limits"): a sane iteration produces a handful of artifacts well under these. +/// `MAX_BYTES_PER_ITERATION` (4 MB) exceeds `MAX_CONTENT` (200 KB) so per-field +/// truncation and the per-iteration budget stay independent guards. +const MAX_ARTIFACTS_PER_ITERATION: usize = 200; +const MAX_BYTES_PER_ITERATION: usize = 4_000_000; + +fn invalid(msg: impl Into) -> LoopError { + LoopError::InvalidInput(msg.into()) +} + +fn truncate(s: &str) -> String { + if s.chars().count() <= MAX_CONTENT { + s.to_string() + } else { + s.chars().take(MAX_CONTENT).collect::() + "\n…[truncated]" + } +} + +/// Reverse-look-up the iteration backing a capability token. Rejects unknown +/// tokens and any iteration not currently `running` (the agent's window to +/// write is exactly its live turn). +async fn running_iteration( + conn: &DatabaseConnection, + token: &str, +) -> Result { + let it = loop_iteration::Entity::find() + .filter(loop_iteration::Column::CapabilityToken.eq(token)) + .one(conn) + .await? + .ok_or_else(|| invalid("unknown capability token"))?; + if it.status != IterationStatus::Running { + return Err(invalid("iteration is not accepting submissions")); + } + Ok(it) +} + +/// Which artifact kind a stage is allowed to produce. The read stages produce +/// their pipeline node; finalize produces the issue's `result`. Other stages +/// have no `loop_submit_artifacts` capability. +fn artifact_kind_for_stage(stage: Stage) -> Result { + match stage { + Stage::Refine => Ok(ArtifactKind::Requirement), + Stage::Design => Ok(ArtifactKind::Design), + Stage::Plan => Ok(ArtifactKind::Task), + Stage::Finalize => Ok(ArtifactKind::Result), + other => Err(invalid(format!("stage {other:?} cannot submit artifacts"))), + } +} + +/// Initial status by kind: tasks land `pending` (awaiting implement); a design +/// lands `awaiting_approval` (the human design gate — the driver files the inbox +/// card and planning waits until a person approves); requirement / result are +/// accepted outputs (`done`). +fn default_status_for_kind(kind: ArtifactKind) -> ArtifactStatus { + match kind { + ArtifactKind::Task => ArtifactStatus::Pending, + ArtifactKind::Design => ArtifactStatus::AwaitingApproval, + // The reflection is an accepted output the moment it is written (explicit + // so a future kind cannot silently inherit `Done` through the wildcard). + ArtifactKind::Reflection => ArtifactStatus::Done, + _ => ArtifactStatus::Done, + } +} + +/// Parse an item's optional `depends_on` into the index of its single +/// predecessor *within this same batch* (0-based, into the `artifacts` array). +/// +/// v1 dependency model (spec §3.1, §4.1): a task may declare **at most one** +/// predecessor, referenced by its position earlier in the same submission. That +/// a reference can only point *backward* (`n < idx`) makes the batch acyclic by +/// construction and sidesteps cross-batch id resolution — so cross-issue and +/// forward/self references are structurally impossible, not just rejected. +/// `None` (absent / null / empty array) means a root task (no predecessor). +fn parse_depends_on(item: &Value, idx: usize) -> Result, LoopError> { + let Some(raw) = item.get("depends_on") else { + return Ok(None); + }; + if raw.is_null() { + return Ok(None); + } + let arr = raw + .as_array() + .ok_or_else(|| invalid("depends_on must be an array"))?; + if arr.is_empty() { + return Ok(None); + } + if arr.len() > 1 { + return Err(invalid( + "a task may declare at most one predecessor (v1 forbids multiple dependencies)", + )); + } + let n = arr[0] + .as_i64() + .ok_or_else(|| invalid("depends_on entry must be an integer batch index"))?; + if n < 0 || (n as usize) >= idx { + return Err(invalid(format!( + "depends_on index {n} out of range; must reference an earlier task in this batch (0..{idx})" + ))); + } + Ok(Some(n as usize)) +} + +/// Parse + validate one item's `criteria`, typed by the batch artifact kind +/// (spec §3.1). Requirements and tasks carry only `acceptance`; designs carry +/// `constraint`/`invariant`/`obligation` (cross-cutting properties, never +/// acceptance); other kinds carry none. Each entry is a bare string (defaulted +/// by artifact kind) or `{ "text": str, "kind"?: str }`. A disallowed or +/// unparseable kind is rejected so the caller can abort the whole batch before +/// any write — same all-or-nothing contract as `depends_on`. +fn parse_criteria( + item: &Value, + kind: ArtifactKind, +) -> Result, LoopError> { + let Some(raw) = item.get("criteria") else { + return Ok(Vec::new()); + }; + if raw.is_null() { + return Ok(Vec::new()); + } + let arr = raw + .as_array() + .ok_or_else(|| invalid("criteria must be an array"))?; + + // Default + allow-set per artifact kind. + let (default_kind, allowed): (Option, &[CriterionKind]) = match kind { + ArtifactKind::Requirement | ArtifactKind::Task => { + (Some(CriterionKind::Acceptance), &[CriterionKind::Acceptance]) + } + ArtifactKind::Design => ( + Some(CriterionKind::Constraint), + &[ + CriterionKind::Constraint, + CriterionKind::Invariant, + CriterionKind::Obligation, + ], + ), + _ => (None, &[]), + }; + + let mut out = Vec::new(); + for c in arr { + let (text, explicit_kind) = if let Some(s) = c.as_str() { + (s.trim().to_string(), None) + } else if let Some(obj) = c.as_object() { + let text = obj + .get("text") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + let k = match obj.get("kind").and_then(|v| v.as_str()) { + Some(ks) => Some( + serde_json::from_value::(json!(ks)) + .map_err(|_| invalid(format!("invalid criterion kind '{ks}'")))?, + ), + None => None, + }; + (text, k) + } else { + return Err(invalid("criterion must be a string or an object")); + }; + if text.is_empty() { + continue; + } + let ck = explicit_kind + .or(default_kind) + .ok_or_else(|| invalid(format!("{kind:?} artifacts do not accept criteria")))?; + if !allowed.contains(&ck) { + return Err(invalid(format!( + "criterion kind {ck:?} not allowed for {kind:?} artifacts" + ))); + } + out.push((ck, text)); + } + Ok(out) +} + +/// Resolve one task item's `covers` ordinals (e.g. `"R1.AC1"`) into the +/// criterion ids they name, against the issue's stable ordinal map (spec §3.3). +/// Up-front validation: an unknown or malformed ordinal is rejected so the +/// caller can abort the whole batch with no partial coverage rows. `None` / +/// null / empty array means the task covers nothing. +fn parse_covers(item: &Value, ordinals: &HashMap) -> Result, LoopError> { + let Some(raw) = item.get("covers") else { + return Ok(Vec::new()); + }; + if raw.is_null() { + return Ok(Vec::new()); + } + let arr = raw + .as_array() + .ok_or_else(|| invalid("covers must be an array"))?; + let mut out = Vec::new(); + for c in arr { + let key = c + .as_str() + .map(str::trim) + .filter(|s| !s.is_empty()) + .ok_or_else(|| invalid("covers entry must be a non-empty ordinal like \"R1.AC1\""))?; + let cid = ordinals.get(key).copied().ok_or_else(|| { + invalid(format!("covers references unknown criterion ordinal '{key}'")) + })?; + out.push(cid); + } + Ok(out) +} + +/// Entry point: validate `(token, tool, payload)` and persist. Returns a small +/// JSON outcome the companion relays back to the agent. +pub async fn ingest( + conn: &DatabaseConnection, + token: &str, + tool: &str, + payload: &Value, +) -> Result { + let it = running_iteration(conn, token).await?; + match tool { + "loop_submit_route" => submit_route(conn, &it, payload).await, + "loop_submit_artifacts" => submit_artifacts(conn, &it, payload).await, + "loop_submit_review" => submit_review(conn, &it, payload).await, + "loop_report_blocked" => report_blocked(conn, &it, payload).await, + "loop_task_complete" => task_complete(conn, &it, payload).await, + "loop_record_memory" => record_memory(conn, &it, payload).await, + "loop_read_memory" => read_memory(conn, &it, payload).await, + "loop_submit_reflection" => submit_reflection(conn, &it, payload).await, + other => Err(invalid(format!("unknown loop tool: {other}"))), + } +} + +/// Production [`LoopIngestAccess`] over the shared database — the bridge the +/// delegation listener calls for `loop_submit_*` traffic. Holds a cheap +/// `DatabaseConnection` clone (a connection-pool handle) and wraps [`ingest`], +/// flattening `LoopError` to its display string so the listener boundary stays +/// free of the loop error type. +pub struct DbLoopIngest { + pub conn: DatabaseConnection, +} + +#[async_trait] +impl LoopIngestAccess for DbLoopIngest { + async fn loop_ingest( + &self, + token: &str, + tool: &str, + payload: &Value, + ) -> Result { + ingest(&self.conn, token, tool, payload) + .await + .map_err(|e| e.to_string()) + } +} + +async fn submit_route( + conn: &DatabaseConnection, + it: &loop_iteration::Model, + payload: &Value, +) -> Result { + if it.stage != Stage::Triage { + return Err(invalid("loop_submit_route is only valid during triage")); + } + let route_str = payload + .get("route") + .and_then(|v| v.as_str()) + .ok_or_else(|| invalid("missing route"))?; + let route: IssueRoute = + serde_json::from_value(json!(route_str)).map_err(|_| invalid("invalid route"))?; + let priority = match payload.get("priority").and_then(|v| v.as_str()) { + Some(p) => Some( + serde_json::from_value::(json!(p)) + .map_err(|_| invalid("invalid priority"))?, + ), + None => None, + }; + + let issue = loop_issue::Entity::find_by_id(it.issue_id) + .one(conn) + .await? + .ok_or_else(|| LoopError::NotFound(format!("issue {}", it.issue_id)))?; + let mut active = issue.into_active_model(); + active.route = Set(route); + if let Some(p) = priority { + active.priority = Set(p); + } + active.update(conn).await?; + + Ok(json!({ "ok": true, "route": route_str })) +} + +/// Current persisted footprint of an iteration: (#artifacts it produced, total +/// chars across all their revisions). Backs the §2.9 per-iteration write budget. +async fn iteration_footprint( + conn: &DatabaseConnection, + iteration_id: i32, +) -> Result<(usize, usize), LoopError> { + let arts = loop_artifact::Entity::find() + .filter(loop_artifact::Column::ProducedByIterationId.eq(iteration_id)) + .all(conn) + .await?; + if arts.is_empty() { + return Ok((0, 0)); + } + let ids: Vec = arts.iter().map(|a| a.id).collect(); + let bytes: usize = loop_artifact_revision::Entity::find() + .filter(loop_artifact_revision::Column::ArtifactId.is_in(ids)) + .all(conn) + .await? + .iter() + .map(|r| r.content.chars().count()) + .sum(); + Ok((arts.len(), bytes)) +} + +async fn submit_artifacts( + conn: &DatabaseConnection, + it: &loop_iteration::Model, + payload: &Value, +) -> Result { + let kind = artifact_kind_for_stage(it.stage)?; + + // Idempotency: this iteration already produced its batch → return it. + let existing: Vec = loop_artifact::Entity::find() + .filter(loop_artifact::Column::ProducedByIterationId.eq(it.id)) + .filter(loop_artifact::Column::Kind.eq(kind)) + .all(conn) + .await? + .into_iter() + .map(|a| a.id) + .collect(); + if !existing.is_empty() { + return Ok(json!({ "ok": true, "idempotent": true, "ids": existing })); + } + + let items = payload + .get("artifacts") + .and_then(|v| v.as_array()) + .ok_or_else(|| invalid("missing artifacts array"))?; + if items.is_empty() { + return Err(invalid("artifacts array is empty")); + } + + // §2.9 write-budget guard: reject a batch that would push this iteration over + // the per-iteration safety threshold, and surface it as a blocked card so the + // human sees it. Generous bounds — runaway defense, not a perf cap. + let (have_count, have_bytes) = iteration_footprint(conn, it.id).await?; + let add_bytes: usize = items + .iter() + .map(|i| { + i.get("content") + .and_then(|v| v.as_str()) + .map(|s| s.chars().count()) + .unwrap_or(0) + }) + .sum(); + if have_count + items.len() > MAX_ARTIFACTS_PER_ITERATION + || have_bytes + add_bytes > MAX_BYTES_PER_ITERATION + { + loop_service::inbox::upsert_inbox( + conn, + it.space_id, + it.issue_id, + Some(it.id), + InboxKind::Blocked, + &format!("write_budget_exceeded:{}", it.id), + json!({ + "v": 1, + "reason": "write_budget_exceeded", + "have_artifacts": have_count, + "have_bytes": have_bytes, + "add_artifacts": items.len(), + "add_bytes": add_bytes, + }), + ) + .await?; + return Err(invalid("iteration write budget exceeded")); + } + + // Edge wiring depends on kind: a `result` (finalize) fans out `results_from` + // to every task of the issue; read artifacts derive from the iteration's + // single target node. + let derive_target = if kind == ArtifactKind::Result { + None + } else { + let target = it + .target_artifact_id + .ok_or_else(|| invalid("iteration has no target node"))?; + let target_row = loop_artifact::Entity::find_by_id(target) + .one(conn) + .await? + .ok_or_else(|| invalid("target node not found"))?; + if target_row.issue_id != it.issue_id { + return Err(invalid("target node belongs to another issue")); + } + Some(target) + }; + let result_targets: Vec = if kind == ArtifactKind::Result { + loop_artifact::Entity::find() + .filter(loop_artifact::Column::IssueId.eq(it.issue_id)) + .filter(loop_artifact::Column::Kind.eq(ArtifactKind::Task)) + .all(conn) + .await? + .into_iter() + .map(|t| t.id) + .collect() + } else { + Vec::new() + }; + + // A design fans into EVERY done requirement of the issue (spec §3.2), not + // just the iteration's single anchor node — so requirement criteria reach + // implement/review through a real edge. Each edge is bound to the + // requirement's latest revision (a content snapshot, so a later requirement + // edit is detectable as stale lineage). Ordered by (sort, id) to match the + // R{i} ordinals the plan stage references. + let design_targets: Vec<(i32, Option)> = if kind == ArtifactKind::Design { + let reqs = loop_artifact::Entity::find() + .filter(loop_artifact::Column::IssueId.eq(it.issue_id)) + .filter(loop_artifact::Column::Kind.eq(ArtifactKind::Requirement)) + .filter(loop_artifact::Column::Status.eq(ArtifactStatus::Done)) + .order_by_asc(loop_artifact::Column::Sort) + .order_by_asc(loop_artifact::Column::Id) + .all(conn) + .await?; + let mut out = Vec::with_capacity(reqs.len()); + for r in reqs { + let rev = loop_service::artifact::latest_revision_id(conn, r.id).await?; + out.push((r.id, rev)); + } + out + } else { + Vec::new() + }; + + // Plan stage: the stable `R{i}.AC{j}` ordinals, built from the single shared + // ordinal source so a task's `covers` references acceptance criteria by the + // same ordinals the driver's coverage gate and the planner briefing use. The + // agent never sees a DB id. `acceptance_ordinals` keeps canonical order (so a + // missing-coverage list reads R1.AC1, R1.AC2, …); `covers_ordinals` is the + // ordinal→id lookup map. + let acceptance_ordinals: Vec<(String, i32)> = if kind == ArtifactKind::Task { + let ordered = + loop_service::coverage::acceptance_ordinals_for_issue(conn, it.issue_id).await?; + let mut v = Vec::new(); + for (ri, (_req, crits)) in ordered.iter().enumerate() { + for (ci, cid) in crits.iter().enumerate() { + v.push((format!("R{}.AC{}", ri + 1, ci + 1), *cid)); + } + } + v + } else { + Vec::new() + }; + let covers_ordinals: HashMap = acceptance_ordinals.iter().cloned().collect(); + + // Validate every item's `depends_on` up-front, before any artifact is + // written — a bad reference must abort the whole batch with no partial rows + // (a partial write would then look "done" to the idempotency replay guard). + // Only Task artifacts carry dependencies; depends_on on other kinds is + // ignored. + let dep_indices: Vec> = if kind == ArtifactKind::Task { + items + .iter() + .enumerate() + .map(|(idx, item)| parse_depends_on(item, idx)) + .collect::>()? + } else { + vec![None; items.len()] + }; + + // Validate every item's criteria up-front (typed, per-artifact-kind + // allow-set) — same all-or-nothing contract as depends_on: a disallowed kind + // aborts the batch before any row is written. + let criteria_per_item: Vec> = items + .iter() + .map(|item| parse_criteria(item, kind)) + .collect::>()?; + + // Validate every task's `covers` ordinals up-front — an unknown ordinal + // aborts the batch before any coverage row is written. + let covers_per_item: Vec> = if kind == ArtifactKind::Task { + items + .iter() + .map(|item| parse_covers(item, &covers_ordinals)) + .collect::>()? + } else { + vec![Vec::new(); items.len()] + }; + + // Plan completeness (spec §3.3): every acceptance criterion must be covered by + // at least one task. Enforced up-front (no write) so an incomplete plan is + // REJECTED in the planner's own turn — it resubmits a complete one immediately, + // converging in-turn — instead of being accepted and then superseded next tick + // by the driver's coverage loop-back (which churns tasks and clutters the DAG). + // Only bites when the issue actually has acceptance criteria (the `direct` + // route has none, so `acceptance_ordinals` is empty and this is a no-op). + if kind == ArtifactKind::Task && !acceptance_ordinals.is_empty() { + let covered: std::collections::HashSet = + covers_per_item.iter().flatten().copied().collect(); + let missing: Vec<&str> = acceptance_ordinals + .iter() + .filter(|(_, cid)| !covered.contains(cid)) + .map(|(ord, _)| ord.as_str()) + .collect(); + if !missing.is_empty() { + return Err(invalid(format!( + "plan leaves these acceptance criteria uncovered: {}. Every \ + acceptance ordinal must be covered by at least one task's `covers`; \ + resubmit the complete task list covering all of them.", + missing.join(", ") + ))); + } + } + + let status = default_status_for_kind(kind); + // One transaction for the whole batch: artifacts + revisions + criteria + + // edges + coverage commit all-or-nothing. This is what makes the + // "any produced artifact exists → idempotent replay" guard above CORRECT — a + // crash mid-batch rolls everything back, so on replay either the full batch + // is present (skip) or none of it is (rewrite). Without this, a crash after + // an artifact but before its links/coverage would leave them permanently + // skipped. + let txn = conn.begin().await?; + let mut ids = Vec::new(); + for (idx, item) in items.iter().enumerate() { + let title = item + .get("title") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or("Untitled"); + let content = truncate(item.get("content").and_then(|v| v.as_str()).unwrap_or("")); + + let art = loop_service::artifact::create_artifact( + &txn, + it.space_id, + it.issue_id, + kind, + title, + status, + ActorKind::Agent, + Some(it.id), + ) + .await?; + loop_service::artifact::add_revision(&txn, art.id, &content, ActorKind::Agent, Some(it.id)) + .await?; + for (ck, text) in &criteria_per_item[idx] { + loop_service::artifact::add_criterion(&txn, art.id, *ck, text).await?; + } + // Canonical edge direction: from = derived/result node, to = its source. + if kind == ArtifactKind::Design { + // Fan into every done requirement, each bound to its latest revision. + for (req_id, rev) in &design_targets { + loop_service::link::create_link( + &txn, + it.space_id, + art.id, + *req_id, + LinkKind::DerivesFrom, + *rev, + ) + .await?; + } + } else if let Some(target) = derive_target { + loop_service::link::create_link( + &txn, + it.space_id, + art.id, + target, + LinkKind::DerivesFrom, + None, + ) + .await?; + } + for task_id in &result_targets { + loop_service::link::create_link( + &txn, + it.space_id, + art.id, + *task_id, + LinkKind::ResultsFrom, + None, + ) + .await?; + } + ids.push(art.id); + // Wire the task dependency edge: from = this (successor) task, to = its + // predecessor (already created earlier in this batch, so `ids[pred]` + // exists). Validated above to be backward-only. + if let Some(pred) = dep_indices[idx] { + loop_service::link::create_link( + &txn, + it.space_id, + art.id, + ids[pred], + LinkKind::DependsOn, + None, + ) + .await?; + } + // Criterion-level coverage: this task claims the acceptance criteria its + // `covers` ordinals named (resolved + validated up-front). Idempotent on + // replay via `uniq_loop_coverage`. + for &cid in &covers_per_item[idx] { + loop_service::coverage::create_coverage(&txn, it.space_id, art.id, cid).await?; + } + } + txn.commit().await?; + + Ok(json!({ "ok": true, "ids": ids })) +} + +/// A single resolved check, validated against the iteration's injected manifest. +struct ResolvedCheck { + criterion_id: i32, + verdict: CheckVerdict, + evidence: String, +} + +/// Review submission (§3.4): the reviewer emits ONE structured check per injected +/// acceptance-criterion handle (`{criterion, verdict, evidence}`), NOT a holistic +/// verdict. Each handle is resolved against the **persisted** criterion manifest +/// (D10) the briefing showed at dispatch — so a concurrent replan can't drift the +/// handles — and the batch is rejected (no write) unless it has exactly one check +/// per injected criterion. The review artifact's `verdict` is derived (`pass` iff +/// all checks pass) for DISPLAY only; the gate decides per-criterion (P2.3). +async fn submit_review( + conn: &DatabaseConnection, + it: &loop_iteration::Model, + payload: &Value, +) -> Result { + if it.stage != Stage::Review { + return Err(invalid("loop_submit_review is only valid during review")); + } + let target = it + .target_artifact_id + .ok_or_else(|| invalid("review iteration has no target task"))?; + + // Idempotency: this review slot already submitted (its artifact + checks are + // committed atomically below, so an existing review artifact means the whole + // submission landed). + if let Some(existing) = loop_artifact::Entity::find() + .filter(loop_artifact::Column::ProducedByIterationId.eq(it.id)) + .filter(loop_artifact::Column::Kind.eq(ArtifactKind::Review)) + .one(conn) + .await? + { + return Ok(json!({ "ok": true, "idempotent": true, "id": existing.id })); + } + + // The injected criterion manifest (D10): handle → criterion id, persisted into + // `context_manifest` at dispatch. Every submitted handle resolves against THIS, + // never a recompute, so a replan after dispatch can't change what's accepted. + let injected = injected_criteria(it)?; + if injected.is_empty() { + return Err(invalid( + "this review has no criteria to check; nothing to submit", + )); + } + + // Parse + resolve each check against the manifest. Unknown/duplicate handle, + // an invalid verdict, or a fail without evidence aborts the whole batch with + // no write — same all-or-nothing contract as artifact submission. + let raw = payload + .get("checks") + .and_then(|v| v.as_array()) + .ok_or_else(|| invalid("missing checks array"))?; + if raw.is_empty() { + return Err(invalid("checks array is empty")); + } + let mut resolved: Vec = Vec::new(); + let mut seen: HashMap = HashMap::new(); + for c in raw { + let handle = c + .get("criterion") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .ok_or_else(|| invalid("each check needs a non-empty `criterion` handle"))?; + let criterion_id = injected.get(handle).copied().ok_or_else(|| { + invalid(format!( + "check references unknown criterion handle '{handle}' (not in this review's checklist)" + )) + })?; + if seen.insert(handle.to_string(), ()).is_some() { + return Err(invalid(format!("duplicate check for criterion '{handle}'"))); + } + let verdict_str = c + .get("verdict") + .and_then(|v| v.as_str()) + .ok_or_else(|| invalid(format!("check '{handle}' is missing a verdict")))?; + let verdict: CheckVerdict = serde_json::from_value(json!(verdict_str)) + .map_err(|_| invalid(format!("check '{handle}' has an invalid verdict '{verdict_str}'")))?; + let evidence = c + .get("evidence") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + if verdict == CheckVerdict::Fail && evidence.is_empty() { + return Err(invalid(format!( + "check '{handle}' is a fail but cites no evidence; name the specific defect" + ))); + } + resolved.push(ResolvedCheck { + criterion_id, + verdict, + evidence: truncate(&evidence), + }); + } + + // Require exactly one check per injected criterion (the injected set = the + // manifest's keys); a missing handle aborts the batch. + let missing: Vec<&str> = injected + .keys() + .filter(|h| !seen.contains_key(*h)) + .map(|s| s.as_str()) + .collect(); + if !missing.is_empty() { + return Err(invalid(format!( + "missing checks for: {}. Submit exactly one check per listed criterion handle.", + missing.join(", ") + ))); + } + + // Derived display verdict (DISPLAY ONLY — the gate decides per-criterion). + let all_pass = resolved.iter().all(|c| c.verdict == CheckVerdict::Pass); + let display_verdict = if all_pass { + ReviewVerdict::Pass + } else { + ReviewVerdict::Fail + }; + let findings = truncate(payload.get("findings").and_then(|v| v.as_str()).unwrap_or("")); + + let title = format!("Review (slot {})", it.slot_no.unwrap_or(0)); + // Atomic: the review artifact, its derived verdict, the findings revision, the + // `reviews` edge, and every per-criterion check land all-or-nothing, so the + // idempotency guard above (a review by this iteration exists → skip) is correct + // under crash replay. + let txn = conn.begin().await?; + let art = loop_service::artifact::create_artifact( + &txn, + it.space_id, + it.issue_id, + ArtifactKind::Review, + &title, + ArtifactStatus::Done, + ActorKind::Agent, + Some(it.id), + ) + .await?; + let mut active = art.clone().into_active_model(); + active.verdict = Set(Some(display_verdict)); + active.update(&txn).await?; + + loop_service::artifact::add_revision(&txn, art.id, &findings, ActorKind::Agent, Some(it.id)) + .await?; + loop_service::link::create_link(&txn, it.space_id, art.id, target, LinkKind::Reviews, None) + .await?; + // One criterion_check per check; the scope is the reviewed target, the + // iteration is this reviewer slot's stable per-attempt identity (the gate's + // digest key). Idempotent on `(criterion, iteration, scope)`. + for c in &resolved { + loop_service::criterion_check::create_check( + &txn, + it.space_id, + c.criterion_id, + it.id, + target, + c.verdict, + &c.evidence, + ) + .await?; + } + txn.commit().await?; + + Ok(json!({ + "ok": true, + "id": art.id, + "verdict": review_verdict_str(display_verdict), + "checks": resolved.len(), + })) +} + +/// Parse the iteration's persisted criterion manifest (D10) into a `handle → +/// criterion id` map. `context_manifest` is the briefing manifest stashed at +/// dispatch; review iterations carry a `"criteria"` object. Absent/malformed ⇒ +/// empty map (the caller rejects an empty injected set). +fn injected_criteria(it: &loop_iteration::Model) -> Result, LoopError> { + let Some(raw) = it.context_manifest.as_deref() else { + return Ok(HashMap::new()); + }; + let manifest: Value = + serde_json::from_str(raw).map_err(|_| invalid("review manifest is unreadable"))?; + let Some(obj) = manifest.get("criteria").and_then(|v| v.as_object()) else { + return Ok(HashMap::new()); + }; + let mut out = HashMap::new(); + for (handle, v) in obj { + if let Some(id) = v.as_i64() { + out.insert(handle.clone(), id as i32); + } + } + Ok(out) +} + +/// The `{ "M{n}": memory_id }` map the briefing stashed into the iteration's +/// `context_manifest` at dispatch. Read handles resolve against THIS — never a +/// recompute — so a concurrent create/supersede can't drift what was shown. +/// Absent/malformed `memory_index` ⇒ empty map (every handle becomes `not_found`). +fn injected_memory_index(it: &loop_iteration::Model) -> Result, LoopError> { + let Some(raw) = it.context_manifest.as_deref() else { + return Ok(HashMap::new()); + }; + let manifest: Value = + serde_json::from_str(raw).map_err(|e| invalid(format!("manifest parse: {e}")))?; + let Some(obj) = manifest.get("memory_index").and_then(|v| v.as_object()) else { + return Ok(HashMap::new()); + }; + // Checked narrowing: an out-of-range / non-integer manifest value resolves to + // no entry (so that handle surfaces as `not_found`) rather than wrapping to a + // wrong id. In practice the engine only ever writes i32 PK ids here. + Ok(obj + .iter() + .filter_map(|(k, v)| { + v.as_i64() + .and_then(|id| i32::try_from(id).ok()) + .map(|id| (k.clone(), id)) + }) + .collect()) +} + +/// `loop_read_memory`: a pure, batch read. The agent passes as many `[M{n}]` +/// handles from its briefing's Memory index as it judges relevant in one call; +/// each resolves against the stashed manifest, and resolved ids are fetched via +/// `get_for_read` (re-scoped to this space + active + non-constitution). A handle +/// that is unknown to the manifest OR resolves to a row that left the recall path +/// (archived/superseded/cross-space) comes back in `not_found` — never silently +/// dropped. Writes nothing. +async fn read_memory( + conn: &DatabaseConnection, + it: &loop_iteration::Model, + payload: &Value, +) -> Result { + let Some(arr) = payload.get("handles").and_then(|v| v.as_array()) else { + return Err(invalid("loop_read_memory requires a `handles` array")); + }; + // Every entry must be a string (the schema says so); a non-string entry is a + // malformed call, rejected rather than silently dropped. + let mut handles: Vec = Vec::with_capacity(arr.len()); + for h in arr { + match h.as_str() { + Some(s) => handles.push(s.to_string()), + None => { + return Err(invalid( + "loop_read_memory `handles` must be an array of strings", + )) + } + } + } + if handles.is_empty() { + return Err(invalid("loop_read_memory requires a non-empty `handles` array")); + } + let index = injected_memory_index(it)?; + // Resolve handles → ids against the stored manifest; unknown → not_found. + // Dedupe by first-seen so a repeated handle yields one result, not N copies. + let mut seen: HashSet = HashSet::new(); + let mut wanted: Vec<(String, i32)> = Vec::new(); + let mut not_found: Vec = Vec::new(); + for h in handles { + if !seen.insert(h.clone()) { + continue; + } + match index.get(&h) { + Some(id) => wanted.push((h, *id)), + None => not_found.push(h), + } + } + let ids: Vec = wanted.iter().map(|(_, id)| *id).collect(); + let rows = loop_service::memory::get_for_read(conn, it.space_id, &ids).await?; + let by_id: HashMap = rows.into_iter().map(|m| (m.id, m)).collect(); + let mut memories: Vec = Vec::new(); + for (handle, id) in wanted { + match by_id.get(&id) { + // Resolved to a live, in-space, active memory → return it in full. + Some(m) => memories.push(json!({ + "handle": handle, + "kind": m.kind, + "trust": m.trust_tier, + "title": m.title, + "summary": m.summary, + "content": m.content, + "source_issue_id": m.source_issue_id, + "source_artifact_id": m.source_artifact_id, + "produced_by_iteration_id": m.produced_by_iteration_id, + })), + // In the manifest but no live row — archived/superseded/deleted since + // dispatch, or a cross-space id get_for_read filtered out — is a + // not_found receipt (NEVER silently dropped). + None => not_found.push(handle), + } + } + Ok(json!({ "ok": true, "memories": memories, "not_found": not_found })) +} + +/// Lowercase wire token for a derived review verdict. +fn review_verdict_str(v: ReviewVerdict) -> &'static str { + match v { + ReviewVerdict::Pass => "pass", + ReviewVerdict::Fail => "fail", + } +} + +async fn report_blocked( + conn: &DatabaseConnection, + it: &loop_iteration::Model, + payload: &Value, +) -> Result { + let reason = payload + .get("reason") + .and_then(|v| v.as_str()) + .unwrap_or("agent reported blocked"); + let subject = match it.target_artifact_id { + Some(target) => format!("artifact:{target}"), + None => format!("issue:{}", it.issue_id), + }; + let payload_json = json!({ + "v": 1, + "reason": truncate(reason), + "iteration_id": it.id, + }); + loop_service::inbox::upsert_inbox( + conn, + it.space_id, + it.issue_id, + Some(it.id), + InboxKind::Blocked, + &subject, + payload_json, + ) + .await?; + Ok(json!({ "ok": true })) +} + +/// D12: the implement agent declares the task already satisfied (a dependency +/// delivered the work / nothing to change) instead of ending its turn with an +/// empty diff. Records the free-text reason on the iteration; the checkpoint path +/// (`finish_implement`) reads it to route the task to review with +/// `outcome=declared_complete` rather than treating the empty diff as a stuck +/// no-progress failure. The review gate still verifies the acceptance criteria +/// against the current worktree HEAD. +async fn task_complete( + conn: &DatabaseConnection, + it: &loop_iteration::Model, + payload: &Value, +) -> Result { + // Stage allow-table: only an implement iteration may declare task completion. + if it.stage != Stage::Implement { + return Err(invalid("loop_task_complete is only valid during implement")); + } + // Implement iterations are task-scoped; a missing target means this token does + // not back a task — reject rather than record an orphan claim. + if it.target_artifact_id.is_none() { + return Err(invalid( + "loop_task_complete requires a task-scoped implement iteration", + )); + } + let reason = payload + .get("reason") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .ok_or_else(|| invalid("loop_task_complete requires a non-empty `reason`"))?; + loop_iteration::Entity::update_many() + .col_expr( + loop_iteration::Column::AgentCompletionReason, + sea_orm::sea_query::Expr::value(truncate(reason)), + ) + .filter(loop_iteration::Column::Id.eq(it.id)) + .exec(conn) + .await?; + Ok(json!({ "ok": true })) +} + +async fn record_memory( + conn: &DatabaseConnection, + it: &loop_iteration::Model, + payload: &Value, +) -> Result { + // Agents may propose constraint/decision/preference/pitfall memories — never + // the space constitution (human-authored only); anything unrecognized falls + // back to a pitfall note. + let kind = match payload.get("kind").and_then(|v| v.as_str()) { + Some(k) => serde_json::from_value::(json!(k)).unwrap_or(MemoryKind::Pitfall), + None => MemoryKind::Pitfall, + }; + let kind = if matches!(kind, MemoryKind::Constitution) { + MemoryKind::Pitfall + } else { + kind + }; + let title = payload + .get("title") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or("Note"); + let content = payload.get("content").and_then(|v| v.as_str()).unwrap_or(""); + if content.trim().is_empty() { + return Err(invalid("memory content is empty")); + } + // Optional one-line summary for the briefing index — collapse any whitespace + // so a multiline summary can't break the one-line-per-memory layout. + let summary = payload + .get("summary") + .and_then(|v| v.as_str()) + .map(|s| s.split_whitespace().collect::>().join(" ")) + .filter(|s| !s.is_empty()); + let m = loop_service::memory::create_memory( + conn, + it.space_id, + kind, + ActorKind::Agent, + title, + summary.as_deref(), + &truncate(content), + TrustTier::Proposed, + loop_service::memory::MemoryProvenance { + source_issue_id: Some(it.issue_id), + source_artifact_id: None, + produced_by_iteration_id: Some(it.id), + }, + ) + .await?; + Ok(json!({ "ok": true, "id": m.id })) +} + +/// reflect write path (§4.4): build the retrospective `reflection` artifact and +/// distill memories in ONE transaction. The reflection-artifact insert is the +/// atomic idempotency gate (uniq_reflection_per_issue): a UNIQUE violation means +/// another submit/replay already consolidated → roll back + return idempotent. +/// Every `supersedes` [M{n}] handle is resolved up-front against the STORED +/// manifest + a live/in-space/active lookup; unknown / cross-space / constitution +/// / duplicate handles abort the whole batch with no partial write. +async fn submit_reflection( + conn: &DatabaseConnection, + it: &loop_iteration::Model, + payload: &Value, +) -> Result { + if it.stage != Stage::Reflect { + return Err(invalid("loop_submit_reflection is only valid during reflect")); + } + // Fast-path idempotency: a reflection for this issue already exists. + if loop_artifact::Entity::find() + .filter(loop_artifact::Column::IssueId.eq(it.issue_id)) + .filter(loop_artifact::Column::Kind.eq(ArtifactKind::Reflection)) + .one(conn) + .await? + .is_some() + { + return Ok(json!({ "ok": true, "idempotent": true })); + } + + let refl = payload + .get("reflection") + .and_then(|v| v.as_object()) + .ok_or_else(|| invalid("loop_submit_reflection requires a `reflection` object"))?; + let refl_title = refl + .get("title") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or("Reflection"); + let refl_content = refl.get("content").and_then(|v| v.as_str()).unwrap_or(""); + if refl_content.trim().is_empty() { + return Err(invalid("reflection content is empty")); + } + + let mem_items: Vec<&Value> = payload + .get("memories") + .and_then(|v| v.as_array()) + .map(|a| a.iter().collect()) + .unwrap_or_default(); + let index = injected_memory_index(it)?; // empty map if no memory_index in manifest + + struct PendingMemory { + kind: MemoryKind, + title: String, + summary: Option, + content: String, + supersede_ids: Vec, + } + let mut pending: Vec = Vec::with_capacity(mem_items.len()); + // (memory index, handle, resolved id) for every supersede across the batch. + let mut all_supersede: Vec<(usize, String, i32)> = Vec::new(); + let mut seen_supersede: HashSet = HashSet::new(); + for (idx, item) in mem_items.iter().enumerate() { + let kind = match item.get("kind").and_then(|v| v.as_str()) { + Some(k) => serde_json::from_value::(json!(k)) + .map_err(|_| invalid(format!("memory {idx}: unknown kind `{k}`")))?, + None => return Err(invalid(format!("memory {idx}: missing kind"))), + }; + if matches!(kind, MemoryKind::Constitution) { + return Err(invalid(format!( + "memory {idx}: agents may not record a constitution memory" + ))); + } + let title = item + .get("title") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or("Note") + .to_string(); + let content = item.get("content").and_then(|v| v.as_str()).unwrap_or(""); + if content.trim().is_empty() { + return Err(invalid(format!("memory {idx}: content is empty"))); + } + let summary = item + .get("summary") + .and_then(|v| v.as_str()) + .map(|s| s.split_whitespace().collect::>().join(" ")) + .filter(|s| !s.is_empty()); + let mut supersede_ids = Vec::new(); + if let Some(arr) = item.get("supersedes").and_then(|v| v.as_array()) { + for h in arr { + let handle = h + .as_str() + .ok_or_else(|| invalid(format!("memory {idx}: supersedes must be string handles")))?; + let id = index.get(handle).copied().ok_or_else(|| { + invalid(format!( + "memory {idx}: supersedes handle `{handle}` is not in your Memory index" + )) + })?; + // A handle may be superseded by at most one memory per batch. + if !seen_supersede.insert(id) { + return Err(invalid(format!( + "memory {idx}: handle `{handle}` is superseded more than once in this submission" + ))); + } + supersede_ids.push(id); + all_supersede.push((idx, handle.to_string(), id)); + } + } + pending.push(PendingMemory { + kind, + title, + summary, + content: truncate(content), + supersede_ids, + }); + } + + // Confirm every supersede target is live/in-space/active in ONE query; abort if any gone. + if !all_supersede.is_empty() { + let ids: Vec = all_supersede.iter().map(|(_, _, id)| *id).collect(); + let live: HashSet = loop_service::memory::get_for_read(conn, it.space_id, &ids) + .await? + .into_iter() + .map(|m| m.id) + .collect(); + if let Some((idx, handle, _)) = all_supersede.iter().find(|(_, _, id)| !live.contains(id)) { + return Err(invalid(format!( + "memory {idx}: supersedes handle `{handle}` no longer names an active memory" + ))); + } + } + + // derives_from: live result else issue root. + let issue_arts = loop_artifact::Entity::find() + .filter(loop_artifact::Column::IssueId.eq(it.issue_id)) + .all(conn) + .await?; + let derive_target = issue_arts + .iter() + .find(|a| { + a.kind == ArtifactKind::Result + && !matches!(a.status, ArtifactStatus::Superseded | ArtifactStatus::Cancelled) + }) + .or_else(|| issue_arts.iter().find(|a| a.kind == ArtifactKind::Issue)) + .map(|a| a.id); + + let txn = conn.begin().await?; + // The artifact insert is the idempotency gate (uniq_reflection_per_issue). + let art = match loop_service::artifact::create_artifact( + &txn, + it.space_id, + it.issue_id, + ArtifactKind::Reflection, + refl_title, + default_status_for_kind(ArtifactKind::Reflection), + ActorKind::Agent, + Some(it.id), + ) + .await + { + Ok(a) => a, + Err(crate::db::error::DbError::Database(e)) if transitions::is_unique_violation(&e) => { + // Lost the race — another submit already consolidated this issue. + let _ = txn.rollback().await; + return Ok(json!({ "ok": true, "idempotent": true })); + } + Err(e) => return Err(e.into()), + }; + loop_service::artifact::add_revision( + &txn, + art.id, + &truncate(refl_content), + ActorKind::Agent, + Some(it.id), + ) + .await?; + if let Some(target) = derive_target { + loop_service::link::create_link( + &txn, + it.space_id, + art.id, + target, + LinkKind::DerivesFrom, + None, + ) + .await?; + } + let mut recorded = 0usize; + let mut superseded_handles: Vec = Vec::new(); + for pm in &pending { + let m = loop_service::memory::create_memory( + &txn, + it.space_id, + pm.kind, + ActorKind::Agent, + &pm.title, + pm.summary.as_deref(), + &pm.content, + TrustTier::Distilled, + loop_service::memory::MemoryProvenance { + source_issue_id: Some(it.issue_id), + source_artifact_id: Some(art.id), + produced_by_iteration_id: Some(it.id), + }, + ) + .await?; + for old_id in &pm.supersede_ids { + if loop_service::memory::supersede_memory(&txn, *old_id, m.id).await? { + // echo the handle (not the id) the agent submitted for this target. + if let Some((_, h, _)) = all_supersede.iter().find(|(_, _, id)| id == old_id) { + superseded_handles.push(h.clone()); + } + } + } + recorded += 1; + } + txn.commit().await?; + Ok(json!({ "ok": true, "recorded": recorded, "superseded": superseded_handles })) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::entities::loop_issue::IssuePriority as Prio; + use crate::db::service::loop_service; + use crate::db::test_helpers::{fresh_in_memory_db, seed_folder}; + use crate::loop_engine::transitions::{self, IterationClaim}; + use crate::models::loops::IssueConfig; + + /// Create space + issue, returning (conn-owning db, space_id, issue_id, + /// root_artifact_id). + async fn seed() -> (crate::db::AppDatabase, i32, i32, i32) { + let db = fresh_in_memory_db().await; + let folder_id = seed_folder(&db, "/repo").await; + let space = loop_service::space::create_space(&db.conn, "S", folder_id) + .await + .unwrap(); + let issue = loop_service::issue::create_issue( + &db.conn, + space.id, + "Build", + "desc", + Prio::Medium, + Some(&IssueConfig::default()), + ) + .await + .unwrap(); + // The root issue artifact is the only kind=issue node. + let dag = loop_service::artifact::list_dag(&db.conn, issue.row.id) + .await + .unwrap(); + let root = dag + .artifacts + .iter() + .find(|a| matches!(a.kind, ArtifactKind::Issue)) + .expect("root issue artifact") + .id; + (db, space.id, issue.row.id, root) + } + + async fn running_iter( + conn: &DatabaseConnection, + space_id: i32, + issue_id: i32, + stage: Stage, + target: Option, + token: &str, + ) -> i32 { + let it = transitions::try_claim_iteration( + conn, + IterationClaim { + space_id, + issue_id, + stage, + target_artifact_id: target, + slot_no: if stage == Stage::Review { Some(0) } else { None }, + capability_token: token.to_string(), + attempt: 0, + }, + ) + .await + .unwrap() + .expect("claimed iteration"); + transitions::cas_iteration_status( + conn, + it.id, + IterationStatus::Queued, + IterationStatus::Running, + ) + .await + .unwrap(); + it.id + } + + #[tokio::test] + async fn unknown_token_is_rejected() { + let (db, _s, _i, _root) = seed().await; + let err = ingest(&db.conn, "nope", "loop_submit_route", &json!({"route":"full"})) + .await + .unwrap_err(); + assert!(matches!(err, LoopError::InvalidInput(_))); + } + + #[tokio::test] + async fn route_written_only_from_triage() { + let (db, space, issue, root) = seed().await; + let _ = running_iter(&db.conn, space, issue, Stage::Triage, Some(root), "tok-triage").await; + ingest( + &db.conn, + "tok-triage", + "loop_submit_route", + &json!({"route":"skip_design","priority":"high"}), + ) + .await + .unwrap(); + let updated = loop_service::issue::get_issue(&db.conn, issue) + .await + .unwrap() + .unwrap(); + assert_eq!(updated.route, IssueRoute::SkipDesign); + assert_eq!(updated.priority, IssuePriority::High); + + // A refine iteration may not submit a route. + let _ = running_iter(&db.conn, space, issue, Stage::Refine, Some(root), "tok-refine").await; + let err = ingest(&db.conn, "tok-refine", "loop_submit_route", &json!({"route":"full"})) + .await + .unwrap_err(); + assert!(matches!(err, LoopError::InvalidInput(_))); + } + + #[tokio::test] + async fn task_complete_records_reason_only_during_implement() { + let (db, space, issue, root) = seed().await; + + // Implement iteration → records the declared reason, returns ok. + let it_id = + running_iter(&db.conn, space, issue, Stage::Implement, Some(root), "tok-impl").await; + let out = ingest( + &db.conn, + "tok-impl", + "loop_task_complete", + &json!({ "reason": "page already renders the component from task #2" }), + ) + .await + .unwrap(); + assert_eq!(out["ok"], json!(true)); + let it = loop_service::iteration::get_iteration(&db.conn, it_id) + .await + .unwrap() + .unwrap(); + assert_eq!( + it.agent_completion_reason.as_deref(), + Some("page already renders the component from task #2") + ); + + // Empty/whitespace reason → rejected (reuse the same running implement + // iteration; no orphan claim is recorded). + let err = ingest(&db.conn, "tok-impl", "loop_task_complete", &json!({ "reason": " " })) + .await + .unwrap_err(); + assert!(matches!(err, LoopError::InvalidInput(_))); + + // A non-implement stage (review) may not declare task completion. + let _ = running_iter(&db.conn, space, issue, Stage::Review, Some(root), "tok-rev").await; + let err = ingest( + &db.conn, + "tok-rev", + "loop_task_complete", + &json!({ "reason": "nothing to do" }), + ) + .await + .unwrap_err(); + assert!(matches!(err, LoopError::InvalidInput(_))); + } + + #[tokio::test] + async fn submit_artifacts_rejects_when_over_write_budget() { + let (db, space, issue, root) = seed().await; + let _ = running_iter(&db.conn, space, issue, Stage::Refine, Some(root), "tok").await; + // One artifact whose content exceeds MAX_BYTES_PER_ITERATION (chars). + let huge = "x".repeat(MAX_BYTES_PER_ITERATION + 1); + let payload = json!({ "artifacts": [ { "title": "Big", "content": huge } ] }); + let err = ingest(&db.conn, "tok", "loop_submit_artifacts", &payload) + .await + .unwrap_err(); + assert!(matches!(err, LoopError::InvalidInput(_))); + // A blocked card was filed for the human. + let items = loop_service::inbox::list_inbox(&db.conn, space, None) + .await + .unwrap(); + assert!(items.iter().any(|i| matches!(i.kind, InboxKind::Blocked))); + } + + #[tokio::test] + async fn artifacts_create_nodes_and_edges_then_idempotent() { + let (db, space, issue, root) = seed().await; + let _ = running_iter(&db.conn, space, issue, Stage::Refine, Some(root), "tok").await; + + let payload = json!({ + "artifacts": [ + {"title": "Req A", "content": "shall A", "criteria": ["AC one", "AC two"]}, + {"title": "Req B", "content": "shall B"} + ] + }); + let out = ingest(&db.conn, "tok", "loop_submit_artifacts", &payload) + .await + .unwrap(); + let ids = out["ids"].as_array().unwrap(); + assert_eq!(ids.len(), 2); + + let dag = loop_service::artifact::list_dag(&db.conn, issue).await.unwrap(); + let reqs: Vec<_> = dag + .artifacts + .iter() + .filter(|a| matches!(a.kind, ArtifactKind::Requirement)) + .collect(); + assert_eq!(reqs.len(), 2); + // Both derive_from the root. + let derive_edges = dag + .links + .iter() + .filter(|l| matches!(l.kind, LinkKind::DerivesFrom) && l.to_artifact_id == root) + .count(); + assert_eq!(derive_edges, 2); + + // Replay → idempotent, no duplicates. + let again = ingest(&db.conn, "tok", "loop_submit_artifacts", &payload) + .await + .unwrap(); + assert_eq!(again["idempotent"], json!(true)); + let dag2 = loop_service::artifact::list_dag(&db.conn, issue).await.unwrap(); + assert_eq!( + dag2.artifacts + .iter() + .filter(|a| matches!(a.kind, ArtifactKind::Requirement)) + .count(), + 2 + ); + } + + #[tokio::test] + async fn design_fans_into_all_requirements_with_bound_revisions() { + let (db, space, issue, root) = seed().await; + // Refine: two requirements, each with an acceptance criterion. + let _ = running_iter(&db.conn, space, issue, Stage::Refine, Some(root), "tok-r").await; + ingest( + &db.conn, + "tok-r", + "loop_submit_artifacts", + &json!({ + "artifacts": [ + {"title": "Req A", "content": "shall A", "criteria": ["A1"]}, + {"title": "Req B", "content": "shall B", "criteria": ["B1"]} + ] + }), + ) + .await + .unwrap(); + let dag = loop_service::artifact::list_dag(&db.conn, issue).await.unwrap(); + let reqs: std::collections::HashSet = dag + .artifacts + .iter() + .filter(|a| matches!(a.kind, ArtifactKind::Requirement)) + .map(|a| a.id) + .collect(); + assert_eq!(reqs.len(), 2); + + // Design: one design fans into BOTH requirements, each edge bound to a rev. + let _ = running_iter(&db.conn, space, issue, Stage::Design, Some(root), "tok-d").await; + ingest( + &db.conn, + "tok-d", + "loop_submit_artifacts", + &json!({ + "artifacts": [ + {"title": "Design", "content": "the design", + "criteria": [{"text": "stays O(1)", "kind": "invariant"}]} + ] + }), + ) + .await + .unwrap(); + let dag = loop_service::artifact::list_dag(&db.conn, issue).await.unwrap(); + let design_id = dag + .artifacts + .iter() + .find(|a| matches!(a.kind, ArtifactKind::Design)) + .unwrap() + .id; + let edges: Vec<_> = dag + .links + .iter() + .filter(|l| matches!(l.kind, LinkKind::DerivesFrom) && l.from_artifact_id == design_id) + .collect(); + assert_eq!(edges.len(), 2, "design derives from both requirements"); + assert!( + edges.iter().all(|e| e.source_revision_id.is_some()), + "each lineage edge binds a requirement revision" + ); + let targets: std::collections::HashSet = + edges.iter().map(|e| e.to_artifact_id).collect(); + assert_eq!(targets, reqs, "edges point at exactly the requirements"); + } + + #[tokio::test] + async fn typed_criteria_allow_set_enforced() { + let (db, space, issue, root) = seed().await; + // A design may not carry an acceptance criterion → batch aborts, no write. + let _ = running_iter(&db.conn, space, issue, Stage::Design, Some(root), "tok-d").await; + let err = ingest( + &db.conn, + "tok-d", + "loop_submit_artifacts", + &json!({ + "artifacts": [{"title": "D", "content": "x", + "criteria": [{"text": "do x", "kind": "acceptance"}]}] + }), + ) + .await + .unwrap_err(); + assert!(matches!(err, LoopError::InvalidInput(_))); + let dag = loop_service::artifact::list_dag(&db.conn, issue).await.unwrap(); + assert!( + !dag.artifacts.iter().any(|a| matches!(a.kind, ArtifactKind::Design)), + "rejected batch wrote nothing" + ); + + // A requirement may not carry an obligation. + let _ = running_iter(&db.conn, space, issue, Stage::Refine, Some(root), "tok-r").await; + let err = ingest( + &db.conn, + "tok-r", + "loop_submit_artifacts", + &json!({ + "artifacts": [{"title": "R", "content": "x", + "criteria": [{"text": "no panics", "kind": "obligation"}]}] + }), + ) + .await + .unwrap_err(); + assert!(matches!(err, LoopError::InvalidInput(_))); + } + + #[tokio::test] + async fn plan_covers_creates_criterion_coverage() { + let (db, space, issue, root) = seed().await; + // Refine: two requirements, each with one acceptance criterion. + let _ = running_iter(&db.conn, space, issue, Stage::Refine, Some(root), "tok-r").await; + ingest( + &db.conn, + "tok-r", + "loop_submit_artifacts", + &json!({ + "artifacts": [ + {"title": "Req A", "content": "shall A", "criteria": ["A1"]}, + {"title": "Req B", "content": "shall B", "criteria": ["B1"]} + ] + }), + ) + .await + .unwrap(); + + // Resolve each requirement's acceptance criterion id (ordered by sort,id). + let dag = loop_service::artifact::list_dag(&db.conn, issue).await.unwrap(); + let mut reqs: Vec<_> = dag + .artifacts + .iter() + .filter(|a| matches!(a.kind, ArtifactKind::Requirement)) + .collect(); + reqs.sort_by_key(|a| (a.sort, a.id)); + let ac1 = loop_service::artifact::get_artifact_detail(&db.conn, reqs[0].id) + .await + .unwrap() + .unwrap() + .criteria[0] + .id; + let ac2 = loop_service::artifact::get_artifact_detail(&db.conn, reqs[1].id) + .await + .unwrap() + .unwrap() + .criteria[0] + .id; + + // Plan: two tasks, each covering one requirement's acceptance criterion. + let _ = running_iter(&db.conn, space, issue, Stage::Plan, Some(root), "tok-p").await; + ingest( + &db.conn, + "tok-p", + "loop_submit_artifacts", + &json!({ + "artifacts": [ + {"title": "T1", "content": "do A", "covers": ["R1.AC1"]}, + {"title": "T2", "content": "do B", "covers": ["R2.AC1"]} + ] + }), + ) + .await + .unwrap(); + + let dag = loop_service::artifact::list_dag(&db.conn, issue).await.unwrap(); + assert_eq!(dag.coverage.len(), 2); + let covered: std::collections::HashSet = + dag.coverage.iter().map(|c| c.criterion_id).collect(); + assert_eq!(covered, [ac1, ac2].into_iter().collect()); + } + + #[tokio::test] + async fn plan_covers_unknown_ordinal_aborts_batch() { + let (db, space, issue, root) = seed().await; + let _ = running_iter(&db.conn, space, issue, Stage::Refine, Some(root), "tok-r").await; + ingest( + &db.conn, + "tok-r", + "loop_submit_artifacts", + &json!({ + "artifacts": [{"title": "Req A", "content": "shall A", "criteria": ["A1"]}] + }), + ) + .await + .unwrap(); + + // Plan references a non-existent ordinal → whole batch rejected, no rows. + let _ = running_iter(&db.conn, space, issue, Stage::Plan, Some(root), "tok-p").await; + let err = ingest( + &db.conn, + "tok-p", + "loop_submit_artifacts", + &json!({ + "artifacts": [ + {"title": "T1", "content": "do A", "covers": ["R1.AC1"]}, + {"title": "T2", "content": "do B", "covers": ["R9.AC1"]} + ] + }), + ) + .await + .unwrap_err(); + assert!(matches!(err, LoopError::InvalidInput(_))); + let dag = loop_service::artifact::list_dag(&db.conn, issue).await.unwrap(); + assert_eq!( + dag.artifacts + .iter() + .filter(|a| matches!(a.kind, ArtifactKind::Task)) + .count(), + 0, + "no tasks written" + ); + assert_eq!(dag.coverage.len(), 0, "no coverage written"); + } + + #[tokio::test] + async fn plan_incomplete_coverage_rejected_then_resubmit_succeeds() { + let (db, space, issue, root) = seed().await; + // Two requirements, each one acceptance criterion → ordinals R1.AC1, R2.AC1. + let _ = running_iter(&db.conn, space, issue, Stage::Refine, Some(root), "tok-r").await; + ingest( + &db.conn, + "tok-r", + "loop_submit_artifacts", + &json!({ + "artifacts": [ + {"title": "Req A", "content": "shall A", "criteria": ["A1"]}, + {"title": "Req B", "content": "shall B", "criteria": ["B1"]} + ] + }), + ) + .await + .unwrap(); + + // A plan covering only R1.AC1 leaves R2.AC1 uncovered → rejected in-turn, + // nothing written (the planner's iteration stays running so it can resubmit). + let _ = running_iter(&db.conn, space, issue, Stage::Plan, Some(root), "tok-p").await; + let err = ingest( + &db.conn, + "tok-p", + "loop_submit_artifacts", + &json!({"artifacts": [{"title": "T1", "content": "do A", "covers": ["R1.AC1"]}]}), + ) + .await + .unwrap_err(); + assert!(matches!(err, LoopError::InvalidInput(_))); + let dag = loop_service::artifact::list_dag(&db.conn, issue).await.unwrap(); + assert_eq!( + dag.artifacts + .iter() + .filter(|a| matches!(a.kind, ArtifactKind::Task)) + .count(), + 0, + "incomplete plan wrote no tasks" + ); + assert_eq!(dag.coverage.len(), 0, "no coverage written"); + + // Same turn (same token): resubmit covering BOTH ordinals → accepted. + let out = ingest( + &db.conn, + "tok-p", + "loop_submit_artifacts", + &json!({ + "artifacts": [ + {"title": "T1", "content": "do A", "covers": ["R1.AC1"]}, + {"title": "T2", "content": "do B", "covers": ["R2.AC1"]} + ] + }), + ) + .await + .unwrap(); + assert_eq!(out["ids"].as_array().unwrap().len(), 2); + let dag = loop_service::artifact::list_dag(&db.conn, issue).await.unwrap(); + assert_eq!(dag.coverage.len(), 2, "complete resubmit records full coverage"); + } + + #[tokio::test] + async fn stage_kind_mismatch_is_rejected() { + let (db, space, issue, root) = seed().await; + // A review iteration cannot submit artifacts. + let _ = running_iter(&db.conn, space, issue, Stage::Review, Some(root), "tok-rev").await; + let err = ingest( + &db.conn, + "tok-rev", + "loop_submit_artifacts", + &json!({"artifacts":[{"title":"x","content":"y"}]}), + ) + .await + .unwrap_err(); + assert!(matches!(err, LoopError::InvalidInput(_))); + } + + /// Mint a task with one acceptance criterion + a running review iteration whose + /// persisted manifest injects `{ "T1": }` (what dispatch would + /// stash). Created directly (not via a Plan iteration) so it can be called + /// repeatedly for one issue without colliding on the Plan node lease. Returns + /// `(task_id, criterion_id)`. + async fn seed_task_under_review( + db: &crate::db::AppDatabase, + space: i32, + issue: i32, + _root: i32, + token: &str, + ) -> (i32, i32) { + let task = loop_service::artifact::create_artifact( + &db.conn, + space, + issue, + ArtifactKind::Task, + "Task 1", + ArtifactStatus::InProgress, + ActorKind::Agent, + None, + ) + .await + .unwrap(); + let task_id = task.id; + let t1 = loop_service::artifact::add_criterion( + &db.conn, + task_id, + CriterionKind::Acceptance, + "does the thing", + ) + .await + .unwrap() + .id; + + let iter_id = running_iter(&db.conn, space, issue, Stage::Review, Some(task_id), token).await; + // Stash the injected criterion manifest (D10) — what assemble_briefing emits. + loop_iteration::Entity::update_many() + .col_expr( + loop_iteration::Column::ContextManifest, + sea_orm::sea_query::Expr::value(json!({ "criteria": { "T1": t1 } }).to_string()), + ) + .filter(loop_iteration::Column::Id.eq(iter_id)) + .exec(&db.conn) + .await + .unwrap(); + (task_id, t1) + } + + #[tokio::test] + async fn review_records_checks_verdict_and_edge() { + let (db, space, issue, root) = seed().await; + let (task_id, t1) = + seed_task_under_review(&db, space, issue, root, "tok-review").await; + + let out = ingest( + &db.conn, + "tok-review", + "loop_submit_review", + &json!({"checks":[{"criterion":"T1","verdict":"pass","evidence":"it works"}], + "findings":"looks good"}), + ) + .await + .unwrap(); + let review_id = out["id"].as_i64().unwrap() as i32; + assert_eq!(out["checks"], json!(1)); + + // Display verdict derived = pass; the reviews edge points at the task. + let detail = loop_service::artifact::get_artifact_detail(&db.conn, review_id) + .await + .unwrap() + .unwrap(); + assert_eq!(detail.row.verdict, Some(ReviewVerdict::Pass)); + assert!(detail + .links + .iter() + .any(|l| matches!(l.kind, LinkKind::Reviews) && l.to_artifact_id == task_id)); + // A criterion_check row landed for T1, scoped to the task. + let checks = loop_service::criterion_check::list_for_issue(&db.conn, issue) + .await + .unwrap(); + assert_eq!(checks.len(), 1); + assert_eq!(checks[0].criterion_id, t1); + assert_eq!(checks[0].scope_artifact_id, task_id); + + // Replay → idempotent, no second review / check. + let again = ingest( + &db.conn, + "tok-review", + "loop_submit_review", + &json!({"checks":[{"criterion":"T1","verdict":"pass","evidence":"it works"}]}), + ) + .await + .unwrap(); + assert_eq!(again["idempotent"], json!(true)); + assert_eq!( + loop_service::criterion_check::list_for_issue(&db.conn, issue) + .await + .unwrap() + .len(), + 1 + ); + } + + #[tokio::test] + async fn review_fail_check_derives_fail_verdict() { + let (db, space, issue, root) = seed().await; + let (_task, _t1) = seed_task_under_review(&db, space, issue, root, "tok-rev").await; + let out = ingest( + &db.conn, + "tok-rev", + "loop_submit_review", + &json!({"checks":[{"criterion":"T1","verdict":"fail","evidence":"crashes on empty input"}]}), + ) + .await + .unwrap(); + let detail = loop_service::artifact::get_artifact_detail(&db.conn, out["id"].as_i64().unwrap() as i32) + .await + .unwrap() + .unwrap(); + assert_eq!(detail.row.verdict, Some(ReviewVerdict::Fail), "any failing check → fail verdict"); + } + + #[tokio::test] + async fn review_rejects_unknown_missing_duplicate_and_evidenceless_fail() { + let (db, space, issue, root) = seed().await; + + // Unknown handle → rejected, no write. + let (_task, _t1) = seed_task_under_review(&db, space, issue, root, "tok-a").await; + let err = ingest( + &db.conn, + "tok-a", + "loop_submit_review", + &json!({"checks":[{"criterion":"R9.AC9","verdict":"pass","evidence":"x"}]}), + ) + .await + .unwrap_err(); + assert!(matches!(err, LoopError::InvalidInput(_))); + assert!( + loop_service::criterion_check::list_for_issue(&db.conn, issue).await.unwrap().is_empty(), + "rejected batch wrote no checks" + ); + + // Missing a required handle (empty checks for a non-empty injected set). + let (_t2, _) = seed_task_under_review(&db, space, issue, root, "tok-b").await; + let err = ingest(&db.conn, "tok-b", "loop_submit_review", &json!({"checks":[]})) + .await + .unwrap_err(); + assert!(matches!(err, LoopError::InvalidInput(_))); + + // Duplicate check for the same handle. + let (_t3, _) = seed_task_under_review(&db, space, issue, root, "tok-c").await; + let err = ingest( + &db.conn, + "tok-c", + "loop_submit_review", + &json!({"checks":[ + {"criterion":"T1","verdict":"pass","evidence":"a"}, + {"criterion":"T1","verdict":"pass","evidence":"b"} + ]}), + ) + .await + .unwrap_err(); + assert!(matches!(err, LoopError::InvalidInput(_))); + + // A fail with no evidence is rejected. + let (_t4, _) = seed_task_under_review(&db, space, issue, root, "tok-d").await; + let err = ingest( + &db.conn, + "tok-d", + "loop_submit_review", + &json!({"checks":[{"criterion":"T1","verdict":"fail","evidence":""}]}), + ) + .await + .unwrap_err(); + assert!(matches!(err, LoopError::InvalidInput(_))); + } + + #[tokio::test] + async fn review_resolves_against_persisted_manifest_not_current_state() { + // D10: ingest resolves handles against the manifest stashed at dispatch, + // so a requirement set that changes AFTER dispatch can't drift the handles. + let (db, space, issue, root) = seed().await; + let (task_id, t1) = seed_task_under_review(&db, space, issue, root, "tok-drift").await; + + // Simulate a concurrent refine adding a new requirement+criterion AFTER the + // review was dispatched (the live ordinals would now differ from T1). + let _ = running_iter(&db.conn, space, issue, Stage::Refine, Some(root), "tok-late").await; + ingest( + &db.conn, + "tok-late", + "loop_submit_artifacts", + &json!({"artifacts":[{"title":"R late","content":"x","criteria":["new ac"]}]}), + ) + .await + .unwrap(); + + // The reviewer still submits against the DISPATCHED handle T1 → resolves to + // the same criterion id the manifest froze. + ingest( + &db.conn, + "tok-drift", + "loop_submit_review", + &json!({"checks":[{"criterion":"T1","verdict":"pass","evidence":"ok"}]}), + ) + .await + .unwrap(); + let checks = loop_service::criterion_check::list_for_issue(&db.conn, issue) + .await + .unwrap(); + assert_eq!(checks.len(), 1); + assert_eq!(checks[0].criterion_id, t1, "resolved against the frozen manifest"); + assert_eq!(checks[0].scope_artifact_id, task_id); + } + + #[tokio::test] + async fn submit_tasks_with_deps_creates_depends_on_links() { + let (db, space, issue, root) = seed().await; + let _ = running_iter(&db.conn, space, issue, Stage::Plan, Some(root), "tok-plan").await; + let out = ingest( + &db.conn, + "tok-plan", + "loop_submit_artifacts", + &json!({"artifacts":[ + {"title":"T0","content":"first"}, + {"title":"T1","content":"second","depends_on":[0]} + ]}), + ) + .await + .unwrap(); + let ids = out["ids"].as_array().unwrap(); + assert_eq!(ids.len(), 2); + let t0 = ids[0].as_i64().unwrap() as i32; + let t1 = ids[1].as_i64().unwrap() as i32; + + let dag = loop_service::artifact::list_dag(&db.conn, issue).await.unwrap(); + // Edge contract: DependsOn from = successor (T1), to = predecessor (T0). + assert!(dag.links.iter().any(|l| matches!(l.kind, LinkKind::DependsOn) + && l.from_artifact_id == t1 + && l.to_artifact_id == t0)); + // The root task (T0) has no DependsOn edge of its own. + assert!(!dag + .links + .iter() + .any(|l| matches!(l.kind, LinkKind::DependsOn) && l.from_artifact_id == t0)); + } + + #[tokio::test] + async fn submit_tasks_rejects_cycle() { + let (db, space, issue, root) = seed().await; + let _ = running_iter(&db.conn, space, issue, Stage::Plan, Some(root), "tok-plan").await; + // Item 0 references index 1 (forward). Refs may only point backward, so + // cycles are impossible by construction — this is rejected. + let err = ingest( + &db.conn, + "tok-plan", + "loop_submit_artifacts", + &json!({"artifacts":[ + {"title":"A","content":"x","depends_on":[1]}, + {"title":"B","content":"y"} + ]}), + ) + .await + .unwrap_err(); + assert!(matches!(err, LoopError::InvalidInput(_))); + } + + #[tokio::test] + async fn submit_tasks_rejects_multi_predecessor() { + let (db, space, issue, root) = seed().await; + let _ = running_iter(&db.conn, space, issue, Stage::Plan, Some(root), "tok-plan").await; + let err = ingest( + &db.conn, + "tok-plan", + "loop_submit_artifacts", + &json!({"artifacts":[ + {"title":"A","content":"x"}, + {"title":"B","content":"y"}, + {"title":"C","content":"z","depends_on":[0,1]} + ]}), + ) + .await + .unwrap_err(); + assert!(matches!(err, LoopError::InvalidInput(_))); + // No partial write: up-front validation aborted before any task row. + let dag = loop_service::artifact::list_dag(&db.conn, issue).await.unwrap(); + assert_eq!( + dag.artifacts + .iter() + .filter(|a| matches!(a.kind, ArtifactKind::Task)) + .count(), + 0 + ); + } + + #[tokio::test] + async fn submit_tasks_rejects_out_of_range_dep() { + let (db, space, issue, root) = seed().await; + let _ = running_iter(&db.conn, space, issue, Stage::Plan, Some(root), "tok-plan").await; + // Batch-index refs can't name another issue's task (cross-issue deps are + // structurally impossible); the equivalent boundary guard rejects an + // index past the batch. + let err = ingest( + &db.conn, + "tok-plan", + "loop_submit_artifacts", + &json!({"artifacts":[{"title":"A","content":"x","depends_on":[5]}]}), + ) + .await + .unwrap_err(); + assert!(matches!(err, LoopError::InvalidInput(_))); + } + + // ---- P3 memory recall (loop_read_memory) ---- + + /// Stash a `{ "M{n}": id }` map under `memory_index` — what assemble_briefing + /// emits at dispatch. + async fn set_memory_index(conn: &DatabaseConnection, iter_id: i32, map: Value) { + loop_iteration::Entity::update_many() + .col_expr( + loop_iteration::Column::ContextManifest, + sea_orm::sea_query::Expr::value(json!({ "memory_index": map }).to_string()), + ) + .filter(loop_iteration::Column::Id.eq(iter_id)) + .exec(conn) + .await + .unwrap(); + } + + async fn mk_memory( + conn: &DatabaseConnection, + space_id: i32, + kind: MemoryKind, + title: &str, + summary: Option<&str>, + ) -> i32 { + loop_service::memory::create_memory( + conn, + space_id, + kind, + ActorKind::Agent, + title, + summary, + "full body", + TrustTier::Proposed, + loop_service::memory::MemoryProvenance::default(), + ) + .await + .unwrap() + .id + } + + async fn fetch_memory( + conn: &DatabaseConnection, + id: i32, + ) -> crate::db::entities::loop_memory::Model { + crate::db::entities::loop_memory::Entity::find_by_id(id) + .one(conn) + .await + .unwrap() + .expect("memory row") + } + + #[tokio::test] + async fn read_memory_returns_full_content_and_not_found_receipt() { + let (db, space, issue, root) = seed().await; + let m1 = + mk_memory(&db.conn, space, MemoryKind::Decision, "Token store", Some("use keyring")).await; + let m2 = mk_memory(&db.conn, space, MemoryKind::Pitfall, "Flaky test", None).await; + let iter = running_iter(&db.conn, space, issue, Stage::Refine, Some(root), "tok-read").await; + set_memory_index(&db.conn, iter, json!({ "M1": m1, "M2": m2 })).await; + + let before = fetch_memory(&db.conn, m1).await.updated_at; + let out = ingest(&db.conn, "tok-read", "loop_read_memory", &json!({"handles":["M1","M99"]})) + .await + .unwrap(); + let mems = out["memories"].as_array().unwrap(); + assert_eq!(mems.len(), 1); + assert_eq!(mems[0]["handle"], "M1"); + assert_eq!(mems[0]["title"], "Token store"); + assert_eq!(mems[0]["summary"], "use keyring"); + assert_eq!(mems[0]["content"], "full body"); + assert_eq!(mems[0]["trust"], "proposed"); + assert_eq!(mems[0]["kind"], "decision"); + // Provenance keys are always emitted (null here) — incl. source_artifact_id. + assert!(mems[0].as_object().unwrap().contains_key("source_artifact_id")); + assert_eq!(out["not_found"], json!(["M99"])); + // Pure read: M1's updated_at is untouched. + assert_eq!(fetch_memory(&db.conn, m1).await.updated_at, before); + } + + #[tokio::test] + async fn read_memory_empty_handles_is_error() { + let (db, space, issue, root) = seed().await; + let iter = running_iter(&db.conn, space, issue, Stage::Refine, Some(root), "tok-empty").await; + set_memory_index(&db.conn, iter, json!({})).await; + let err = ingest(&db.conn, "tok-empty", "loop_read_memory", &json!({"handles":[]})) + .await + .unwrap_err(); + assert!(matches!(err, LoopError::InvalidInput(_))); + } + + #[tokio::test] + async fn read_memory_without_index_returns_all_not_found() { + let (db, space, issue, root) = seed().await; + let _ = running_iter(&db.conn, space, issue, Stage::Refine, Some(root), "tok-noidx").await; + // No set_memory_index: the manifest carries no memory_index object. + let out = ingest(&db.conn, "tok-noidx", "loop_read_memory", &json!({"handles":["M1","M2"]})) + .await + .unwrap(); + assert!(out["memories"].as_array().unwrap().is_empty()); + assert_eq!(out["not_found"], json!(["M1", "M2"])); + } + + #[tokio::test] + async fn read_memory_superseded_since_dispatch_is_not_found() { + let (db, space, issue, root) = seed().await; + let m1 = mk_memory(&db.conn, space, MemoryKind::Decision, "Was active", None).await; + let iter = running_iter(&db.conn, space, issue, Stage::Refine, Some(root), "tok-gone").await; + set_memory_index(&db.conn, iter, json!({ "M1": m1 })).await; + // Left the recall path AFTER dispatch — the handle resolves but get_for_read + // returns no row, so it is reported (never silently dropped). + loop_service::memory::update_memory( + &db.conn, + m1, + "Was active", + "full body", + crate::db::entities::loop_memory::MemoryStatus::Superseded, + ) + .await + .unwrap(); + let out = ingest(&db.conn, "tok-gone", "loop_read_memory", &json!({"handles":["M1"]})) + .await + .unwrap(); + assert!(out["memories"].as_array().unwrap().is_empty()); + assert_eq!(out["not_found"], json!(["M1"])); + } + + #[tokio::test] + async fn read_memory_cross_space_handle_is_not_found() { + let (db, space, issue, root) = seed().await; + // A memory in a DIFFERENT space, forced into this iteration's manifest. + let other_folder = seed_folder(&db, "/repo-other").await; + let other = loop_service::space::create_space(&db.conn, "Other", other_folder) + .await + .unwrap(); + let foreign = mk_memory(&db.conn, other.id, MemoryKind::Decision, "Foreign", None).await; + let iter = + running_iter(&db.conn, space, issue, Stage::Refine, Some(root), "tok-xspace").await; + set_memory_index(&db.conn, iter, json!({ "M1": foreign })).await; + let out = ingest(&db.conn, "tok-xspace", "loop_read_memory", &json!({"handles":["M1"]})) + .await + .unwrap(); + // get_for_read re-scopes by it.space_id → no row → not_found. + assert!(out["memories"].as_array().unwrap().is_empty()); + assert_eq!(out["not_found"], json!(["M1"])); + } + + #[tokio::test] + async fn read_memory_non_string_handle_is_error() { + let (db, space, issue, root) = seed().await; + let m1 = mk_memory(&db.conn, space, MemoryKind::Decision, "M", None).await; + let iter = running_iter(&db.conn, space, issue, Stage::Refine, Some(root), "tok-bad").await; + set_memory_index(&db.conn, iter, json!({ "M1": m1 })).await; + // A non-string entry is a malformed call — rejected, never silently dropped. + let err = + ingest(&db.conn, "tok-bad", "loop_read_memory", &json!({"handles":["M1", 5, null]})) + .await + .unwrap_err(); + assert!(matches!(err, LoopError::InvalidInput(_))); + } + + #[tokio::test] + async fn read_memory_dedupes_repeated_handles() { + let (db, space, issue, root) = seed().await; + let m1 = mk_memory(&db.conn, space, MemoryKind::Decision, "Once", None).await; + let iter = running_iter(&db.conn, space, issue, Stage::Refine, Some(root), "tok-dup").await; + set_memory_index(&db.conn, iter, json!({ "M1": m1 })).await; + let out = ingest(&db.conn, "tok-dup", "loop_read_memory", &json!({"handles":["M1","M1"]})) + .await + .unwrap(); + // A repeated handle yields exactly one memory and no spurious not_found. + assert_eq!(out["memories"].as_array().unwrap().len(), 1); + assert!(out["not_found"].as_array().unwrap().is_empty()); + } + + #[tokio::test] + async fn record_memory_persists_one_line_summary_and_provenance() { + let (db, space, issue, root) = seed().await; + let iter = running_iter(&db.conn, space, issue, Stage::Refine, Some(root), "tok-rec").await; + let out = ingest( + &db.conn, + "tok-rec", + "loop_record_memory", + &json!({"kind":"decision","title":"T","summary":"line one\n line two","content":"body"}), + ) + .await + .unwrap(); + let id = out["id"].as_i64().unwrap() as i32; + let m = fetch_memory(&db.conn, id).await; + // Multiline summary collapsed to one line. + assert_eq!(m.summary.as_deref(), Some("line one line two")); + assert_eq!(m.trust_tier, TrustTier::Proposed); + assert_eq!(m.source_issue_id, Some(issue)); + assert_eq!(m.produced_by_iteration_id, Some(iter)); + assert_eq!(m.source_artifact_id, None); + } + + // ---- loop_submit_reflection (P4.2) ---- + + async fn count_reflections(conn: &DatabaseConnection, issue_id: i32) -> usize { + loop_artifact::Entity::find() + .filter(loop_artifact::Column::IssueId.eq(issue_id)) + .filter(loop_artifact::Column::Kind.eq(ArtifactKind::Reflection)) + .all(conn) + .await + .unwrap() + .len() + } + + async fn count_distilled(conn: &DatabaseConnection, space_id: i32) -> usize { + crate::db::entities::loop_memory::Entity::find() + .filter(crate::db::entities::loop_memory::Column::SpaceId.eq(space_id)) + .filter(crate::db::entities::loop_memory::Column::TrustTier.eq(TrustTier::Distilled)) + .all(conn) + .await + .unwrap() + .len() + } + + #[tokio::test] + async fn reflection_distills_memory_supersedes_and_links() { + let (db, space, issue, root) = seed().await; + let old = mk_memory(&db.conn, space, MemoryKind::Decision, "Old decision", Some("old")).await; + let iter = running_iter(&db.conn, space, issue, Stage::Reflect, None, "tok-refl").await; + set_memory_index(&db.conn, iter, json!({ "M1": old })).await; + + let out = ingest( + &db.conn, + "tok-refl", + "loop_submit_reflection", + &json!({ + "reflection": { "title": "Retro", "content": "It went well." }, + "memories": [{ + "kind": "procedural", "title": "Recipe", + "summary": "do X then Y", "content": "steps", + "supersedes": ["M1"] + }] + }), + ) + .await + .unwrap(); + assert_eq!(out["recorded"], json!(1)); + assert_eq!(out["superseded"], json!(["M1"])); + + // A reflection artifact exists, Done, derives_from the issue root (no result). + let refl = loop_artifact::Entity::find() + .filter(loop_artifact::Column::IssueId.eq(issue)) + .filter(loop_artifact::Column::Kind.eq(ArtifactKind::Reflection)) + .one(&db.conn) + .await + .unwrap() + .expect("reflection artifact"); + assert_eq!(refl.status, ArtifactStatus::Done); + let link = crate::db::entities::loop_link::Entity::find() + .filter(crate::db::entities::loop_link::Column::FromArtifactId.eq(refl.id)) + .one(&db.conn) + .await + .unwrap() + .expect("derives_from link"); + assert_eq!(link.to_artifact_id, root); + assert_eq!(link.kind, LinkKind::DerivesFrom); + + // The distilled memory carries the reflection as its source artifact. + let distilled = crate::db::entities::loop_memory::Entity::find() + .filter(crate::db::entities::loop_memory::Column::SpaceId.eq(space)) + .filter(crate::db::entities::loop_memory::Column::TrustTier.eq(TrustTier::Distilled)) + .one(&db.conn) + .await + .unwrap() + .expect("distilled memory"); + assert_eq!(distilled.kind, MemoryKind::Procedural); + assert_eq!(distilled.source_artifact_id, Some(refl.id)); + assert_eq!(distilled.source_issue_id, Some(issue)); + + // The old memory is superseded, pointing at the new one. + let old_row = fetch_memory(&db.conn, old).await; + assert_eq!( + old_row.status, + crate::db::entities::loop_memory::MemoryStatus::Superseded + ); + assert_eq!(old_row.superseded_by, Some(distilled.id)); + } + + #[tokio::test] + async fn reflection_replay_is_idempotent() { + let (db, space, issue, _root) = seed().await; + let old = mk_memory(&db.conn, space, MemoryKind::Decision, "Old", None).await; + let iter = running_iter(&db.conn, space, issue, Stage::Reflect, None, "tok-refl").await; + set_memory_index(&db.conn, iter, json!({ "M1": old })).await; + let payload = json!({ + "reflection": { "title": "Retro", "content": "ok" }, + "memories": [{ "kind": "episodic", "title": "E", "content": "c", "supersedes": ["M1"] }] + }); + let first = ingest(&db.conn, "tok-refl", "loop_submit_reflection", &payload) + .await + .unwrap(); + assert_eq!(first["recorded"], json!(1)); + let old_after_first = fetch_memory(&db.conn, old).await.updated_at; + + let second = ingest(&db.conn, "tok-refl", "loop_submit_reflection", &payload) + .await + .unwrap(); + assert_eq!(second["idempotent"], json!(true)); + // Exactly one reflection artifact; exactly one distilled memory. + assert_eq!(count_reflections(&db.conn, issue).await, 1); + assert_eq!(count_distilled(&db.conn, space).await, 1); + // The superseded memory was not double-touched on replay. + assert_eq!(fetch_memory(&db.conn, old).await.updated_at, old_after_first); + } + + #[tokio::test] + async fn reflection_without_index_and_no_supersede_succeeds() { + let (db, space, issue, _root) = seed().await; + let _ = running_iter(&db.conn, space, issue, Stage::Reflect, None, "tok-refl").await; + // No set_memory_index → injected_memory_index returns empty (not an error). + let out = ingest( + &db.conn, + "tok-refl", + "loop_submit_reflection", + &json!({ + "reflection": { "title": "Retro", "content": "ok" }, + "memories": [{ "kind": "episodic", "title": "E", "content": "c" }] + }), + ) + .await + .unwrap(); + assert_eq!(out["recorded"], json!(1)); + assert_eq!(out["superseded"], json!([])); + } + + #[tokio::test] + async fn reflection_unknown_supersede_handle_aborts() { + let (db, space, issue, _root) = seed().await; + let iter = running_iter(&db.conn, space, issue, Stage::Reflect, None, "tok-refl").await; + set_memory_index(&db.conn, iter, json!({})).await; + let err = ingest( + &db.conn, + "tok-refl", + "loop_submit_reflection", + &json!({ + "reflection": { "title": "R", "content": "c" }, + "memories": [{ "kind": "episodic", "title": "E", "content": "c", "supersedes": ["M9"] }] + }), + ) + .await + .unwrap_err(); + assert!(matches!(err, LoopError::InvalidInput(_))); + assert_eq!(count_reflections(&db.conn, issue).await, 0); // no partial write + } + + #[tokio::test] + async fn reflection_duplicate_supersede_target_aborts() { + let (db, space, issue, _root) = seed().await; + let old = mk_memory(&db.conn, space, MemoryKind::Decision, "Old", None).await; + let iter = running_iter(&db.conn, space, issue, Stage::Reflect, None, "tok-refl").await; + set_memory_index(&db.conn, iter, json!({ "M1": old })).await; + let err = ingest( + &db.conn, + "tok-refl", + "loop_submit_reflection", + &json!({ + "reflection": { "title": "R", "content": "c" }, + "memories": [ + { "kind": "episodic", "title": "A", "content": "c", "supersedes": ["M1"] }, + { "kind": "procedural", "title": "B", "content": "c", "supersedes": ["M1"] } + ] + }), + ) + .await + .unwrap_err(); + assert!(matches!(err, LoopError::InvalidInput(_))); + assert_eq!(count_reflections(&db.conn, issue).await, 0); + assert_eq!( + fetch_memory(&db.conn, old).await.status, + crate::db::entities::loop_memory::MemoryStatus::Active + ); + } + + #[tokio::test] + async fn reflection_constitution_kind_rejected() { + let (db, space, issue, _root) = seed().await; + let _ = running_iter(&db.conn, space, issue, Stage::Reflect, None, "tok-refl").await; + let err = ingest( + &db.conn, + "tok-refl", + "loop_submit_reflection", + &json!({ + "reflection": { "title": "R", "content": "c" }, + "memories": [{ "kind": "constitution", "title": "X", "content": "c" }] + }), + ) + .await + .unwrap_err(); + assert!(matches!(err, LoopError::InvalidInput(_))); + assert_eq!(count_reflections(&db.conn, issue).await, 0); + } + + #[tokio::test] + async fn reflection_wrong_stage_rejected() { + let (db, space, issue, root) = seed().await; + let _ = running_iter(&db.conn, space, issue, Stage::Refine, Some(root), "tok-refine").await; + let err = ingest( + &db.conn, + "tok-refine", + "loop_submit_reflection", + &json!({ "reflection": { "title": "R", "content": "c" } }), + ) + .await + .unwrap_err(); + assert!(matches!(err, LoopError::InvalidInput(_))); + } + + #[tokio::test] + async fn reflection_with_empty_memories_writes_artifact_only() { + let (db, space, issue, _root) = seed().await; + let _ = running_iter(&db.conn, space, issue, Stage::Reflect, None, "tok-refl").await; + let out = ingest( + &db.conn, + "tok-refl", + "loop_submit_reflection", + &json!({ "reflection": { "title": "Retro", "content": "nothing to record" }, "memories": [] }), + ) + .await + .unwrap(); + assert_eq!(out["recorded"], json!(0)); + assert_eq!(count_reflections(&db.conn, issue).await, 1); + assert_eq!(count_distilled(&db.conn, space).await, 0); + } +} diff --git a/src-tauri/src/loop_engine/metrics.rs b/src-tauri/src/loop_engine/metrics.rs new file mode 100644 index 0000000000..90352cde51 --- /dev/null +++ b/src-tauri/src/loop_engine/metrics.rs @@ -0,0 +1,45 @@ +//! Process-since-boot engine counters (§2.10b). Cheap relaxed atomics, +//! snapshotted for the health endpoint. Not persisted — they describe *this* +//! process. The authoritative "what's happening now" view (running issues, +//! in-flight iterations, live drivers) is DB- and registry-derived in +//! [`crate::loop_engine::health::LoopEngineHealth`]; these add since-boot context. +//! +//! Intentionally a small, cheaply-reachable subset: incremented only where the +//! engine `self`/`Arc` is already in scope, so no metrics handle is threaded +//! through the engine's free functions (settle/claim/breaker paths). The +//! operational signal that matters most lives in the live counts, not here. + +use std::sync::atomic::{AtomicU64, Ordering}; + +#[derive(Default)] +pub struct EngineMetrics { + /// Iterations settled via the turn-complete event path (the common path; the + /// rare reconcile-backstop settles are not counted here). + pub settle_events_total: AtomicU64, + /// Times the completion watcher fell behind the broadcast buffer and ran a + /// full in-flight reconcile sweep — i.e. a dropped-event recovery. + pub lag_sweep_total: AtomicU64, +} + +impl EngineMetrics { + /// Bump a counter (relaxed — these are monotonic tallies, not a sync point). + pub fn inc(counter: &AtomicU64) { + counter.fetch_add(1, Ordering::Relaxed); + } + + pub fn snapshot(&self) -> MetricsSnapshot { + MetricsSnapshot { + settle_events_total: self.settle_events_total.load(Ordering::Relaxed), + lag_sweep_total: self.lag_sweep_total.load(Ordering::Relaxed), + } + } +} + +/// Serializable view of [`EngineMetrics`] for the health endpoint (camelCase to +/// match the TS mirror). +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct MetricsSnapshot { + pub settle_events_total: u64, + pub lag_sweep_total: u64, +} diff --git a/src-tauri/src/loop_engine/mod.rs b/src-tauri/src/loop_engine/mod.rs new file mode 100644 index 0000000000..a10aa95f5c --- /dev/null +++ b/src-tauri/src/loop_engine/mod.rs @@ -0,0 +1,658 @@ +//! Loop engineering engine: drives each running issue through triage → refine → +//! design → plan → implement → verify → review → finalize, autonomously. +//! +//! The engine holds cheap clones of the shared runtime handles (database, +//! connection manager, event emitter) plus a per-issue driver registry. A +//! single instance lives per process — desktop manages it as Tauri state, the +//! web/server `AppState` reuses (or builds) the same `Arc` — so a +//! trigger from either entry point drives the same drivers. +//! +//! State is DB-authoritative (§4.1a dispatch leases); the in-process registry +//! below is only a single-instance guard, never the concurrency authority. + +use std::collections::HashMap; +use std::future::Future; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QuerySelect}; +use tokio::sync::{broadcast, Mutex, Notify}; +use tokio::task::AbortHandle; + +use crate::acp::internal_bus::InternalEventBus; +use crate::acp::manager::ConnectionManager; +use crate::acp::types::AcpEvent; +use crate::db::entities::loop_issue::{self, IssueStatus}; +use crate::db::entities::loop_iteration::{self, IterationStatus}; +use crate::db::AppDatabase; +use crate::web::event_bridge::EventEmitter; + +pub mod actions; +pub mod briefing; +pub mod config_resolver; +pub mod dispatch; +pub mod driver; +pub mod error; +pub mod fan_in; +pub mod gates; +pub mod health; +pub mod ingest; +pub mod metrics; +pub mod questions; +pub mod recovery; +pub mod transitions; +pub mod validation; +pub mod worktree; + +pub use error::LoopError; + +/// Registry entry for a running per-issue driver task. `abort` tears the task +/// down on stop/cancel; `wake` nudges it to re-tick without polling. +pub struct DriverHandle { + pub abort: AbortHandle, + pub wake: Arc, +} + +/// The loop engineering engine. Cheaply shareable via `Arc`; all fields are +/// either `Arc`-backed handles or cloned connection refs. +pub struct LoopEngine { + // Read by dispatch / driver / recovery / worktree. + db: AppDatabase, + manager: ConnectionManager, + data_dir: PathBuf, + emitter: EventEmitter, + /// Process-internal single-instance guard: at most one driver task per + /// issue. NOT the concurrency authority — that is the DB dispatch lease. + drivers: Mutex>, + /// Per-repo merge serialization: two issues that share a base repo must not + /// run their `--no-ff` landings concurrently (they would race on the base + /// branch ref and working tree). Keyed by repo path; entries are created on + /// demand and never removed (bounded by the number of distinct repos). + merge_locks: Mutex>>>, + /// Process-since-boot counters surfaced by the health endpoint (§2.10b). + metrics: Arc, +} + +impl LoopEngine { + pub fn new( + db: AppDatabase, + manager: ConnectionManager, + data_dir: PathBuf, + emitter: EventEmitter, + ) -> Arc { + Arc::new(Self { + db, + manager, + data_dir, + emitter, + drivers: Mutex::new(HashMap::new()), + merge_locks: Mutex::new(HashMap::new()), + metrics: Arc::new(metrics::EngineMetrics::default()), + }) + } + + /// The merge lock for `repo_path`, created on first use. Held across an + /// issue's entire merge so concurrent merges into the same repo serialize. + pub(crate) async fn repo_merge_lock(&self, repo_path: &Path) -> Arc> { + let mut locks = self.merge_locks.lock().await; + Arc::clone( + locks + .entry(repo_path.to_path_buf()) + .or_insert_with(|| Arc::new(Mutex::new(()))), + ) + } + + /// Ensure a live driver backs `issue_id`, holding the registry lock across the + /// whole check-evict-spawn-register so no concurrent path can interleave + /// (§2.5): a finished handle is evicted and replaced; a live one is left + /// untouched. The single atomic owner for both external triggers and the + /// periodic supervisor backstop — so a dead handle self-heals on *any* start + /// path, not only via the 15s supervisor. + pub async fn start_issue(self: &Arc, issue_id: i32) { + let mut drivers = self.drivers.lock().await; + match drivers.get(&issue_id) { + Some(h) if !h.abort.is_finished() => return, // live driver already on it + _ => {} // absent or finished → (re)spawn + } + self.spawn_driver_into(&mut drivers, issue_id); + } + + /// Spawn a per-issue driver task and register its handle in the held + /// `drivers` map. The caller MUST hold the `drivers` lock (passed in by + /// `&mut`), so the check-and-spawn stays atomic against a concurrent start. + fn spawn_driver_into(self: &Arc, drivers: &mut HashMap, issue_id: i32) { + let wake = Arc::new(Notify::new()); + let engine = Arc::clone(self); + let wake_for_task = Arc::clone(&wake); + let join = tokio::spawn(async move { + driver::run_driver(engine, issue_id, wake_for_task).await; + }); + drivers.insert( + issue_id, + DriverHandle { + abort: join.abort_handle(), + wake, + }, + ); + } + + /// Wake a running driver to re-tick after an iteration settles or a human + /// action lands. No-op when the issue has no driver. `notify_one` buffers a + /// permit, so a wake that races ahead of the driver's `notified().await` is + /// not lost. + pub async fn wake(&self, issue_id: i32) { + let drivers = self.drivers.lock().await; + if let Some(handle) = drivers.get(&issue_id) { + handle.wake.notify_one(); + } + } + + /// Stop a running driver and drop its registry entry. + pub async fn stop_issue(&self, issue_id: i32) { + let mut drivers = self.drivers.lock().await; + if let Some(handle) = drivers.remove(&issue_id) { + handle.abort.abort(); + } + } + + /// Remove a driver's registry entry. Called by the driver task itself when + /// it exits cleanly (issue left `running`); idempotent with `stop_issue`. + pub(crate) async fn deregister_driver(&self, issue_id: i32) { + self.drivers.lock().await.remove(&issue_id); + } + + /// On boot, reconcile interrupted iterations and restart a driver for every + /// still-`running` issue. Idempotent — safe on every process start, + /// including a clean boot with nothing in flight. Reconciliation (releasing + /// stale leases + restoring worktrees) is pure DB+git and lives in + /// [`recovery`]; this wrapper only restarts the drivers it identifies. + pub async fn recover_on_boot(self: &Arc) { + match recovery::reconcile_on_boot(&self.db).await { + Ok(running_ids) => { + for issue_id in running_ids { + self.start_issue(issue_id).await; + } + // Backstop for memory consolidation (§4.4/§5.5): a `Done` issue that + // never produced a reflection (crashed mid-reflect, or before the + // merge hook could dispatch) gets a bounded re-dispatch here. + // Idempotent + best-effort via the reflection-artifact anchor. + self.recover_pending_reflections().await; + } + Err(e) => tracing::error!(error = %e, "recover_on_boot failed"), + } + } + + /// After boot reconcile released interrupted leases, re-dispatch reflect for any + /// `Done` issue that never consolidated. Best-effort + bounded inside + /// [`LoopEngine::dispatch_reflect_best_effort`]; idempotent via the + /// reflection-artifact anchor (D12), so a re-run never double-distills. + async fn recover_pending_reflections(self: &Arc) { + let done = match loop_issue::Entity::find() + .filter(loop_issue::Column::Status.eq(IssueStatus::Done)) + .all(&self.db.conn) + .await + { + Ok(rows) => rows, + Err(e) => { + tracing::warn!(error = %e, "recover: listing done issues failed"); + return; + } + }; + for issue in done { + self.dispatch_reflect_best_effort(&issue).await; + } + } + + /// Self-healing backstop: ensure every `running` issue has a live driver, + /// respawning any whose task is missing or has died (a per-issue driver that + /// panics out of its loop vanishes, but the issue stays `running` in the DB — + /// without this it would silently never advance again until the next boot). + /// Idempotent: `start_issue` is the single-instance guard, so this is safe to + /// call on a schedule. A finished-but-still-registered handle is dropped under + /// the same lock that observes it, so a concurrent legitimate (re)start can + /// never have its live handle evicted here. + pub async fn supervise_drivers(self: &Arc) { + let running = match loop_issue::Entity::find() + .filter(loop_issue::Column::Status.eq(IssueStatus::Running)) + .all(&self.db.conn) + .await + { + Ok(rows) => rows, + Err(e) => { + tracing::warn!(error = %e, "supervisor: listing running issues failed"); + return; + } + }; + for issue in running { + // Same single-lock ensure as the trigger path: check-evict-spawn under + // one acquisition, so a finished handle is replaced and a live one is + // never evicted out from under a concurrent legitimate start. + let mut drivers = self.drivers.lock().await; + match drivers.get(&issue.id) { + Some(h) if !h.abort.is_finished() => {} // a live driver is already on it + _ => { + tracing::warn!( + issue_id = issue.id, + "supervisor: (re)spawning driver for running issue" + ); + self.spawn_driver_into(&mut drivers, issue.id); + } + } + } + } + + /// The periodic supervisor loop, spawned once at boot alongside the completion + /// watcher (same `subscribe-before-spawn` pattern is unneeded — it is a poll, + /// not an event subscriber). The 15s cadence is a heartbeat for a rare failure + /// (driver panic), not a cap on work. The first tick fires immediately, which + /// is a harmless idempotent pass right after `recover_on_boot`. + pub fn supervisor_task(self: &Arc) -> impl Future + Send + 'static { + let engine = Arc::clone(self); + async move { + let mut tick = tokio::time::interval(std::time::Duration::from_secs(15)); + tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { + tick.tick().await; + engine.supervise_drivers().await; + } + } + } + + /// Reconcile every issue that currently has an in-flight iteration. Called + /// when the completion watcher detects it dropped events (broadcast lag): a + /// precise, immediate backstop so a missed `TurnComplete` still settles + /// promptly instead of waiting for the next per-issue heartbeat. Idempotent + /// (settles are CAS). + pub(crate) async fn reconcile_all_inflight(self: &Arc) { + metrics::EngineMetrics::inc(&self.metrics.lag_sweep_total); + let issue_ids = match loop_iteration::Entity::find() + .filter(loop_iteration::Column::Status.eq(IterationStatus::Running)) + .select_only() + .column(loop_iteration::Column::IssueId) + .distinct() + .into_tuple::() + .all(&self.db.conn) + .await + { + Ok(ids) => ids, + Err(e) => { + tracing::warn!(error = %e, "lag sweep: listing in-flight issues failed"); + return; + } + }; + for issue_id in issue_ids { + if let Err(e) = + driver::reconcile_orphaned_iterations(&self.db, &self.emitter, &self.manager, issue_id) + .await + { + tracing::warn!(issue_id, error = %e, "lag sweep: reconcile failed"); + } + } + } + + /// Live engine health (§2.10b): DB-authoritative issue/iteration counts, the + /// in-process driver-registry size, and this process's since-boot counters. + pub async fn engine_health(&self) -> Result { + let conn = &self.db.conn; + let running_issues = loop_issue::Entity::find() + .filter(loop_issue::Column::Status.eq(IssueStatus::Running)) + .count(conn) + .await? as u64; + let in_flight_iterations = loop_iteration::Entity::find() + .filter( + loop_iteration::Column::Status + .is_in([IterationStatus::Queued, IterationStatus::Running]), + ) + .count(conn) + .await? as u64; + let pending_token_iterations = loop_iteration::Entity::find() + .filter(loop_iteration::Column::TokensPending.eq(true)) + .count(conn) + .await? as u64; + let active_drivers = self.drivers.lock().await.len() as u64; + Ok(health::LoopEngineHealth { + running_issues, + in_flight_iterations, + pending_token_iterations, + active_drivers, + metrics: self.metrics.snapshot(), + }) + } + + /// Subscribe to the in-process event bus synchronously and return the + /// completion-watcher loop future; the caller spawns it with the + /// mode-appropriate spawner (`tauri::async_runtime::spawn` from the desktop + /// `setup` hook, which runs outside any tokio runtime, or `tokio::spawn` + /// under the server runtime). This is the engine's completion-awareness: a + /// separate, additive bus subscriber (it never touches the delegation + /// lifecycle path), settling + waking loop iterations as their turns + /// complete, reacting only to loop conversations. + /// + /// `subscribe()` runs here, before the future is returned, so a + /// `TurnComplete` emitted between this call and the first poll is buffered + /// by the broadcast channel rather than dropped (subscribe-before-spawn). + pub fn completion_watcher_task( + self: &Arc, + bus: Arc, + ) -> impl Future + Send + 'static { + let engine = Arc::clone(self); + let mut rx = bus.subscribe(); + async move { + loop { + match rx.recv().await { + Ok(envelope) => match &envelope.payload { + AcpEvent::TurnComplete { .. } => { + engine.on_turn_complete(&envelope.connection_id).await; + } + // A loop iteration's agent asked the operator a question: + // surface it as a `question` inbox card. Cleared on the + // matching QuestionResolved. + AcpEvent::QuestionRequest { + question_id, + questions, + } => { + engine + .on_question_request( + &envelope.connection_id, + question_id, + questions, + ) + .await; + } + AcpEvent::QuestionResolved { question_id } => { + engine.on_question_resolved(question_id).await; + } + _ => {} + }, + // Dropped `n` in-process events — a `TurnComplete` may be + // among them. Run an immediate, precise reconcile of all + // in-flight iterations so settlement stays timely without + // waiting for the per-issue heartbeat cadence (§2.1). + Err(broadcast::error::RecvError::Lagged(n)) => { + tracing::warn!( + dropped = n, + "completion watcher lagged; running in-flight reconcile sweep" + ); + engine.reconcile_all_inflight().await; + } + Err(broadcast::error::RecvError::Closed) => break, + } + } + } + } + + /// Settle the loop iteration backing a just-completed connection's turn, + /// then wake its issue driver to advance the DAG. No-op for any connection + /// that isn't a running loop iteration (e.g. ordinary or delegation turns). + pub async fn on_turn_complete(self: &Arc, connection_id: &str) { + // Resolve the conversation backing this connection (in-memory, same as + // the delegation lifecycle path). + let Some((state, _)) = self.manager.get_state_and_emitter(connection_id).await else { + // Connection state already gone (e.g. teardown raced this event); the + // driver's periodic reconcile will settle the iteration instead. + tracing::debug!(connection_id, "turn complete: no connection state (already torn down?)"); + return; + }; + let conversation_id = state.read().await.conversation_id; + let Some(cid) = conversation_id else { + tracing::debug!(connection_id, "turn complete: connection has no conversation_id"); + return; + }; + // DB-authoritative: is this conversation a running loop iteration? + let iter = match loop_iteration::Entity::find() + .filter(loop_iteration::Column::ConversationId.eq(cid)) + .filter(loop_iteration::Column::Status.eq(IterationStatus::Running)) + .one(&self.db.conn) + .await + { + Ok(Some(it)) => it, + // Not a running loop iteration (ordinary/delegation turn, or already + // settled by the reconcile) — nothing to do. + Ok(None) => return, + Err(e) => { + tracing::warn!(error = %e, "on_turn_complete: iteration lookup failed"); + return; + } + }; + if let Err(e) = self.settle_iteration(iter.id).await { + tracing::warn!(iteration_id = iter.id, error = %e, "settle iteration failed"); + } else { + metrics::EngineMetrics::inc(&self.metrics.settle_events_total); + tracing::debug!( + iteration_id = iter.id, + issue_id = iter.issue_id, + "settled iteration on turn complete" + ); + } + self.wake(iter.issue_id).await; + // Uptime self-retry (§5.3): a reflect turn just settled. Re-evaluate + // consolidation — if it produced no reflection artifact and is still under + // max_attempts, dispatch_reflect_best_effort re-dispatches (bounded); if an + // artifact exists, it is a no-op. This self-heals a failed reflect during + // uptime; boot recovery is the crash backstop. + if iter.stage == loop_iteration::Stage::Reflect { + if let Ok(Some(issue)) = loop_issue::Entity::find_by_id(iter.issue_id) + .one(&self.db.conn) + .await + { + self.dispatch_reflect_best_effort(&issue).await; + } + } + } + + /// Run the §4.3 seven-step dispatch for a single frontier decision. Returns + /// `Ok(None)` when the dispatch lease was already held (lost the race). The + /// driver (Task 1.6) chooses the [`dispatch::DispatchInput`]; this just + /// executes it against the live connection manager. + pub async fn dispatch_iteration( + &self, + input: dispatch::DispatchInput, + ) -> Result, LoopError> { + dispatch::dispatch_iteration( + &self.db, + &self.data_dir, + &self.manager, + self.emitter.clone(), + input, + ) + .await + } + + /// §4.9 settlement for a finished iteration (token accounting + success CAS + /// + no-progress signal). + pub async fn settle_iteration( + &self, + iteration_id: i32, + ) -> Result { + dispatch::settle_iteration(&self.db, &self.emitter, iteration_id).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::entities::loop_issue::IssuePriority; + use crate::db::service::loop_service::{issue, space}; + use crate::db::test_helpers::{fresh_in_memory_db, seed_folder}; + use crate::loop_engine::transitions::cas_issue_status; + use crate::models::loops::IssueConfig; + + #[tokio::test] + async fn supervisor_respawns_dead_driver_for_running_issue() { + let db = fresh_in_memory_db().await; + let folder_id = seed_folder(&db, "/tmp/loop-supervisor").await; + let space = space::create_space(&db.conn, "S", folder_id).await.unwrap(); + let issue = issue::create_issue( + &db.conn, + space.id, + "I", + "b", + IssuePriority::Medium, + Some(&IssueConfig::default()), + ) + .await + .unwrap(); + cas_issue_status(&db.conn, issue.row.id, IssueStatus::Pending, IssueStatus::Running) + .await + .unwrap(); + let engine = LoopEngine::new( + db, + ConnectionManager::new(), + std::path::PathBuf::from("/tmp/loop-supervisor-data"), + EventEmitter::Noop, + ); + + // No driver registered — simulates one that panicked out / never started. + assert!(engine.drivers.lock().await.get(&issue.row.id).is_none()); + + engine.supervise_drivers().await; + + // A live driver now backs the still-running issue (it idles harmlessly: + // the issue has no worktree folder in this test, so the tick parks). + let drivers = engine.drivers.lock().await; + let handle = drivers.get(&issue.row.id).expect("driver respawned"); + assert!(!handle.abort.is_finished(), "respawned driver is live"); + } + + #[tokio::test] + async fn supervisor_leaves_live_driver_untouched() { + let db = fresh_in_memory_db().await; + let folder_id = seed_folder(&db, "/tmp/loop-supervisor2").await; + let space = space::create_space(&db.conn, "S", folder_id).await.unwrap(); + let issue = issue::create_issue( + &db.conn, + space.id, + "I", + "b", + IssuePriority::Medium, + Some(&IssueConfig::default()), + ) + .await + .unwrap(); + cas_issue_status(&db.conn, issue.row.id, IssueStatus::Pending, IssueStatus::Running) + .await + .unwrap(); + let engine = LoopEngine::new( + db, + ConnectionManager::new(), + std::path::PathBuf::from("/tmp/loop-supervisor2-data"), + EventEmitter::Noop, + ); + + engine.start_issue(issue.row.id).await; + let first = engine + .drivers + .lock() + .await + .get(&issue.row.id) + .map(|h| h.abort.id()); + + // A second supervision pass must not replace the existing live driver. + engine.supervise_drivers().await; + let second = engine + .drivers + .lock() + .await + .get(&issue.row.id) + .map(|h| h.abort.id()); + assert_eq!(first, second, "live driver is not respawned"); + } + + #[tokio::test] + async fn start_issue_replaces_a_finished_handle() { + let db = fresh_in_memory_db().await; + let folder_id = seed_folder(&db, "/tmp/loop-ensure").await; + let space = space::create_space(&db.conn, "S", folder_id).await.unwrap(); + let issue = issue::create_issue( + &db.conn, + space.id, + "I", + "b", + IssuePriority::Medium, + Some(&IssueConfig::default()), + ) + .await + .unwrap(); + cas_issue_status(&db.conn, issue.row.id, IssueStatus::Pending, IssueStatus::Running) + .await + .unwrap(); + let engine = LoopEngine::new( + db, + ConnectionManager::new(), + std::path::PathBuf::from("/tmp/loop-ensure-data"), + EventEmitter::Noop, + ); + + // Register a *finished* handle (simulates a driver that returned/panicked + // but whose registry entry was never evicted). + let done = tokio::spawn(async {}); + let abort = done.abort_handle(); + done.await.unwrap(); + assert!(abort.is_finished()); + engine.drivers.lock().await.insert( + issue.row.id, + DriverHandle { + abort, + wake: Arc::new(Notify::new()), + }, + ); + + // A direct start must evict the finished handle and spawn a live one (the + // old `contains_key` guard would have no-opped on the stale entry). + engine.start_issue(issue.row.id).await; + let drivers = engine.drivers.lock().await; + let handle = drivers.get(&issue.row.id).expect("driver present"); + assert!( + !handle.abort.is_finished(), + "finished handle replaced by a live driver" + ); + } + + #[tokio::test] + async fn engine_health_reports_live_counts() { + let db = fresh_in_memory_db().await; + let folder_id = seed_folder(&db, "/tmp/loop-health").await; + let space = space::create_space(&db.conn, "S", folder_id).await.unwrap(); + let issue = issue::create_issue( + &db.conn, + space.id, + "I", + "b", + IssuePriority::Medium, + Some(&IssueConfig::default()), + ) + .await + .unwrap(); + cas_issue_status(&db.conn, issue.row.id, IssueStatus::Pending, IssueStatus::Running) + .await + .unwrap(); + // One queued iteration → in-flight count of 1. + crate::loop_engine::transitions::try_claim_iteration( + &db.conn, + crate::loop_engine::transitions::IterationClaim { + space_id: space.id, + issue_id: issue.row.id, + stage: crate::db::entities::loop_iteration::Stage::Triage, + target_artifact_id: None, + slot_no: None, + capability_token: "t".into(), + attempt: 0, + }, + ) + .await + .unwrap() + .unwrap(); + let engine = LoopEngine::new( + db, + ConnectionManager::new(), + std::path::PathBuf::from("/tmp/loop-health-data"), + EventEmitter::Noop, + ); + let health = engine.engine_health().await.unwrap(); + assert_eq!(health.running_issues, 1); + assert_eq!(health.in_flight_iterations, 1); + assert_eq!(health.pending_token_iterations, 0); + // No driver was started in this test, so the registry is empty. + assert_eq!(health.active_drivers, 0); + } +} diff --git a/src-tauri/src/loop_engine/questions.rs b/src-tauri/src/loop_engine/questions.rs new file mode 100644 index 0000000000..c2aa82afc1 --- /dev/null +++ b/src-tauri/src/loop_engine/questions.rs @@ -0,0 +1,328 @@ +//! Routing of a loop iteration's `ask_user_question` into the space inbox. +//! +//! A loop iteration runs an agent that can call the `ask_user_question` MCP tool +//! like any other session (the loop launch always exposes it — see +//! `inject_codeg_mcp`). The existing question machinery already parks the tool +//! call and broadcasts a `QuestionRequest` / `QuestionResolved` pair on the +//! in-process event bus. The engine's bus subscriber +//! ([`crate::loop_engine::LoopEngine::completion_watcher_task`]) reacts to that +//! pair here — writing a `question` inbox card when an iteration raises a +//! question, clearing it when the question is answered or canceled — so a person +//! can discover the blocked iteration from the space inbox and open it to +//! answer. +//! +//! This never touches the question/answer path itself: the answer still flows +//! through the normal `answer_question` route on the iteration's connection. The +//! card is purely a discovery surface, and (like the completion watcher) this is +//! an *additive* bus subscriber — it never modifies the ACP lifecycle. + +use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; + +use crate::acp::question::QuestionSpec; +use crate::db::entities::loop_inbox_item::{self, InboxKind, InboxStatus}; +use crate::db::entities::loop_issue; +use crate::db::entities::loop_iteration::{self, IterationStatus}; +use crate::db::service::loop_service::inbox; +use crate::loop_engine::driver::resolve_agent; +use crate::models::agent::AgentType; + +use super::LoopEngine; + +impl LoopEngine { + /// A session's agent called `ask_user_question`. When `connection_id` backs a + /// running loop iteration, file a `question` inbox card so the space inbox + /// surfaces it (deduped on the question id); otherwise ignore — ordinary and + /// delegation turns are not loop iterations and keep their existing flow. + pub async fn on_question_request( + &self, + connection_id: &str, + question_id: &str, + questions: &[QuestionSpec], + ) { + // Resolve the conversation backing this connection (in-memory), then ask + // the DB whether it is a running loop iteration — same gate as + // `on_turn_complete`. + let Some((state, _)) = self.manager.get_state_and_emitter(connection_id).await else { + return; + }; + let conversation_id = state.read().await.conversation_id; + let Some(cid) = conversation_id else { + return; + }; + let iter = match loop_iteration::Entity::find() + .filter(loop_iteration::Column::ConversationId.eq(cid)) + .filter(loop_iteration::Column::Status.eq(IterationStatus::Running)) + .one(&self.db.conn) + .await + { + Ok(Some(it)) => it, + Ok(None) => return, + Err(e) => { + tracing::warn!(error = %e, "on_question_request: iteration lookup failed"); + return; + } + }; + // The agent that owns this iteration's session, so the inbox can open a + // viewer with the right rendering before the transcript loads. + let agent_type = match loop_issue::Entity::find_by_id(iter.issue_id) + .one(&self.db.conn) + .await + { + Ok(Some(issue)) => { + // Display-hint only (which agent's renderer the inbox opens). A + // broken config degrades the hint, not the question routing. + match crate::loop_engine::config_resolver::effective_config(&self.db.conn, &issue) + .await + { + Ok(config) => resolve_agent(&config, iter.stage), + Err(_) => AgentType::ClaudeCode, + } + } + _ => AgentType::ClaudeCode, + }; + // The payload carries everything the inbox needs to open the iteration + // viewer and re-render the live question, without a second round-trip. + let payload = serde_json::json!({ + "question_id": question_id, + "questions": questions, + "connection_id": connection_id, + "conversation_id": cid, + "agent_type": agent_type, + }); + let subject = format!("question:{question_id}"); + if let Err(e) = inbox::upsert_inbox( + &self.db.conn, + iter.space_id, + iter.issue_id, + Some(iter.id), + InboxKind::Question, + &subject, + payload, + ) + .await + { + tracing::warn!(error = %e, "on_question_request: upsert_inbox failed"); + return; + } + self.emit_changed(iter.space_id, iter.issue_id, "question_raised"); + } + + /// A question was answered (from the iteration viewer or any client) or + /// canceled (tool call aborted / connection drained). Clear its inbox card if + /// one is still pending. Idempotent: `question_id` is a UUID, so the + /// `question:{id}` subject matches at most one pending card. + pub async fn on_question_resolved(&self, question_id: &str) { + let subject = format!("question:{question_id}"); + let card = match loop_inbox_item::Entity::find() + .filter(loop_inbox_item::Column::SubjectKey.eq(&subject)) + .filter(loop_inbox_item::Column::Kind.eq(InboxKind::Question)) + .filter(loop_inbox_item::Column::Status.eq(InboxStatus::Pending)) + .one(&self.db.conn) + .await + { + Ok(Some(c)) => c, + Ok(None) => return, + Err(e) => { + tracing::warn!(error = %e, "on_question_resolved: lookup failed"); + return; + } + }; + if let Err(e) = inbox::handle_inbox( + &self.db.conn, + card.id, + serde_json::json!({ "action": "answered" }), + ) + .await + { + tracing::warn!(error = %e, "on_question_resolved: handle_inbox failed"); + return; + } + self.emit_changed(card.space_id, card.issue_id, "question_resolved"); + } +} + +#[cfg(test)] +mod tests { + // `super::*` re-exports the module's own imports (inbox, loop_iteration, + // loop_issue, IterationStatus, InboxKind/Status, AgentType, IssueConfig, + // QuestionSpec, the sea_orm traits); the test adds only what's unique to it. + use super::*; + use crate::models::loops::IssueConfig; + use std::sync::Arc; + + use sea_orm::sea_query::Expr; + + use crate::acp::manager::ConnectionManager; + use crate::acp::question::QuestionOption; + use crate::db::entities::loop_issue::{IssuePriority, IssueStatus}; + use crate::db::entities::loop_iteration::Stage; + use crate::db::service::loop_service::{issue, space}; + use crate::db::test_helpers::{fresh_in_memory_db, seed_folder}; + use crate::loop_engine::transitions::{ + cas_issue_status, cas_iteration_status, try_claim_iteration, IterationClaim, + }; + use crate::web::event_bridge::EventEmitter; + + fn q_spec() -> QuestionSpec { + QuestionSpec { + id: "q-0".into(), + question: "Which approach?".into(), + header: "Approach".into(), + multi_select: false, + options: vec![ + QuestionOption { + label: "A".into(), + description: String::new(), + }, + QuestionOption { + label: "B".into(), + description: String::new(), + }, + ], + } + } + + /// Stand up an engine + a running loop iteration whose `conversation_id` is + /// bound to a live agent connection, mirroring the dispatch wiring. Returns + /// the engine, the DB conn, space id, issue id, and the iteration's + /// connection id. + async fn setup_running_iteration() -> ( + Arc, + sea_orm::DatabaseConnection, + i32, + i32, + String, + ) { + let db = fresh_in_memory_db().await; + let conn = db.conn.clone(); + let folder_id = seed_folder(&db, "/tmp/loop-questions").await; + let space = space::create_space(&conn, "S", folder_id).await.unwrap(); + let issue = issue::create_issue( + &conn, + space.id, + "I", + "b", + IssuePriority::Medium, + Some(&IssueConfig::default()), + ) + .await + .unwrap(); + cas_issue_status(&conn, issue.row.id, IssueStatus::Pending, IssueStatus::Running) + .await + .unwrap(); + let engine = LoopEngine::new( + db, + ConnectionManager::new(), + std::path::PathBuf::from("/tmp/loop-questions-data"), + EventEmitter::Noop, + ); + + // A running iteration with a conversation id. + let iter = try_claim_iteration( + &conn, + IterationClaim { + space_id: space.id, + issue_id: issue.row.id, + stage: Stage::Triage, + target_artifact_id: None, + slot_no: None, + capability_token: "cap".into(), + attempt: 0, + }, + ) + .await + .unwrap() + .unwrap(); + cas_iteration_status(&conn, iter.id, IterationStatus::Queued, IterationStatus::Running) + .await + .unwrap(); + let convo = 7777; + loop_iteration::Entity::update_many() + .col_expr(loop_iteration::Column::ConversationId, Expr::value(convo)) + .filter(loop_iteration::Column::Id.eq(iter.id)) + .exec(&conn) + .await + .unwrap(); + + // A live agent connection whose session is bound to that conversation. + let conn_id = "iter-conn".to_string(); + engine + .manager + .insert_test_connection(&conn_id, AgentType::ClaudeCode, None, EventEmitter::Noop) + .await; + engine + .manager + .get_state(&conn_id) + .await + .unwrap() + .write() + .await + .conversation_id = Some(convo); + + (engine, conn, space.id, issue.row.id, conn_id) + } + + #[tokio::test] + async fn question_request_files_a_card_then_resolved_clears_it() { + let (engine, conn, space_id, _issue_id, conn_id) = setup_running_iteration().await; + + engine + .on_question_request(&conn_id, "qid-1", &[q_spec()]) + .await; + + let pending = inbox::list_inbox(&conn, space_id, Some(InboxStatus::Pending)) + .await + .unwrap(); + let card = pending + .iter() + .find(|c| c.kind == InboxKind::Question) + .expect("a question card was filed"); + assert_eq!(card.subject_key, "question:qid-1"); + assert_eq!( + card.payload["agent_type"], "claude_code", + "payload carries the iteration's agent so the viewer renders right" + ); + assert_eq!(card.payload["connection_id"], "iter-conn"); + assert_eq!(card.payload["conversation_id"], 7777); + + // Resolving the question clears the card. + engine.on_question_resolved("qid-1").await; + let still_pending = inbox::list_inbox(&conn, space_id, Some(InboxStatus::Pending)) + .await + .unwrap(); + assert!( + !still_pending.iter().any(|c| c.kind == InboxKind::Question), + "the question card is handled once the question resolves" + ); + } + + #[tokio::test] + async fn question_from_a_non_loop_connection_is_ignored() { + let (engine, conn, space_id, _issue_id, _conn_id) = setup_running_iteration().await; + // A connection bound to no loop iteration (different conversation). + engine + .manager + .insert_test_connection("plain-conn", AgentType::ClaudeCode, None, EventEmitter::Noop) + .await; + engine + .manager + .get_state("plain-conn") + .await + .unwrap() + .write() + .await + .conversation_id = Some(9999); + + engine + .on_question_request("plain-conn", "qid-x", &[q_spec()]) + .await; + + let pending = inbox::list_inbox(&conn, space_id, Some(InboxStatus::Pending)) + .await + .unwrap(); + assert!( + !pending.iter().any(|c| c.kind == InboxKind::Question), + "no card for a question raised by a non-loop session" + ); + } +} diff --git a/src-tauri/src/loop_engine/recovery.rs b/src-tauri/src/loop_engine/recovery.rs new file mode 100644 index 0000000000..9ccfbdb748 --- /dev/null +++ b/src-tauri/src/loop_engine/recovery.rs @@ -0,0 +1,434 @@ +//! Crash recovery: idempotent boot-time reconciliation (§4.10). +//! +//! A fresh process has no live ACP connections, so any loop iteration still in +//! an active (`queued`/`running`) status is an *interruption* — its agent is +//! gone and its turn will never complete. Recovery marks each such iteration +//! `interrupted`, which releases its §4.1a dispatch lease (the partial unique +//! indexes are predicated on `status IN ('queued','running')`). This is a pure +//! status change: `attempt` is never bumped (a pure interruption resume is not a +//! rework), and an interrupted iteration is never faked as succeeded. +//! +//! Idempotency falls out of the driver's frontier, not iteration-level dedup: +//! the frontier keys off DAG state (which artifacts exist), so a re-tick simply +//! skips any stage whose output already landed via MCP — already-persisted +//! artifacts are never produced twice. The interrupted iteration keeps its +//! backing conversation as an audit record of the partial run; it is harmless +//! (hidden by the `kind=loop` sidebar guard) and never reused. +//! +//! For every issue still `running`, recovery also restores the clean-tree +//! invariant (`reset --hard HEAD && clean -fd`), discarding only uncommitted +//! side-effects left by an interrupted implement — never rewinding a committed +//! checkpoint. In the M2.1 read pipeline the worktree is always clean, so this +//! is a no-op there; it becomes load-bearing once implement lands in M2.2. + +use std::path::Path; + +use chrono::Utc; +use sea_orm::sea_query::Expr; +use sea_orm::{ActiveEnum, ColumnTrait, EntityTrait, QueryFilter}; + +use crate::db::entities::loop_issue::{self, IssueStatus}; +use crate::db::entities::loop_iteration::{self, IterationStatus}; +use crate::db::service::{folder_service, loop_service}; +use crate::db::AppDatabase; + +use crate::loop_engine::error::LoopError; +use crate::loop_engine::worktree; + +/// Reconcile interrupted iterations and restore running issues' worktrees. +/// Returns the ids of issues still `running`, whose drivers the engine must +/// restart. Pure over DB + git (no `ConnectionManager`), so it is unit-tested +/// directly; restarting drivers from the returned ids is the thin part left to +/// the caller ([`super::LoopEngine::recover_on_boot`]). +pub(crate) async fn reconcile_on_boot(db: &AppDatabase) -> Result, LoopError> { + let conn = &db.conn; + + // 1. Release stale leases: every active iteration is interrupted (no live + // connection survives a restart). A pure status change — `attempt` is not + // bumped, and the run is never faked as succeeded. + loop_iteration::Entity::update_many() + .col_expr( + loop_iteration::Column::Status, + Expr::value(IterationStatus::Interrupted.to_value()), + ) + .col_expr(loop_iteration::Column::EndedAt, Expr::value(Utc::now())) + // D11: an interrupted-by-restart iteration is `abandoned` — but COALESCE + // preserves any outcome already recorded, so the write-once invariant + // holds at the write itself (not merely via the active-status filter): + // a row that crashed after settling but before its status moved keeps its + // real outcome (Codex r1). + .col_expr( + loop_iteration::Column::Outcome, + Expr::col(loop_iteration::Column::Outcome) + .if_null(loop_iteration::IterationOutcome::Abandoned.to_value()), + ) + .filter( + loop_iteration::Column::Status + .is_in([IterationStatus::Queued, IterationStatus::Running]), + ) + .exec(conn) + .await?; + + // 2. Running issues: restore the clean-tree invariant, then hand their ids + // back so the caller can restart drivers. + let running = loop_issue::Entity::find() + .filter(loop_issue::Column::Status.eq(IssueStatus::Running)) + .all(conn) + .await?; + + for issue in &running { + restore_worktree_clean(db, issue).await; + } + Ok(running.iter().map(|i| i.id).collect()) +} + +/// Discard any uncommitted side-effects in an issue's worktree, returning it to +/// its branch HEAD (the latest accepted checkpoint). No-op when the issue has no +/// on-disk worktree. Best-effort: a git failure is logged, not fatal — the +/// driver still restarts, and a tree it can't clean surfaces later as a +/// no-progress signal rather than blocking boot. +async fn restore_worktree_clean(db: &AppDatabase, issue: &loop_issue::Model) { + let Some(folder_id) = issue.worktree_folder_id else { + return; + }; + let folder = match folder_service::get_folder_by_id(&db.conn, folder_id).await { + Ok(Some(f)) => f, + Ok(None) => return, + Err(e) => { + tracing::warn!(folder_id, error = %e, "recover: worktree folder lookup failed"); + return; + } + }; + // The space repo — needed to enumerate a parallel issue's per-task / + // integrate worktrees (resolved best-effort; absence just skips the subtree). + let repo_path = match loop_service::space::get_space(&db.conn, issue.space_id).await { + Ok(Some(space)) => folder_service::get_folder_by_id(&db.conn, space.folder_id) + .await + .ok() + .flatten() + .map(|repo| repo.path), + _ => None, + }; + + let issue_wt = Path::new(&folder.path); + if issue_wt.exists() { + if let Err(e) = worktree::reset_to_head(issue_wt).await { + tracing::warn!(path = %folder.path, error = %e, "recover: reset worktree failed"); + } + } + // Parallel issues also have per-task / integrate worktrees to restore (discard + // crash-time uncommitted residue; committed task checkpoints survive). + if let Some(repo) = repo_path { + let _ = worktree::reset_issue_subtree(Path::new(&repo), issue_wt).await; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::entities::loop_artifact::{ArtifactKind, ArtifactStatus}; + use crate::db::entities::loop_artifact_revision::ActorKind; + use crate::db::entities::loop_issue::IssuePriority; + use crate::db::entities::loop_iteration::Stage; + use crate::db::service::loop_service::{artifact, issue, space}; + use crate::db::test_helpers::{fresh_disk_db, fresh_in_memory_db, seed_folder}; + use crate::loop_engine::transitions::{ + cas_iteration_status, try_claim_iteration, IterationClaim, + }; + use crate::loop_engine::worktree::{checkpoint, ensure_worktree}; + use crate::models::loops::IssueConfig; + use std::process::Command as StdCommand; + + /// Mark an issue `running` (the trigger precondition recovery keys off). + async fn set_running(db: &AppDatabase, issue_id: i32) { + loop_issue::Entity::update_many() + .col_expr( + loop_issue::Column::Status, + Expr::value(IssueStatus::Running.to_value()), + ) + .filter(loop_issue::Column::Id.eq(issue_id)) + .exec(&db.conn) + .await + .unwrap(); + } + + async fn set_status(db: &AppDatabase, issue_id: i32, status: IssueStatus) { + loop_issue::Entity::update_many() + .col_expr(loop_issue::Column::Status, Expr::value(status.to_value())) + .filter(loop_issue::Column::Id.eq(issue_id)) + .exec(&db.conn) + .await + .unwrap(); + } + + /// Claim a lease and (optionally) flip it to `running`, returning the row. + async fn claim( + db: &AppDatabase, + space_id: i32, + issue_id: i32, + stage: Stage, + target: Option, + attempt: i32, + run: bool, + ) -> loop_iteration::Model { + let iter = try_claim_iteration( + &db.conn, + IterationClaim { + space_id, + issue_id, + stage, + target_artifact_id: target, + slot_no: None, + capability_token: format!("tok-{issue_id}-{stage:?}-{attempt}"), + attempt, + }, + ) + .await + .unwrap() + .expect("lease claimed"); + if run { + cas_iteration_status( + &db.conn, + iter.id, + IterationStatus::Queued, + IterationStatus::Running, + ) + .await + .unwrap(); + } + iter + } + + async fn get_iter(db: &AppDatabase, id: i32) -> loop_iteration::Model { + loop_iteration::Entity::find_by_id(id) + .one(&db.conn) + .await + .unwrap() + .unwrap() + } + + async fn seed_issue(db: &AppDatabase, folder_id: i32) -> (i32, i32) { + let space = space::create_space(&db.conn, "S", folder_id).await.unwrap(); + let issue = issue::create_issue( + &db.conn, + space.id, + "Issue", + "body", + IssuePriority::Medium, + Some(&IssueConfig::default()), + ) + .await + .unwrap(); + (space.id, issue.row.id) + } + + #[tokio::test] + async fn interrupts_active_iterations_without_bumping_attempt() { + let db = fresh_in_memory_db().await; + let folder_id = seed_folder(&db, "/tmp/recover-a").await; + let (space_id, issue_id) = seed_issue(&db, folder_id).await; + set_running(&db, issue_id).await; // worktree_folder_id stays None → reset is a no-op + + let task = artifact::create_artifact( + &db.conn, + space_id, + issue_id, + ArtifactKind::Task, + "T", + ArtifactStatus::Pending, + ActorKind::Agent, + None, + ) + .await + .unwrap(); + // A queued triage (claimed, never ran) and a running implement (attempt 3). + let queued = claim(&db, space_id, issue_id, Stage::Triage, None, 0, false).await; + let running = claim(&db, space_id, issue_id, Stage::Implement, Some(task.id), 3, true).await; + + let restart = reconcile_on_boot(&db).await.unwrap(); + assert_eq!(restart, vec![issue_id], "the running issue is queued for restart"); + + let q = get_iter(&db, queued.id).await; + let r = get_iter(&db, running.id).await; + assert_eq!(q.status, IterationStatus::Interrupted); + assert_eq!(r.status, IterationStatus::Interrupted); + assert!(q.ended_at.is_some() && r.ended_at.is_some(), "abandonment stamped"); + assert_eq!(r.attempt, 3, "pure interruption never bumps attempt"); + } + + #[tokio::test] + async fn releases_lease_so_redispatch_can_reclaim() { + let db = fresh_in_memory_db().await; + let folder_id = seed_folder(&db, "/tmp/recover-b").await; + let (space_id, issue_id) = seed_issue(&db, folder_id).await; + set_running(&db, issue_id).await; + + let task = artifact::create_artifact( + &db.conn, + space_id, + issue_id, + ArtifactKind::Task, + "T", + ArtifactStatus::Pending, + ActorKind::Agent, + None, + ) + .await + .unwrap(); + claim(&db, space_id, issue_id, Stage::Implement, Some(task.id), 0, true).await; + + // While the lease is held, a second implement of the SAME task is leased + // out by `uniq_active_node(target, stage)`. + let blocked = try_claim_iteration( + &db.conn, + IterationClaim { + space_id, + issue_id, + stage: Stage::Implement, + target_artifact_id: Some(task.id), + slot_no: None, + capability_token: "blocked".into(), + attempt: 1, + }, + ) + .await + .unwrap(); + assert!(blocked.is_none(), "uniq_active_node holds before recovery"); + + reconcile_on_boot(&db).await.unwrap(); + + // The lease is now free: a fresh dispatch can reclaim it. + let reclaimed = try_claim_iteration( + &db.conn, + IterationClaim { + space_id, + issue_id, + stage: Stage::Implement, + target_artifact_id: Some(task.id), + slot_no: None, + capability_token: "reclaim".into(), + attempt: 1, + }, + ) + .await + .unwrap(); + assert!(reclaimed.is_some(), "interruption released the dispatch lease"); + } + + #[tokio::test] + async fn restart_list_holds_only_running_issues_but_all_leases_release() { + let db = fresh_in_memory_db().await; + let folder_id = seed_folder(&db, "/tmp/recover-c").await; + let (space_id, running_issue) = seed_issue(&db, folder_id).await; + set_running(&db, running_issue).await; + // A paused issue with its own in-flight iteration (e.g. crashed mid-pause). + let paused_issue = issue::create_issue( + &db.conn, + space_id, + "Paused", + "body", + IssuePriority::Medium, + Some(&IssueConfig::default()), + ) + .await + .unwrap() + .row + .id; + set_status(&db, paused_issue, IssueStatus::Paused).await; + + let on_running = claim(&db, space_id, running_issue, Stage::Triage, None, 0, true).await; + let on_paused = claim(&db, space_id, paused_issue, Stage::Triage, None, 0, true).await; + + let restart = reconcile_on_boot(&db).await.unwrap(); + assert_eq!(restart, vec![running_issue], "only running issues restart"); + + // Both iterations are reconciled regardless of issue status — a dead + // connection's lease must release even for a paused issue. + assert_eq!( + get_iter(&db, on_running.id).await.status, + IterationStatus::Interrupted + ); + assert_eq!( + get_iter(&db, on_paused.id).await.status, + IterationStatus::Interrupted + ); + } + + #[tokio::test] + async fn idempotent_and_clean_boot_is_noop() { + let db = fresh_in_memory_db().await; + let folder_id = seed_folder(&db, "/tmp/recover-d").await; + let (_space_id, issue_id) = seed_issue(&db, folder_id).await; + set_running(&db, issue_id).await; + + // No active iterations: a clean boot still lists the running issue, twice, + // without error (idempotent). + assert_eq!(reconcile_on_boot(&db).await.unwrap(), vec![issue_id]); + assert_eq!(reconcile_on_boot(&db).await.unwrap(), vec![issue_id]); + } + + fn git(dir: &Path, args: &[&str]) { + let st = StdCommand::new("git") + .args(args) + .current_dir(dir) + .status() + .expect("spawn git"); + assert!(st.success(), "git {args:?} failed"); + } + + fn init_repo(dir: &Path) { + git(dir, &["init", "-q"]); + git(dir, &["config", "user.email", "t@example.com"]); + git(dir, &["config", "user.name", "tester"]); + std::fs::write(dir.join("README.md"), "hello\n").unwrap(); + git(dir, &["add", "-A"]); + git(dir, &["commit", "-q", "-m", "init"]); + } + + #[tokio::test] + async fn resets_dirty_worktree_to_head_on_recovery() { + let repo = tempfile::tempdir().unwrap(); + init_repo(repo.path()); + let data = tempfile::tempdir().unwrap(); + let db = fresh_disk_db(data.path()).await; + let folder_id = seed_folder(&db, &repo.path().to_string_lossy()).await; + let (space_id, issue_id) = seed_issue(&db, folder_id).await; + + // Trigger: bind the worktree (ensure_worktree records it on the issue), + // then mark the issue running and leave an interrupted iteration behind. + let ctx = ensure_worktree(&db.conn, data.path(), issue_id) + .await + .unwrap(); + set_running(&db, issue_id).await; + let iter = claim(&db, space_id, issue_id, Stage::Implement, None, 0, true).await; + + // Accept one checkpoint, then leave the tree dirty (as a crashed implement + // would): a modified tracked file plus an untracked scratch file. + std::fs::write(ctx.worktree_path.join("kept.txt"), "keep\n").unwrap(); + checkpoint(&ctx.worktree_path, "loop: keep") + .await + .unwrap() + .expect("committed"); + std::fs::write(ctx.worktree_path.join("kept.txt"), "dirty\n").unwrap(); + std::fs::write(ctx.worktree_path.join("scratch.txt"), "temp\n").unwrap(); + + let restart = reconcile_on_boot(&db).await.unwrap(); + assert_eq!(restart, vec![issue_id]); + + // The iteration is interrupted and the tree is back at the checkpoint: + // committed work kept, uncommitted side-effects discarded. + assert_eq!( + get_iter(&db, iter.id).await.status, + IterationStatus::Interrupted + ); + assert_eq!( + std::fs::read_to_string(ctx.worktree_path.join("kept.txt")).unwrap(), + "keep\n", + "committed checkpoint preserved" + ); + assert!( + !ctx.worktree_path.join("scratch.txt").exists(), + "uncommitted side-effect discarded" + ); + } +} diff --git a/src-tauri/src/loop_engine/transitions.rs b/src-tauri/src/loop_engine/transitions.rs new file mode 100644 index 0000000000..76f5e7afc1 --- /dev/null +++ b/src-tauri/src/loop_engine/transitions.rs @@ -0,0 +1,943 @@ +//! The single funnel for loop state changes: compare-and-swap status +//! transitions and the durable dispatch leases (partial unique indexes enforce +//! one active finalize per issue, one active iteration per (target, stage) +//! excluding review, and N review slots per task). All concurrency safety +//! bottoms out here, not in the in-memory driver registry. + +use chrono::Utc; +use sea_orm::sea_query::Expr; +use sea_orm::{ + ActiveEnum, ActiveModelTrait, ColumnTrait, ConnectionTrait, DatabaseConnection, DbBackend, + EntityTrait, QueryFilter, Set, SqlErr, Statement, +}; + +use crate::db::entities::loop_artifact::{self, ArtifactStatus}; +use crate::db::entities::loop_issue::{self, IssueStatus}; +use crate::db::entities::loop_iteration::{self, IterationStatus, LaunchedBy, Stage}; +use crate::loop_engine::error::LoopError; + +/// A SQLite UNIQUE-constraint failure — a dispatch lease was already held by a +/// concurrent claimer. Classified through SeaORM's driver-typed `sql_err()` +/// (`SqlErr::UniqueConstraintViolation`) rather than matching the message text, +/// which silently breaks when a driver reworded its error. `pub(crate)` so the +/// reflect ingest path can classify the `uniq_reflection_per_issue` race as an +/// idempotent replay (P4/D12). +pub(crate) fn is_unique_violation(e: &sea_orm::DbErr) -> bool { + matches!(e.sql_err(), Some(SqlErr::UniqueConstraintViolation(_))) +} + +/// Single source of truth for legal status edges across the three loop state +/// machines (§2.8). Defense-in-depth: the `cas_*_status` helpers assert the +/// (expected → new) pair is a legal *edge* before issuing the conditional +/// UPDATE, so a stray CAS with a nonsense pair surfaces as +/// [`LoopError::IllegalTransition`] instead of silently corrupting the pipeline. +/// This is a *static* check on the pair, independent of the row's live value +/// (that race is the CAS miss → [`LoopError::Conflict`]). Bulk recovery +/// transitions (`recovery.rs` interrupting many rows at once) intentionally +/// bypass this — they are a documented mass `update_many`, not a per-row CAS. +pub(crate) fn is_legal_issue(from: IssueStatus, to: IssueStatus) -> bool { + use IssueStatus::*; + matches!( + (from, to), + (Pending, Running) + | (Running, Paused) + | (Paused, Running) + | (Running, Blocked) + | (Blocked, Running) + | (Running, Done) + | (Pending, Cancelled) + | (Running, Cancelled) + | (Paused, Cancelled) + | (Blocked, Cancelled) + ) +} + +pub(crate) fn is_legal_iteration(from: IterationStatus, to: IterationStatus) -> bool { + use IterationStatus::*; + matches!( + (from, to), + (Queued, Running) + | (Running, Succeeded) + | (Running, Failed) + | (Queued, Failed) + | (Queued, Interrupted) + | (Running, Interrupted) + | (Queued, Cancelled) + | (Running, Cancelled) + ) +} + +pub(crate) fn is_legal_artifact(from: ArtifactStatus, to: ArtifactStatus) -> bool { + use ArtifactStatus::*; + matches!( + (from, to), + (Pending, InProgress) + | (InProgress, Done) + | (AwaitingApproval, Done) + | (Pending, Blocked) + | (InProgress, Blocked) + // Review-rejected retry sends an in-progress task back to pending so + // it can be re-implemented at the next attempt (gates.rs). + | (InProgress, Pending) + | (AwaitingApproval, Superseded) + | (AwaitingApproval, Cancelled) + | (Done, Superseded) + // Coverage loop-back supersedes still-`pending` plan tasks to replan + // (driver.rs `maybe_coverage_loopback`). + | (Pending, Superseded) + | (Blocked, InProgress) + | (Blocked, Pending) + // D15: human force-complete of a blocked, empty-diff task marks it Done + // (as a no-op) so the issue can finish — the only path to Done from + // Blocked, gated by `force_complete_task`'s cause guard. + | (Blocked, Done) + | (Pending, Cancelled) + | (InProgress, Cancelled) + ) +} + +/// CAS an issue's status: write `new` only if it currently equals `expected`. +/// Returns `true` on success, `false` on a miss (the caller maps that to +/// [`LoopError::Conflict`]). +pub async fn cas_issue_status( + conn: &impl ConnectionTrait, + id: i32, + expected: IssueStatus, + new: IssueStatus, +) -> Result { + // `IssueStatus` is `Clone` (not `Copy`, unlike its sibling enums), so clone + // for the legality probe and keep the originals for the filter/update below. + if !is_legal_issue(expected.clone(), new.clone()) { + return Err(LoopError::IllegalTransition); + } + let res = loop_issue::Entity::update_many() + .col_expr(loop_issue::Column::Status, Expr::value(new.to_value())) + .col_expr(loop_issue::Column::UpdatedAt, Expr::value(Utc::now())) + .filter(loop_issue::Column::Id.eq(id)) + .filter(loop_issue::Column::Status.eq(expected)) + .exec(conn) + .await?; + Ok(res.rows_affected == 1) +} + +/// D13/r4: re-park a wedged issue in ONE atomic statement — flip running→blocked +/// iff the FRESH DB state shows it genuinely cannot progress: no in-flight +/// iteration, no task the driver could pick up (none `pending`/`in_progress`), AND +/// at least one `blocked` task (the wedge a human exit must reach). The +/// EXISTS / NOT EXISTS are evaluated atomically WITH the status flip, so a +/// concurrent exit that re-armed a task (→ pending) or completed the last blocked +/// one makes the WHERE false and the UPDATE a no-op — closing the TOCTOU a +/// read-then-CAS would open. Returns whether it re-parked. +/// +/// Raw SQL because the query builder can't express `EXISTS` subqueries in an +/// `UPDATE … WHERE`. The status string literals match the entity +/// `DeriveActiveEnum` values pinned by the `m20260613_000001` CHECK constraints +/// (verified); the re-park DB test exercises every predicate branch. +pub async fn cas_issue_repark_if_wedged( + conn: &impl ConnectionTrait, + issue_id: i32, +) -> Result { + let sql = r#" + UPDATE loop_issue SET status = 'blocked', updated_at = ?1 + WHERE id = ?2 AND status = 'running' + AND EXISTS (SELECT 1 FROM loop_artifact + WHERE issue_id = ?2 AND kind = 'task' AND status = 'blocked') + AND NOT EXISTS (SELECT 1 FROM loop_artifact + WHERE issue_id = ?2 AND kind = 'task' + AND status IN ('pending','in_progress')) + AND NOT EXISTS (SELECT 1 FROM loop_iteration + WHERE issue_id = ?2 AND status IN ('queued','running')) + "#; + let res = conn + .execute(Statement::from_sql_and_values( + DbBackend::Sqlite, + sql, + [Utc::now().into(), issue_id.into()], + )) + .await?; + Ok(res.rows_affected() == 1) +} + +/// CAS an artifact's status. +pub async fn cas_artifact_status( + conn: &DatabaseConnection, + id: i32, + expected: ArtifactStatus, + new: ArtifactStatus, +) -> Result { + if !is_legal_artifact(expected, new) { + return Err(LoopError::IllegalTransition); + } + let res = loop_artifact::Entity::update_many() + .col_expr(loop_artifact::Column::Status, Expr::value(new.to_value())) + .col_expr(loop_artifact::Column::UpdatedAt, Expr::value(Utc::now())) + .filter(loop_artifact::Column::Id.eq(id)) + .filter(loop_artifact::Column::Status.eq(expected)) + .exec(conn) + .await?; + Ok(res.rows_affected == 1) +} + +/// CAS an artifact's status from any of several legal predecessors to `to` +/// (§2.4). Each `(from, to)` pair must be legal ([`is_legal_artifact`]), else +/// [`LoopError::IllegalTransition`]. Used where a node is reached from more than +/// one state (e.g. blocking a task that may be `pending` or `in_progress`), +/// making the previously-blind write an explicit bounded CAS. +pub async fn cas_artifact_status_from( + conn: &DatabaseConnection, + id: i32, + from: &[ArtifactStatus], + to: ArtifactStatus, +) -> Result { + if from.iter().any(|f| !is_legal_artifact(*f, to)) { + return Err(LoopError::IllegalTransition); + } + let res = loop_artifact::Entity::update_many() + .col_expr(loop_artifact::Column::Status, Expr::value(to.to_value())) + .col_expr(loop_artifact::Column::UpdatedAt, Expr::value(Utc::now())) + .filter(loop_artifact::Column::Id.eq(id)) + .filter(loop_artifact::Column::Status.is_in(from.iter().copied())) + .exec(conn) + .await?; + Ok(res.rows_affected == 1) +} + +/// A task's accepted contribution, coupling the kind to its commit so the +/// invariant `no_op ⇔ fan_in_commit IS NULL` is unrepresentable to violate +/// (Codex r1 — no `(Delta, None)` / `(NoOp, Some)` pairs). `Delta` always carries +/// its frozen commit; `NoOp` never does. +pub enum TaskContribution { + /// A real diff, frozen at this commit SHA. + Delta(String), + /// Agent-declared / force-completed no-op — no commit beyond the base. + NoOp, +} + +/// D12: atomically mark a task `Done` with its contribution in a single CAS +/// (`status='done', contribution_kind=?, fan_in_commit=? WHERE id=? AND +/// status='in_progress'`). The [`TaskContribution`] enum couples kind+commit, so +/// the write always upholds `no_op ⇔ fan_in_commit IS NULL`. Also upholds **a Done +/// task is observed atomically** (no "Done but unfrozen" window the parallel fan-in +/// could trip over). Returns whether the CAS applied (a miss means the task was no +/// longer `in_progress` — a stale snapshot, not an error). +pub async fn cas_task_done_with_contribution( + conn: &DatabaseConnection, + task_id: i32, + contribution: TaskContribution, +) -> Result { + let (kind, commit) = match &contribution { + TaskContribution::Delta(sha) => { + (loop_artifact::ContributionKind::Delta, Some(sha.clone())) + } + TaskContribution::NoOp => (loop_artifact::ContributionKind::NoOp, None), + }; + let res = loop_artifact::Entity::update_many() + .col_expr( + loop_artifact::Column::Status, + Expr::value(ArtifactStatus::Done.to_value()), + ) + .col_expr( + loop_artifact::Column::ContributionKind, + Expr::value(kind.to_value()), + ) + .col_expr(loop_artifact::Column::FanInCommit, Expr::value(commit)) + .col_expr(loop_artifact::Column::UpdatedAt, Expr::value(Utc::now())) + .filter(loop_artifact::Column::Id.eq(task_id)) + .filter(loop_artifact::Column::Status.eq(ArtifactStatus::InProgress)) + .exec(conn) + .await?; + Ok(res.rows_affected == 1) +} + +/// D15: human force-complete of a blocked task → mark it Done as a no-op +/// (`contribution_kind='no_op'`, `fan_in_commit=NULL`) iff it is still `Blocked`. +/// Goes through the legality gate (`(Blocked, Done)` is the human-exit edge) and +/// returns whether the CAS applied (a miss = no longer blocked → caller Conflicts). +/// The fan-in treats it like any agent-declared no-op (skipped, provenance-linked). +pub async fn cas_task_force_done_no_op( + conn: &impl ConnectionTrait, + task_id: i32, +) -> Result { + if !is_legal_artifact(ArtifactStatus::Blocked, ArtifactStatus::Done) { + return Err(LoopError::IllegalTransition); + } + let res = loop_artifact::Entity::update_many() + .col_expr( + loop_artifact::Column::Status, + Expr::value(ArtifactStatus::Done.to_value()), + ) + .col_expr( + loop_artifact::Column::ContributionKind, + Expr::value(loop_artifact::ContributionKind::NoOp.to_value()), + ) + .col_expr( + loop_artifact::Column::FanInCommit, + Expr::value(Option::::None), + ) + .col_expr(loop_artifact::Column::UpdatedAt, Expr::value(Utc::now())) + .filter(loop_artifact::Column::Id.eq(task_id)) + .filter(loop_artifact::Column::Status.eq(ArtifactStatus::Blocked)) + .exec(conn) + .await?; + Ok(res.rows_affected == 1) +} + +/// D14: step a task's `block_sig`-keyed oscillation epoch. Reads the current +/// epoch, then increments (same sig as the last block — a deterministic repeat) or +/// resets to 1 (a new sig — a genuinely different failure), and writes the new +/// `(count, sig)`. Returns the new count; the caller decides promotion at the +/// limit. Call ONLY when a genuine NEW block lands (the task CAS actually applied), +/// so an idempotent replay never inflates the count. +pub async fn step_oscillation( + conn: &impl ConnectionTrait, + task_id: i32, + block_sig: &str, +) -> Result { + let cur = loop_artifact::Entity::find_by_id(task_id).one(conn).await?; + let (same, prev) = match cur { + Some(m) => ( + m.recent_failure_sig.as_deref() == Some(block_sig), + m.oscillation_count, + ), + None => (false, 0), + }; + let next = if same { prev + 1 } else { 1 }; + loop_artifact::Entity::update_many() + .col_expr(loop_artifact::Column::OscillationCount, Expr::value(next)) + .col_expr( + loop_artifact::Column::RecentFailureSig, + Expr::value(block_sig.to_string()), + ) + .filter(loop_artifact::Column::Id.eq(task_id)) + .exec(conn) + .await?; + Ok(next) +} + +/// D14: clear a task's oscillation epoch counters on real forward progress +/// (validation pass / task done) or a human override. Idempotent. Takes +/// `&impl ConnectionTrait` so it runs both directly and inside the exit-action +/// transactions (C10). +pub async fn clear_oscillation( + conn: &impl ConnectionTrait, + task_id: i32, +) -> Result<(), LoopError> { + loop_artifact::Entity::update_many() + .col_expr(loop_artifact::Column::OscillationCount, Expr::value(0)) + .col_expr( + loop_artifact::Column::RecentFailureSig, + Expr::value(Option::::None), + ) + .filter(loop_artifact::Column::Id.eq(task_id)) + .exec(conn) + .await?; + Ok(()) +} + +/// Claim the parallel fan-in **session lock** by writing the manifest exactly +/// once: `UPDATE loop_issue SET fan_in_manifest=? WHERE id=? AND fan_in_manifest +/// IS NULL`. Returns whether this caller won (rows==1). A versioned, write-once +/// session token — distinct from the `uniq_active_finalize` agent lease (one +/// guards the integration *session*, the other a single in-flight agent). +pub async fn try_claim_fan_in( + conn: &DatabaseConnection, + issue_id: i32, + manifest_json: &str, +) -> Result { + let res = loop_issue::Entity::update_many() + .col_expr( + loop_issue::Column::FanInManifest, + Expr::value(manifest_json.to_string()), + ) + .col_expr(loop_issue::Column::UpdatedAt, Expr::value(Utc::now())) + .filter(loop_issue::Column::Id.eq(issue_id)) + .filter(loop_issue::Column::FanInManifest.is_null()) + .exec(conn) + .await?; + Ok(res.rows_affected == 1) +} + +/// Clear the fan-in session lock (`fan_in_manifest`/`fan_in_resolver_tip → NULL`) +/// once the session has landed or been abandoned, so a future re-trigger can claim +/// a fresh one. CAS-guarded on the exact manifest we owned (`WHERE +/// fan_in_manifest=?`): a stale driver replaying an old manifest must NOT erase a +/// newer session another driver has since claimed — a miss is benign (nothing to do). +pub async fn clear_fan_in( + conn: &DatabaseConnection, + issue_id: i32, + expected_manifest: &str, +) -> Result<(), LoopError> { + loop_issue::Entity::update_many() + .col_expr( + loop_issue::Column::FanInManifest, + Expr::value(Option::::None), + ) + .col_expr( + loop_issue::Column::FanInResolverTip, + Expr::value(Option::::None), + ) + .col_expr(loop_issue::Column::UpdatedAt, Expr::value(Utc::now())) + .filter(loop_issue::Column::Id.eq(issue_id)) + .filter(loop_issue::Column::FanInManifest.eq(expected_manifest)) + .exec(conn) + .await?; + Ok(()) +} + +/// Record the integrate-worktree tip at which a fan-in conflict resolver is being +/// dispatched (see `loop_issue.fan_in_resolver_tip`). Idempotent overwrite within +/// a session; cleared by [`clear_fan_in`]. +pub async fn set_fan_in_resolver_tip( + conn: &DatabaseConnection, + issue_id: i32, + tip: &str, +) -> Result<(), LoopError> { + loop_issue::Entity::update_many() + .col_expr( + loop_issue::Column::FanInResolverTip, + Expr::value(tip.to_string()), + ) + .col_expr(loop_issue::Column::UpdatedAt, Expr::value(Utc::now())) + .filter(loop_issue::Column::Id.eq(issue_id)) + .exec(conn) + .await?; + Ok(()) +} + +/// Inputs for a dispatch claim. `conversation_id` is intentionally absent — the +/// lease row is inserted first (conversation attached afterwards by the winner). +pub struct IterationClaim { + pub space_id: i32, + pub issue_id: i32, + pub stage: Stage, + pub target_artifact_id: Option, + pub slot_no: Option, + pub capability_token: String, + pub attempt: i32, +} + +/// Attempt to claim a dispatch lease by inserting a `queued` iteration row. The +/// partial unique indexes make this the atomic gate: a lost race surfaces as a +/// UNIQUE violation, returned here as `Ok(None)` (not an error) so the driver +/// simply skips. The winner gets `Ok(Some(row))`. +pub async fn try_claim_iteration( + conn: &DatabaseConnection, + claim: IterationClaim, +) -> Result, LoopError> { + let now = Utc::now(); + let active = loop_iteration::ActiveModel { + space_id: Set(claim.space_id), + issue_id: Set(claim.issue_id), + stage: Set(claim.stage), + target_artifact_id: Set(claim.target_artifact_id), + slot_no: Set(claim.slot_no), + conversation_id: Set(None), + capability_token: Set(claim.capability_token), + status: Set(IterationStatus::Queued), + launched_by: Set(LaunchedBy::Engine), + attempt: Set(claim.attempt), + tokens_used: Set(0), + tokens_pending: Set(false), + context_manifest: Set(None), + created_at: Set(now), + started_at: Set(None), + ended_at: Set(None), + ..Default::default() + }; + match active.insert(conn).await { + Ok(model) => Ok(Some(model)), + Err(e) if is_unique_violation(&e) => Ok(None), + Err(e) => Err(e.into()), + } +} + +/// CAS an iteration's status. +pub async fn cas_iteration_status( + conn: &DatabaseConnection, + id: i32, + expected: IterationStatus, + new: IterationStatus, +) -> Result { + if !is_legal_iteration(expected, new) { + return Err(LoopError::IllegalTransition); + } + let res = loop_iteration::Entity::update_many() + .col_expr(loop_iteration::Column::Status, Expr::value(new.to_value())) + .filter(loop_iteration::Column::Id.eq(id)) + .filter(loop_iteration::Column::Status.eq(expected)) + .exec(conn) + .await?; + Ok(res.rows_affected == 1) +} + +/// Fail an iteration from whichever active state it holds in a single UPDATE: +/// `status IN ('queued','running') → 'failed'`, stamping `ended_at`. Atomic +/// (§2.6) — replaces the old two sequential CAS that could wedge a row in +/// `running` if the process died between them. Returns whether a row changed. +pub async fn fail_iteration_active(conn: &DatabaseConnection, id: i32) -> Result { + let res = loop_iteration::Entity::update_many() + .col_expr( + loop_iteration::Column::Status, + Expr::value(IterationStatus::Failed.to_value()), + ) + .col_expr(loop_iteration::Column::EndedAt, Expr::value(Utc::now())) + // D11: a failed-before-settling iteration is `abandoned` — COALESCE keeps + // any real outcome already recorded, so the write-once invariant holds at + // the write itself, not merely via the active-status filter (Codex r1). + .col_expr( + loop_iteration::Column::Outcome, + Expr::col(loop_iteration::Column::Outcome) + .if_null(loop_iteration::IterationOutcome::Abandoned.to_value()), + ) + .filter(loop_iteration::Column::Id.eq(id)) + .filter( + loop_iteration::Column::Status + .is_in([IterationStatus::Queued, IterationStatus::Running]), + ) + .exec(conn) + .await?; + Ok(res.rows_affected == 1) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::entities::loop_artifact::{ArtifactKind, ArtifactStatus}; + use crate::db::entities::loop_artifact_revision::ActorKind; + use crate::db::entities::loop_issue::IssuePriority; + use crate::db::service::loop_service::{artifact, issue, iteration, space}; + use crate::db::test_helpers::{fresh_in_memory_db, seed_folder}; + use crate::models::loops::IssueConfig; + + async fn seed() -> (crate::db::AppDatabase, i32, i32) { + let db = fresh_in_memory_db().await; + let folder_id = seed_folder(&db, "/tmp/trans").await; + let space = space::create_space(&db.conn, "S", folder_id).await.unwrap(); + let issue = issue::create_issue( + &db.conn, + space.id, + "I", + "d", + IssuePriority::Medium, + Some(&IssueConfig::default()), + ) + .await + .unwrap(); + (db, space.id, issue.row.id) + } + + fn claim(space_id: i32, issue_id: i32, stage: Stage, target: Option, slot: Option, token: &str) -> IterationClaim { + IterationClaim { + space_id, + issue_id, + stage, + target_artifact_id: target, + slot_no: slot, + capability_token: token.to_string(), + attempt: 0, + } + } + + #[tokio::test] + async fn cas_issue_status_only_on_expected() { + let (db, _space, issue_id) = seed().await; + assert!( + cas_issue_status(&db.conn, issue_id, IssueStatus::Pending, IssueStatus::Running) + .await + .unwrap() + ); + // Now the row is Running; a Pending→Running CAS must miss. + assert!( + !cas_issue_status(&db.conn, issue_id, IssueStatus::Pending, IssueStatus::Running) + .await + .unwrap() + ); + } + + #[tokio::test] + async fn node_lease_blocks_second_implement_per_task() { + let (db, space_id, issue_id) = seed().await; + let task = artifact::create_artifact(&db.conn, space_id, issue_id, ArtifactKind::Task, "T", ArtifactStatus::Pending, ActorKind::Agent, None).await.unwrap(); + let first = try_claim_iteration(&db.conn, claim(space_id, issue_id, Stage::Implement, Some(task.id), None, "tok-a")).await.unwrap(); + assert!(first.is_some()); + // Same task, another implement → uniq_active_node(target, stage) blocks it + // (phase 2: per-task, not per-issue; different tasks now run concurrently). + let second = try_claim_iteration(&db.conn, claim(space_id, issue_id, Stage::Implement, Some(task.id), None, "tok-b")).await.unwrap(); + assert!(second.is_none(), "second implement of the same task is leased out"); + } + + #[tokio::test] + async fn review_slots_parallel_but_unique_per_slot() { + let (db, space_id, issue_id) = seed().await; + let task = artifact::create_artifact(&db.conn, space_id, issue_id, ArtifactKind::Task, "T", ArtifactStatus::Done, ActorKind::Agent, None).await.unwrap(); + // Two reviews of the same task on distinct slots both claim. + let s0 = try_claim_iteration(&db.conn, claim(space_id, issue_id, Stage::Review, Some(task.id), Some(0), "r0")).await.unwrap(); + let s1 = try_claim_iteration(&db.conn, claim(space_id, issue_id, Stage::Review, Some(task.id), Some(1), "r1")).await.unwrap(); + assert!(s0.is_some() && s1.is_some(), "review slots run in parallel"); + // Same slot again → blocked. + let dup = try_claim_iteration(&db.conn, claim(space_id, issue_id, Stage::Review, Some(task.id), Some(0), "r0b")).await.unwrap(); + assert!(dup.is_none(), "duplicate review slot is leased out"); + } + + #[tokio::test] + async fn cas_task_done_with_contribution_delta_sets_both_atomically() { + let (db, space_id, issue_id) = seed().await; + let task = artifact::create_artifact( + &db.conn, space_id, issue_id, ArtifactKind::Task, "T", + ArtifactStatus::InProgress, ActorKind::Agent, None, + ) + .await + .unwrap(); + + // From InProgress: applies, setting status=Done AND contribution+commit. + assert!(cas_task_done_with_contribution( + &db.conn, + task.id, + TaskContribution::Delta("deadbeef".into()), + ) + .await + .unwrap()); + let row = loop_artifact::Entity::find_by_id(task.id) + .one(&db.conn) + .await + .unwrap() + .unwrap(); + assert_eq!(row.status, ArtifactStatus::Done); + assert_eq!(row.contribution_kind, loop_artifact::ContributionKind::Delta); + assert_eq!( + row.fan_in_commit.as_deref(), + Some("deadbeef"), + "Done ⟹ frozen, no unfrozen window" + ); + + // A second call (now Done, not InProgress) is a CAS miss — no overwrite. + assert!(!cas_task_done_with_contribution( + &db.conn, + task.id, + TaskContribution::Delta("other".into()), + ) + .await + .unwrap()); + let row = loop_artifact::Entity::find_by_id(task.id) + .one(&db.conn) + .await + .unwrap() + .unwrap(); + assert_eq!(row.fan_in_commit.as_deref(), Some("deadbeef")); + } + + #[tokio::test] + async fn cas_task_done_with_contribution_no_op_leaves_commit_null() { + let (db, space_id, issue_id) = seed().await; + let task = artifact::create_artifact( + &db.conn, space_id, issue_id, ArtifactKind::Task, "T", + ArtifactStatus::InProgress, ActorKind::Agent, None, + ) + .await + .unwrap(); + // NoOp: Done with NULL fan_in_commit (the no_op ⇔ NULL invariant). + assert!(cas_task_done_with_contribution(&db.conn, task.id, TaskContribution::NoOp) + .await + .unwrap()); + let row = loop_artifact::Entity::find_by_id(task.id) + .one(&db.conn) + .await + .unwrap() + .unwrap(); + assert_eq!(row.status, ArtifactStatus::Done); + assert_eq!(row.contribution_kind, loop_artifact::ContributionKind::NoOp); + assert!(row.fan_in_commit.is_none(), "no_op ⟹ fan_in_commit IS NULL"); + } + + #[tokio::test] + async fn cas_task_done_with_contribution_misses_when_not_in_progress() { + let (db, space_id, issue_id) = seed().await; + // A Pending task is not yet eligible → CAS misses, no partial write. + let task = artifact::create_artifact( + &db.conn, space_id, issue_id, ArtifactKind::Task, "T", + ArtifactStatus::Pending, ActorKind::Agent, None, + ) + .await + .unwrap(); + assert!(!cas_task_done_with_contribution( + &db.conn, + task.id, + TaskContribution::Delta("abc".into()), + ) + .await + .unwrap()); + let row = loop_artifact::Entity::find_by_id(task.id) + .one(&db.conn) + .await + .unwrap() + .unwrap(); + assert_eq!(row.status, ArtifactStatus::Pending); + assert!(row.fan_in_commit.is_none(), "no write on a CAS miss"); + } + + #[tokio::test] + async fn repark_if_wedged_only_when_genuinely_stuck() { + let (db, space_id, issue_id) = seed().await; + let conn = &db.conn; + + async fn set_running(conn: &DatabaseConnection, issue_id: i32) { + loop_issue::Entity::update_many() + .col_expr( + loop_issue::Column::Status, + Expr::value(IssueStatus::Running.to_value()), + ) + .filter(loop_issue::Column::Id.eq(issue_id)) + .exec(conn) + .await + .unwrap(); + } + async fn status(conn: &DatabaseConnection, issue_id: i32) -> IssueStatus { + loop_issue::Entity::find_by_id(issue_id) + .one(conn) + .await + .unwrap() + .unwrap() + .status + } + async fn set_task(conn: &DatabaseConnection, id: i32, s: ArtifactStatus) { + loop_artifact::Entity::update_many() + .col_expr(loop_artifact::Column::Status, Expr::value(s.to_value())) + .filter(loop_artifact::Column::Id.eq(id)) + .exec(conn) + .await + .unwrap(); + } + let mk = |status: ArtifactStatus| { + artifact::create_artifact( + conn, space_id, issue_id, ArtifactKind::Task, "T", status, ActorKind::Agent, None, + ) + }; + + // A pending task present → driver could still pick it up → NO re-park. + set_running(conn, issue_id).await; + let blocked = mk(ArtifactStatus::Blocked).await.unwrap().id; + let pending = mk(ArtifactStatus::Pending).await.unwrap().id; + assert!(!cas_issue_repark_if_wedged(conn, issue_id).await.unwrap()); + assert_eq!(status(conn, issue_id).await, IssueStatus::Running); + + // Remove the pending task (→ done): now only a blocked task remains, no + // in-flight iteration → genuinely wedged → RE-PARK. + set_task(conn, pending, ArtifactStatus::Done).await; + assert!(cas_issue_repark_if_wedged(conn, issue_id).await.unwrap()); + assert_eq!(status(conn, issue_id).await, IssueStatus::Blocked); + + // A non-running issue is never re-parked (idempotent on the blocked result). + assert!(!cas_issue_repark_if_wedged(conn, issue_id).await.unwrap()); + + // An in-flight iteration blocks re-park even with a blocked task. + set_running(conn, issue_id).await; + let _it = try_claim_iteration( + conn, + claim(space_id, issue_id, Stage::Triage, None, None, "wedge-it"), + ) + .await + .unwrap() + .unwrap(); + assert!(!cas_issue_repark_if_wedged(conn, issue_id).await.unwrap()); + assert_eq!(status(conn, issue_id).await, IssueStatus::Running); + + // Settle the iteration and clear the blocked task (→ done): no blocked task + // → nothing to re-park to (all-done is the finalize path) → NO re-park. + // (Set the iteration terminal directly — test setup, not a real transition.) + loop_iteration::Entity::update_many() + .col_expr( + loop_iteration::Column::Status, + Expr::value(IterationStatus::Succeeded.to_value()), + ) + .filter(loop_iteration::Column::Id.eq(_it.id)) + .exec(conn) + .await + .unwrap(); + set_task(conn, blocked, ArtifactStatus::Done).await; + assert!(!cas_issue_repark_if_wedged(conn, issue_id).await.unwrap()); + assert_eq!(status(conn, issue_id).await, IssueStatus::Running); + } + + #[tokio::test] + async fn step_oscillation_increments_same_sig_resets_new_and_clears() { + let (db, space_id, issue_id) = seed().await; + let task = artifact::create_artifact( + &db.conn, space_id, issue_id, ArtifactKind::Task, "T", + ArtifactStatus::Blocked, ActorKind::Agent, None, + ) + .await + .unwrap(); + + // Same sig increments the epoch; a new sig resets it to 1. + assert_eq!(step_oscillation(&db.conn, task.id, "A").await.unwrap(), 1); + assert_eq!(step_oscillation(&db.conn, task.id, "A").await.unwrap(), 2); + assert_eq!(step_oscillation(&db.conn, task.id, "B").await.unwrap(), 1); + + // clear_oscillation zeroes the count and drops the sig. + clear_oscillation(&db.conn, task.id).await.unwrap(); + let row = loop_artifact::Entity::find_by_id(task.id) + .one(&db.conn) + .await + .unwrap() + .unwrap(); + assert_eq!(row.oscillation_count, 0); + assert!(row.recent_failure_sig.is_none()); + } + + #[tokio::test] + async fn duplicate_token_is_typed_unique_violation() { + let (db, space_id, issue_id) = seed().await; + let c = |t: &str| claim(space_id, issue_id, Stage::Triage, None, None, t); + // First claim wins. + assert!(try_claim_iteration(&db.conn, c("dup")).await.unwrap().is_some()); + // Second claim with the SAME capability_token hits uniq_loop_iteration_token + // → classified as a lost race (Ok(None)), not an Err. + let again = try_claim_iteration(&db.conn, c("dup")).await.unwrap(); + assert!(again.is_none(), "duplicate token is a typed unique violation → Ok(None)"); + } + + #[test] + fn is_legal_covers_known_edges_and_rejects_nonsense() { + use crate::db::entities::loop_artifact::ArtifactStatus as A; + use crate::db::entities::loop_issue::IssueStatus as I; + use crate::db::entities::loop_iteration::IterationStatus as It; + // Representative legal edges. + assert!(is_legal_issue(I::Pending, I::Running)); + assert!(is_legal_issue(I::Running, I::Done)); + assert!(is_legal_iteration(It::Queued, It::Running)); + assert!(is_legal_iteration(It::Running, It::Succeeded)); + assert!(is_legal_artifact(A::Pending, A::InProgress)); + assert!(is_legal_artifact(A::InProgress, A::Done)); + // Nonsense edges are illegal. + assert!(!is_legal_issue(I::Done, I::Running)); + assert!(!is_legal_iteration(It::Succeeded, It::Running)); + assert!(!is_legal_artifact(A::Done, A::InProgress)); + // Identity is never a "transition". + assert!(!is_legal_issue(I::Running, I::Running)); + } + + #[tokio::test] + async fn cas_rejects_illegal_pair_before_touching_db() { + let (db, _space, issue_id) = seed().await; + // Done is terminal; Done→Running is not a legal edge → IllegalTransition, + // independent of the row's current status. + let err = cas_issue_status(&db.conn, issue_id, IssueStatus::Done, IssueStatus::Running) + .await + .unwrap_err(); + assert!(matches!(err, LoopError::IllegalTransition)); + } + + #[tokio::test] + async fn fail_iteration_active_covers_queued_and_running_in_one_update() { + let (db, space_id, issue_id) = seed().await; + // queued lease + let q = try_claim_iteration(&db.conn, claim(space_id, issue_id, Stage::Triage, None, None, "q")) + .await + .unwrap() + .unwrap(); + assert!(fail_iteration_active(&db.conn, q.id).await.unwrap()); + // running lease + let r = try_claim_iteration(&db.conn, claim(space_id, issue_id, Stage::Refine, Some(1), None, "r")) + .await + .unwrap() + .unwrap(); + cas_iteration_status(&db.conn, r.id, IterationStatus::Queued, IterationStatus::Running) + .await + .unwrap(); + assert!(fail_iteration_active(&db.conn, r.id).await.unwrap()); + // already-terminal → no-op (false) + assert!(!fail_iteration_active(&db.conn, r.id).await.unwrap()); + } + + /// COALESCE no-clobber (Codex r1): the abandon write must preserve any real + /// outcome already recorded on an active row (the settle→status window, or a + /// future agent-declared completion), while still abandoning NULL-outcome rows. + #[tokio::test] + async fn fail_iteration_active_preserves_a_real_outcome() { + use loop_iteration::IterationOutcome; + let (db, space_id, issue_id) = seed().await; + // A running iteration that has ALREADY recorded a real outcome. + let settled = + try_claim_iteration(&db.conn, claim(space_id, issue_id, Stage::Refine, None, None, "s")) + .await + .unwrap() + .unwrap(); + cas_iteration_status(&db.conn, settled.id, IterationStatus::Queued, IterationStatus::Running) + .await + .unwrap(); + assert!(iteration::set_iteration_outcome(&db.conn, settled.id, IterationOutcome::Succeeded) + .await + .unwrap()); + // A sibling running iteration with no outcome yet. + let unsettled = + try_claim_iteration(&db.conn, claim(space_id, issue_id, Stage::Design, None, None, "u")) + .await + .unwrap() + .unwrap(); + cas_iteration_status(&db.conn, unsettled.id, IterationStatus::Queued, IterationStatus::Running) + .await + .unwrap(); + + assert!(fail_iteration_active(&db.conn, settled.id).await.unwrap()); + assert!(fail_iteration_active(&db.conn, unsettled.id).await.unwrap()); + + let s = loop_iteration::Entity::find_by_id(settled.id) + .one(&db.conn) + .await + .unwrap() + .unwrap(); + assert_eq!(s.status, IterationStatus::Failed); + assert_eq!( + s.outcome, + Some(IterationOutcome::Succeeded), + "COALESCE preserves a real outcome on an active row" + ); + let u = loop_iteration::Entity::find_by_id(unsettled.id) + .one(&db.conn) + .await + .unwrap() + .unwrap(); + assert_eq!(u.status, IterationStatus::Failed); + assert_eq!( + u.outcome, + Some(IterationOutcome::Abandoned), + "a NULL-outcome active row still becomes abandoned" + ); + } + + #[tokio::test] + async fn cas_artifact_status_from_blocks_pending_or_in_progress() { + let (db, space_id, issue_id) = seed().await; + let t = artifact::create_artifact(&db.conn, space_id, issue_id, ArtifactKind::Task, "T", ArtifactStatus::Pending, ActorKind::Agent, None) + .await + .unwrap(); + // pending → blocked via the multi-from set + assert!(cas_artifact_status_from(&db.conn, t.id, &[ArtifactStatus::Pending, ArtifactStatus::InProgress], ArtifactStatus::Blocked) + .await + .unwrap()); + // already blocked → no-op + assert!(!cas_artifact_status_from(&db.conn, t.id, &[ArtifactStatus::Pending, ArtifactStatus::InProgress], ArtifactStatus::Blocked) + .await + .unwrap()); + } + + /// Legality totality: the supersede edges the loop-backs rely on are legal, and + /// representative illegal transitions are rejected (so a typo in a loop-back can + /// never silently corrupt a node's lifecycle). + #[test] + fn is_legal_artifact_supersede_edges_and_rejections() { + use ArtifactStatus::*; + // Loop-back supersede edges (coverage / integration / design-reject). + assert!(is_legal_artifact(Done, Superseded), "integration loop-back supersedes done tasks/result"); + assert!(is_legal_artifact(AwaitingApproval, Superseded), "design-reject supersedes the awaiting design"); + assert!(is_legal_artifact(Pending, Superseded), "coverage loop-back supersedes pending tasks"); + // Review-fail retry sends an in-progress task back to pending. + assert!(is_legal_artifact(InProgress, Pending)); + // Rejections: a settled/implemented node can't regress or be superseded + // through an undefined edge. + assert!(!is_legal_artifact(Done, Pending), "a done task never reopens to pending"); + assert!(!is_legal_artifact(InProgress, Superseded), "an in-progress task isn't directly superseded"); + assert!(!is_legal_artifact(Done, InProgress), "a done task never reverts to in-progress"); + assert!(!is_legal_artifact(Blocked, Superseded), "a blocked node is resolved by retry/cancel, not supersede"); + } +} diff --git a/src-tauri/src/loop_engine/validation.rs b/src-tauri/src/loop_engine/validation.rs new file mode 100644 index 0000000000..0f1b6407be --- /dev/null +++ b/src-tauri/src/loop_engine/validation.rs @@ -0,0 +1,227 @@ +//! Deterministic validation (§4.6): run the issue's `validation_commands` in the +//! worktree after an implement checkpoint, with no agent involved. +//! +//! The runner draws a deliberate line between two failure shapes the gate treats +//! very differently: +//! +//! - **A command runs and exits non-zero** — a *code* problem (tests/lint fail). +//! The agent can fix it, so the gate reworks (bumps the task attempt and +//! re-dispatches implement with the failure output fed back in). +//! - **A command can't run at all** — missing tool, un-spawnable shell, or a +//! timeout. That's an *environment/config* problem a human must resolve, so the +//! gate blocks and files an inbox card rather than burning rework attempts. +//! +//! Missing-tool detection pre-flights the leading program with `which`; a typed +//! validation command (`cargo test`, `pnpm lint`) names a real program, so this +//! is reliable in practice. Execution itself goes through the platform shell so +//! arguments, quoting, and pipelines behave as written. + +use std::path::Path; +use std::time::Duration; + +use crate::loop_engine::error::LoopError; + +/// How a validation pass concluded. The gate maps each variant to a distinct +/// next action (advance / rework / block). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ValidationOutcome { + /// Every command ran and exited zero. + Passed, + /// A command ran and exited non-zero — a code problem the agent can fix. + Failed, + /// A command could not be run (missing tool, un-spawnable, or timed out) — an + /// environment problem a human must resolve. + Unrunnable, +} + +/// The result of one validation pass: the per-command exit codes collected so +/// far, the combined transcript, and the classified outcome. +#[derive(Debug, Clone)] +pub struct ValidationReport { + pub exit_codes: Vec, + pub output: String, + pub outcome: ValidationOutcome, +} + +impl ValidationReport { + pub fn passed(&self) -> bool { + self.outcome == ValidationOutcome::Passed + } +} + +/// Build a shell command for `cmd` rooted at `dir`. `kill_on_drop` lets the +/// timeout path terminate the child simply by dropping the awaited future. +fn shell_command(cmd: &str, dir: &Path) -> tokio::process::Command { + #[cfg(windows)] + let mut command = { + let mut c = crate::process::tokio_command("cmd"); + c.args(["/D", "/S", "/C", cmd]); + c + }; + #[cfg(not(windows))] + let mut command = { + let mut c = crate::process::tokio_command("sh"); + c.args(["-c", cmd]); + c + }; + command.current_dir(dir).kill_on_drop(true); + command +} + +/// Run `commands` sequentially in `worktree_path`, stopping at the first failure. +/// +/// Returns `Passed` when every command exits zero, `Failed` at the first +/// non-zero exit, and `Unrunnable` when a command's program is missing, the +/// shell can't be spawned, or `timeout` elapses (each command gets the full +/// `timeout`; `None` means unlimited). Never returns `Err` for ordinary process +/// trouble — infrastructure failures classify as `Unrunnable` so the caller can +/// surface them as a blocked inbox card rather than a hard engine error. +pub async fn run_validation( + worktree_path: &Path, + commands: &[String], + timeout: Option, +) -> Result { + let mut exit_codes = Vec::new(); + let mut output = String::new(); + + for raw in commands { + let cmd = raw.trim(); + if cmd.is_empty() { + continue; + } + output.push_str(&format!("$ {cmd}\n")); + + // Pre-flight: a missing program is a config problem (block), not a code + // failure (rework). A typed validation command leads with a real program. + if let Some(program) = cmd.split_whitespace().next() { + if which::which(program).is_err() { + output.push_str(&format!("[validation] command not found: {program}\n")); + return Ok(ValidationReport { + exit_codes, + output, + outcome: ValidationOutcome::Unrunnable, + }); + } + } + + let mut command = shell_command(cmd, worktree_path); + let spawned = match timeout { + Some(d) => match tokio::time::timeout(d, command.output()).await { + Ok(result) => result, + Err(_) => { + // The awaited future is dropped here; `kill_on_drop` reaps the + // child. A timeout is an environment problem → block. + output.push_str(&format!("[validation] timed out after {}s\n", d.as_secs())); + return Ok(ValidationReport { + exit_codes, + output, + outcome: ValidationOutcome::Unrunnable, + }); + } + }, + None => command.output().await, + }; + let out = match spawned { + Ok(out) => out, + Err(e) => { + output.push_str(&format!("[validation] could not run: {e}\n")); + return Ok(ValidationReport { + exit_codes, + output, + outcome: ValidationOutcome::Unrunnable, + }); + } + }; + + output.push_str(&String::from_utf8_lossy(&out.stdout)); + output.push_str(&String::from_utf8_lossy(&out.stderr)); + let code = out.status.code().unwrap_or(-1); + exit_codes.push(code); + if code != 0 { + return Ok(ValidationReport { + exit_codes, + output, + outcome: ValidationOutcome::Failed, + }); + } + } + + Ok(ValidationReport { + exit_codes, + output, + outcome: ValidationOutcome::Passed, + }) +} + +// Exercises real coreutils, so it is gated to unix (the CI/dev platform). The +// engine code above compiles and runs on every platform via `shell_command`. +#[cfg(all(test, unix))] +mod tests { + use super::*; + + fn cmds(v: &[&str]) -> Vec { + v.iter().map(|s| s.to_string()).collect() + } + + #[tokio::test] + async fn all_zero_exit_passes() { + let dir = tempfile::tempdir().unwrap(); + let r = run_validation(dir.path(), &cmds(&["true", "true"]), None) + .await + .unwrap(); + assert_eq!(r.outcome, ValidationOutcome::Passed); + assert_eq!(r.exit_codes, vec![0, 0]); + assert!(r.passed()); + } + + #[tokio::test] + async fn nonzero_exit_fails_and_stops_at_first() { + let dir = tempfile::tempdir().unwrap(); + let r = run_validation(dir.path(), &cmds(&["false", "true"]), None) + .await + .unwrap(); + assert_eq!(r.outcome, ValidationOutcome::Failed); + assert_eq!(r.exit_codes, vec![1], "fail-fast: the second command never ran"); + assert!(!r.passed()); + } + + #[tokio::test] + async fn missing_command_is_unrunnable() { + let dir = tempfile::tempdir().unwrap(); + let r = run_validation( + dir.path(), + &cmds(&["codeg-no-such-tool-xyzzy --version"]), + None, + ) + .await + .unwrap(); + assert_eq!(r.outcome, ValidationOutcome::Unrunnable); + assert!(r.exit_codes.is_empty(), "nothing was executed"); + assert!(r.output.contains("command not found")); + } + + #[tokio::test] + async fn timeout_is_unrunnable() { + let dir = tempfile::tempdir().unwrap(); + let r = run_validation( + dir.path(), + &cmds(&["sleep 5"]), + Some(Duration::from_millis(200)), + ) + .await + .unwrap(); + assert_eq!(r.outcome, ValidationOutcome::Unrunnable); + assert!(r.output.contains("timed out")); + } + + #[tokio::test] + async fn commands_run_in_the_worktree() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("marker.txt"), "x").unwrap(); + // `test -f` exits 0 only when the cwd is the worktree holding the marker. + let r = run_validation(dir.path(), &cmds(&["test -f marker.txt"]), None) + .await + .unwrap(); + assert_eq!(r.outcome, ValidationOutcome::Passed); + } +} diff --git a/src-tauri/src/loop_engine/worktree.rs b/src-tauri/src/loop_engine/worktree.rs new file mode 100644 index 0000000000..1a79513c7e --- /dev/null +++ b/src-tauri/src/loop_engine/worktree.rs @@ -0,0 +1,1961 @@ +//! worktree-per-issue lifecycle. +//! +//! Each running issue gets its own git worktree + branch so issues run fully in +//! parallel without touching each other's tree. The engine drives git directly +//! (returning `LoopError`) rather than through the `AppCommandError`-returning +//! command helpers, and registers the worktree as a hidden `loop_worktree` +//! folder so cwd resolution works while it stays out of every user folder list. +//! +//! Invariants (spec §4.4 / §4.10): the engine checkpoint-commits accepted work +//! onto the issue branch; `reset_to_head` only ever discards *uncommitted* +//! side-effects (never rewinds a committed checkpoint). + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use sea_orm::{ + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, IntoActiveModel, QueryFilter, + Set, +}; + +use crate::db::entities::loop_artifact; +use crate::db::entities::loop_issue; +use crate::db::entities::loop_link::{self, LinkKind}; +use crate::db::service::{folder_service, loop_service}; +use crate::loop_engine::{validation, LoopError}; + +/// Identity stamped on engine checkpoint commits. +const ENGINE_NAME: &str = "codeg loop engine"; +const ENGINE_EMAIL: &str = "loop@codeg.local"; + +/// Resolved location of an issue's worktree. +#[derive(Debug, Clone)] +pub struct WorktreeContext { + pub worktree_path: PathBuf, + pub worktree_folder_id: i32, + pub branch: String, + pub base_branch: String, + pub base_commit: String, +} + +fn path_str(p: &Path) -> String { + p.to_string_lossy().to_string() +} + +async fn run_git(dir: &Path, args: &[&str]) -> Result { + crate::process::tokio_command("git") + .args(args) + .current_dir(dir) + .output() + .await + .map_err(|e| LoopError::Git(format!("git {args:?}: {e}"))) +} + +fn stderr_of(out: &std::process::Output) -> String { + String::from_utf8_lossy(&out.stderr).trim().to_string() +} + +fn stdout_trimmed(out: &std::process::Output) -> String { + String::from_utf8_lossy(&out.stdout).trim().to_string() +} + +/// stdout followed by stderr (trimmed) — git writes conflict reports to both, so +/// merge-fault details want the union. +fn combined_output(out: &std::process::Output) -> String { + let mut s = String::from_utf8_lossy(&out.stdout).trim().to_string(); + let err = String::from_utf8_lossy(&out.stderr); + let err = err.trim(); + if !err.is_empty() { + if !s.is_empty() { + s.push('\n'); + } + s.push_str(err); + } + s +} + +async fn ensure_git_repo(repo: &Path) -> Result<(), LoopError> { + let out = run_git(repo, &["rev-parse", "--is-inside-work-tree"]).await?; + if out.status.success() && stdout_trimmed(&out) == "true" { + Ok(()) + } else { + Err(LoopError::NotGitRepo) + } +} + +async fn current_branch(repo: &Path) -> Result { + let head = run_git(repo, &["rev-parse", "--abbrev-ref", "HEAD"]).await?; + if head.status.success() { + let name = stdout_trimmed(&head); + if !name.is_empty() && name != "HEAD" { + return Ok(name); + } + } + // Unborn branch (init before first commit): symbolic-ref still resolves. + let sym = run_git(repo, &["symbolic-ref", "--short", "HEAD"]).await?; + if sym.status.success() { + let name = stdout_trimmed(&sym); + if !name.is_empty() { + return Ok(name); + } + } + Err(LoopError::Git("could not resolve current branch".into())) +} + +/// The commit OID at a worktree's HEAD (its branch tip). +pub async fn head_commit(repo: &Path) -> Result { + let out = run_git(repo, &["rev-parse", "HEAD"]).await?; + if !out.status.success() { + return Err(LoopError::Git(format!("rev-parse HEAD: {}", stderr_of(&out)))); + } + Ok(stdout_trimmed(&out)) +} + +/// Create (or re-attach) the issue's worktree, branch, and hidden folder, and +/// record the merge base on the issue. Idempotent: if the issue already has a +/// live worktree folder whose directory exists, returns it untouched. +pub async fn ensure_worktree( + conn: &DatabaseConnection, + data_dir: &Path, + issue_id: i32, +) -> Result { + let issue = loop_service::issue::get_issue(conn, issue_id) + .await? + .ok_or_else(|| LoopError::NotFound(format!("issue {issue_id}")))?; + let branch = format!("loop/{}/issue-{}", issue.space_id, issue.seq_no); + + // Re-attach path: an existing, on-disk worktree folder is reused as-is. + if let Some(folder_id) = issue.worktree_folder_id { + if let Some(folder) = folder_service::get_folder_by_id(conn, folder_id).await? { + if Path::new(&folder.path).exists() { + return Ok(WorktreeContext { + worktree_path: PathBuf::from(folder.path), + worktree_folder_id: folder_id, + branch, + base_branch: issue.base_branch.clone().unwrap_or_default(), + base_commit: issue.base_commit.clone().unwrap_or_default(), + }); + } + } + } + + let space = loop_service::space::get_space(conn, issue.space_id) + .await? + .ok_or_else(|| LoopError::NotFound(format!("space {}", issue.space_id)))?; + let repo = folder_service::get_folder_by_id(conn, space.folder_id) + .await? + .ok_or(LoopError::Detached)?; + let repo_path = PathBuf::from(&repo.path); + ensure_git_repo(&repo_path).await?; + + let base_branch = current_branch(&repo_path).await?; + let base_commit = head_commit(&repo_path).await?; + + let worktree_path = data_dir + .join("loop-worktrees") + .join(issue.space_id.to_string()) + .join(format!("issue-{}", issue.seq_no)); + if let Some(parent) = worktree_path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| LoopError::Git(format!("create worktree parent dir: {e}")))?; + } + + let wt = path_str(&worktree_path); + // Reconcile leftovers from a prior life of this (space_id, seq_no): a stale + // admin entry (data_dir wiped, dir gone), an orphaned worktree dir (DB-only + // reset), or a leftover `loop/*` branch — teardown intentionally keeps + // cancel/merge branches, and a DB reset reuses the same name. `loop/*` is an + // engine-owned, disposable namespace, so prune dangling entries, force-remove + // anything still at the path, then `-B` (create-or-reset) the branch from the + // current base HEAD. A (re)triggered issue always starts from base HEAD + // (matching the `base_commit` recorded just above) — reset is correct here, + // never a reuse of stale loop commits. + let _ = run_git(&repo_path, &["worktree", "prune"]).await; + if worktree_path.exists() { + let _ = run_git(&repo_path, &["worktree", "remove", "--force", &wt]).await; + let _ = std::fs::remove_dir_all(&worktree_path); + } + let out = run_git(&repo_path, &["worktree", "add", "-B", &branch, &wt]).await?; + if !out.status.success() { + return Err(LoopError::Git(format!("worktree add: {}", stderr_of(&out)))); + } + + let folder = folder_service::add_loop_worktree_folder(conn, &wt, space.folder_id).await?; + + let mut active = issue.into_active_model(); + active.worktree_folder_id = Set(Some(folder.id)); + active.base_branch = Set(Some(base_branch.clone())); + active.base_commit = Set(Some(base_commit.clone())); + active.update(conn).await?; + + Ok(WorktreeContext { + worktree_path, + worktree_folder_id: folder.id, + branch, + base_branch, + base_commit, + }) +} + +/// Peel a ref / sha to a concrete commit OID (`^{commit}`), erroring if +/// it doesn't resolve. +pub async fn resolve_oid(repo: &Path, refspec: &str) -> Result { + let out = run_git( + repo, + &["rev-parse", "--verify", &format!("{refspec}^{{commit}}")], + ) + .await?; + if !out.status.success() { + return Err(LoopError::Git(format!( + "rev-parse {refspec}: {}", + stderr_of(&out) + ))); + } + Ok(stdout_trimmed(&out)) +} + +/// Whether `oid` is an ancestor of the worktree's HEAD (`merge-base +/// --is-ancestor`: exit 0 = yes, 1 = no, other = error → treated as no, which +/// triggers a safe rebuild). +async fn is_ancestor_of_head(worktree: &Path, oid: &str) -> Result { + let out = run_git(worktree, &["merge-base", "--is-ancestor", oid, "HEAD"]).await?; + Ok(out.status.success()) +} + +/// Resolve the base ref a task's worktree branches from: its single `DependsOn` +/// predecessor's **frozen** integration commit (`loop_artifact.fan_in_commit`), +/// or — for a root task (no predecessor) — the issue branch tip. NEVER the live +/// predecessor branch ref, which would smuggle post-Done drift (spec §3.2). +async fn task_base_ref( + conn: &DatabaseConnection, + space_id: i32, + issue_seq: i32, + task_id: i32, +) -> Result { + // `DependsOn`: from = successor (this task), to = predecessor. + let pred = loop_link::Entity::find() + .filter(loop_link::Column::FromArtifactId.eq(task_id)) + .filter(loop_link::Column::Kind.eq(LinkKind::DependsOn)) + .one(conn) + .await?; + if let Some(link) = pred { + let pred = loop_artifact::Entity::find_by_id(link.to_artifact_id) + .one(conn) + .await? + .ok_or_else(|| { + LoopError::NotFound(format!("predecessor task {}", link.to_artifact_id)) + })?; + // D12: a `NoOp` predecessor produced NO commit (its HEAD == its own + // integration base, `fan_in_commit IS NULL` by invariant). This task must + // branch from the SAME base the no-op predecessor used — recurse up the + // single-predecessor chain to the first `Delta`'s frozen commit (or the + // issue branch tip at the chain root). A `Delta` predecessor MUST carry a + // frozen commit; a NULL there is an invariant breach, never silently + // treated as a no-op. Recursion is bounded: `depends_on` is a single, + // strictly-backward predecessor (acyclic by the ingest guard). + match pred.contribution_kind { + loop_artifact::ContributionKind::NoOp => { + return Box::pin(task_base_ref(conn, space_id, issue_seq, pred.id)).await; + } + loop_artifact::ContributionKind::Delta => { + let sha = pred.fan_in_commit.ok_or_else(|| { + LoopError::Git(format!( + "delta predecessor task {} has no frozen commit yet", + pred.id + )) + })?; + return Ok(sha); + } + } + } + Ok(format!("loop/{space_id}/issue-{issue_seq}")) +} + +/// D12: resolve the OID a parallel task's worktree branched from (its pinned +/// integration base) WITHOUT creating or rebuilding a worktree — read-only. +/// `freeze_and_done` uses it to discriminate a no-op task (HEAD == base, no +/// commit) from a delta (HEAD advanced); force-complete uses it for a +/// defence-in-depth clean-tree check. Honours the no-op-predecessor recursion in +/// [`task_base_ref`], so the base is stable across the task's lifetime (fan-in +/// runs strictly after all tasks are Done, so neither the issue tip nor a +/// predecessor's frozen commit moves between create and done). +pub async fn task_base_oid( + conn: &DatabaseConnection, + issue: &loop_issue::Model, + task_id: i32, +) -> Result { + let space = loop_service::space::get_space(conn, issue.space_id) + .await? + .ok_or_else(|| LoopError::NotFound(format!("space {}", issue.space_id)))?; + let repo = folder_service::get_folder_by_id(conn, space.folder_id) + .await? + .ok_or(LoopError::Detached)?; + let repo_path = PathBuf::from(&repo.path); + let base_ref = task_base_ref(conn, issue.space_id, issue.seq_no, task_id).await?; + resolve_oid(&repo_path, &base_ref).await +} + +/// D15 defence (read-only): whether a parallel task's branch HEAD still equals its +/// pinned integration base — i.e. the task carries NO committed delta, so a human +/// force-complete-as-no-op would discard nothing. `None` when the task branch does +/// not resolve (it never built one), so the caller skips the check and relies on +/// the empty-diff cause guard. Checks the branch ref directly (no worktree folder +/// needed), so it holds even if the on-disk worktree was pruned. +pub async fn task_branch_at_base( + conn: &DatabaseConnection, + issue: &loop_issue::Model, + task_id: i32, +) -> Result, LoopError> { + let space = loop_service::space::get_space(conn, issue.space_id) + .await? + .ok_or_else(|| LoopError::NotFound(format!("space {}", issue.space_id)))?; + let repo = folder_service::get_folder_by_id(conn, space.folder_id) + .await? + .ok_or(LoopError::Detached)?; + let repo_path = PathBuf::from(&repo.path); + let branch = format!( + "loop/{}/issue-{}-task-{}", + issue.space_id, issue.seq_no, task_id + ); + // Branch absent (never built) → can't verify; let the cause guard stand. + let Ok(head) = resolve_oid(&repo_path, &format!("refs/heads/{branch}")).await else { + return Ok(None); + }; + let base = task_base_oid(conn, issue, task_id).await?; + Ok(Some(head == base)) +} + +/// Create (or re-attach) a per-task worktree + branch for **parallel-mode** task +/// execution, so concurrent tasks never share a tree (spec §3.2). +/// +/// Branch `loop/{space}/issue-{seq}/task-{id}`; path +/// `loop-worktrees/{space}/issue-{seq}-tasks/task-{id}` — a **sibling** of the +/// issue worktree, never nested under it (else the issue worktree's `clean -fd` +/// during finalize/recovery would delete live task trees). The branch is cut from +/// [`task_base_ref`] (predecessor's frozen sha, or the issue branch tip). +/// +/// Attach-first: an on-disk worktree whose current branch is the expected task +/// branch AND whose HEAD descends from the expected base is reused untouched +/// (never `-B`, so a task branch with committed work is not rewound). Otherwise +/// prune + force-remove + `-B` from the base. Task worktree folders have no issue +/// column; they re-attach by their deterministic path (`add_loop_worktree_folder` +/// upserts on path). +pub async fn ensure_task_worktree( + conn: &DatabaseConnection, + data_dir: &Path, + issue_id: i32, + task_id: i32, +) -> Result { + let issue = loop_service::issue::get_issue(conn, issue_id) + .await? + .ok_or_else(|| LoopError::NotFound(format!("issue {issue_id}")))?; + let space = loop_service::space::get_space(conn, issue.space_id) + .await? + .ok_or_else(|| LoopError::NotFound(format!("space {}", issue.space_id)))?; + let repo = folder_service::get_folder_by_id(conn, space.folder_id) + .await? + .ok_or(LoopError::Detached)?; + let repo_path = PathBuf::from(&repo.path); + ensure_git_repo(&repo_path).await?; + + // Hyphen, not a slash: `loop/{s}/issue-{n}/task-{id}` would be a git ref + // D/F conflict with the issue branch `loop/{s}/issue-{n}` (a ref file cannot + // also be a directory). `issue-{n}-task-{id}` is a sibling ref. + let branch = format!("loop/{}/issue-{}-task-{}", issue.space_id, issue.seq_no, task_id); + let worktree_path = data_dir + .join("loop-worktrees") + .join(issue.space_id.to_string()) + .join(format!("issue-{}-tasks", issue.seq_no)) + .join(format!("task-{task_id}")); + + let base_ref = task_base_ref(conn, issue.space_id, issue.seq_no, task_id).await?; + let base_oid = resolve_oid(&repo_path, &base_ref).await?; + + attach_or_rebuild_worktree( + conn, + &repo_path, + space.folder_id, + &branch, + &worktree_path, + &base_oid, + issue.base_branch.clone().unwrap_or_default(), + ) + .await +} + +/// Create (or re-attach) the issue's temp **integrate** worktree + branch, where +/// the parallel result-stage fan-in merges the frozen task commits before the +/// atomic CAS landing onto the issue branch (spec §4.4). +/// +/// Branch `loop/{space}/issue-{seq}-integrate`; path a sibling of the issue + task +/// worktrees, cut from `base_oid` (the manifest's `issue_base_oid`). Attach-first +/// reuse is crucial here: a partially-merged integration — or one mid-conflict +/// (`MERGE_HEAD` set) — must be preserved across ticks and crashes so the fan-in +/// resumes from it rather than restarting. +pub async fn ensure_integrate_worktree( + conn: &DatabaseConnection, + data_dir: &Path, + issue_id: i32, + base_oid: &str, +) -> Result { + let issue = loop_service::issue::get_issue(conn, issue_id) + .await? + .ok_or_else(|| LoopError::NotFound(format!("issue {issue_id}")))?; + let space = loop_service::space::get_space(conn, issue.space_id) + .await? + .ok_or_else(|| LoopError::NotFound(format!("space {}", issue.space_id)))?; + let repo = folder_service::get_folder_by_id(conn, space.folder_id) + .await? + .ok_or(LoopError::Detached)?; + let repo_path = PathBuf::from(&repo.path); + ensure_git_repo(&repo_path).await?; + + let branch = format!("loop/{}/issue-{}-integrate", issue.space_id, issue.seq_no); + let worktree_path = data_dir + .join("loop-worktrees") + .join(issue.space_id.to_string()) + .join(format!("issue-{}-integrate", issue.seq_no)); + + attach_or_rebuild_worktree( + conn, + &repo_path, + space.folder_id, + &branch, + &worktree_path, + base_oid, + issue.base_branch.clone().unwrap_or_default(), + ) + .await +} + +/// Attach-or-rebuild a worktree at `worktree_path` on `branch`, cut from +/// `base_oid`. Reused as-is iff it exists on `branch` with `base_oid` an ancestor +/// of its HEAD (so committed work / an in-progress merge survive); otherwise +/// pruned, force-removed, and recreated `-B` from `base_oid`. Upserts the hidden +/// `loop_worktree` folder (parented to `repo_folder_id`) — task / integrate +/// worktrees have no issue column, so they re-attach by their deterministic path. +async fn attach_or_rebuild_worktree( + conn: &DatabaseConnection, + repo_path: &Path, + repo_folder_id: i32, + branch: &str, + worktree_path: &Path, + base_oid: &str, + base_branch: String, +) -> Result { + let ctx = |folder_id: i32| WorktreeContext { + worktree_path: worktree_path.to_path_buf(), + worktree_folder_id: folder_id, + branch: branch.to_string(), + base_branch: base_branch.clone(), + base_commit: base_oid.to_string(), + }; + + if worktree_path.exists() { + if let Ok(cur) = current_branch(worktree_path).await { + if cur == branch && is_ancestor_of_head(worktree_path, base_oid).await? { + let folder = folder_service::add_loop_worktree_folder( + conn, + &path_str(worktree_path), + repo_folder_id, + ) + .await?; + return Ok(ctx(folder.id)); + } + } + } + + if let Some(parent) = worktree_path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| LoopError::Git(format!("create worktree parent dir: {e}")))?; + } + let wt = path_str(worktree_path); + let _ = run_git(repo_path, &["worktree", "prune"]).await; + if worktree_path.exists() { + let _ = run_git(repo_path, &["worktree", "remove", "--force", &wt]).await; + let _ = std::fs::remove_dir_all(worktree_path); + } + let out = run_git(repo_path, &["worktree", "add", "-B", branch, &wt, base_oid]).await?; + if !out.status.success() { + return Err(LoopError::Git(format!("worktree add: {}", stderr_of(&out)))); + } + let folder = folder_service::add_loop_worktree_folder(conn, &wt, repo_folder_id).await?; + Ok(ctx(folder.id)) +} + +/// Stage everything and, if there is a non-empty diff, create an engine +/// checkpoint commit. Returns the new commit sha, or `None` when the tree was +/// already clean (no changes to accept). +pub async fn checkpoint(worktree_path: &Path, message: &str) -> Result, LoopError> { + let add = run_git(worktree_path, &["add", "-A"]).await?; + if !add.status.success() { + return Err(LoopError::Git(format!("add -A: {}", stderr_of(&add)))); + } + // `diff --cached --quiet`: exit 0 = no staged changes, 1 = changes present. + let diff = run_git(worktree_path, &["diff", "--cached", "--quiet"]).await?; + match diff.status.code() { + Some(0) => return Ok(None), + Some(1) => {} + _ => { + return Err(LoopError::Git(format!( + "diff --cached: {}", + stderr_of(&diff) + ))) + } + } + + let name_cfg = format!("user.name={ENGINE_NAME}"); + let email_cfg = format!("user.email={ENGINE_EMAIL}"); + let commit = run_git( + worktree_path, + &[ + "-c", &name_cfg, "-c", &email_cfg, "commit", "-m", message, + ], + ) + .await?; + if !commit.status.success() { + return Err(LoopError::Git(format!("commit: {}", stderr_of(&commit)))); + } + let sha = run_git(worktree_path, &["rev-parse", "HEAD"]).await?; + Ok(Some(stdout_trimmed(&sha))) +} + +/// Discard all uncommitted changes, returning the worktree to its branch HEAD +/// (the latest accepted checkpoint). Never rewinds committed history. +pub async fn reset_to_head(worktree_path: &Path) -> Result<(), LoopError> { + let reset = run_git(worktree_path, &["reset", "--hard", "HEAD"]).await?; + if !reset.status.success() { + return Err(LoopError::Git(format!("reset --hard: {}", stderr_of(&reset)))); + } + let clean = run_git(worktree_path, &["clean", "-fd"]).await?; + if !clean.status.success() { + return Err(LoopError::Git(format!("clean -fd: {}", stderr_of(&clean)))); + } + Ok(()) +} + +/// Whether the worktree has no uncommitted changes (tracked or untracked) — i.e. +/// it equals its branch HEAD. Used to assert all accepted work is committed +/// before finalize / merge. +pub async fn is_clean(worktree_path: &Path) -> Result { + let out = run_git(worktree_path, &["status", "--porcelain"]).await?; + if !out.status.success() { + return Err(LoopError::Git(format!( + "status --porcelain: {}", + stderr_of(&out) + ))); + } + Ok(stdout_trimmed(&out).is_empty()) +} + +/// Like [`is_clean`], but ignores untracked files (`--untracked-files=no`). Used +/// as the BASE-repo precondition before a `--no-ff` landing: untracked files are +/// harmless to a merge (git itself refuses if an incoming file would clobber an +/// untracked one, surfacing as a `Conflict`), so refusing on them would block +/// every merge in a normal dev checkout. Modified or staged TRACKED files remain +/// a real hazard — a checkout/merge could clobber them — and still report dirty. +pub async fn is_clean_tracked(repo_path: &Path) -> Result { + let out = run_git( + repo_path, + &["status", "--porcelain", "--untracked-files=no"], + ) + .await?; + if !out.status.success() { + return Err(LoopError::Git(format!( + "status --porcelain -uno: {}", + stderr_of(&out) + ))); + } + Ok(stdout_trimmed(&out).is_empty()) +} + +/// Outcome of attempting to land an issue's loop branch onto its base branch. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MergeOutcome { + /// The loop branch merged into the base branch (a `--no-ff` merge commit + /// landed). The caller closes the issue and removes the worktree. + Merged { merge_commit: String }, + /// `base_branch` no longer exists — a human deleted or renamed it. + BaseGone, + /// The base repo's working tree has uncommitted changes; we refuse to merge + /// rather than disturb the human's in-progress state. + BaseDirty, + /// A merge conflict. `stage` is `"integrate"` when folding an advanced base + /// into the loop branch, `"merge"` on the final landing. The merge was + /// aborted and the trees restored; `detail` is git's conflict report. + Conflict { stage: &'static str, detail: String }, + /// The base advanced and was folded in cleanly, but re-running the issue's + /// validation suite on the integrated tree failed — the new base broke the + /// work, so it must not land. + RevalidationFailed { output: String }, +} + +/// Land an issue's loop branch onto its base branch (spec §4.10). Pure git + +/// deterministic validation — no DB, no engine state; the caller (engine +/// `merge_issue`) owns the per-repo serialization lock and the post-merge DB +/// lifecycle. +/// +/// Stale-base aware: if `base_branch` advanced past the `base_commit` recorded +/// at trigger time, the new base is first folded into the loop branch (in the +/// worktree) and the issue's validation suite re-run there; only a clean, +/// re-validated integration proceeds to the final `--no-ff` landing. The landing +/// happens in the base repo's working tree, on `base_branch`, so that tree must +/// be clean — we never clobber uncommitted human state. A successful landing +/// leaves the base repo checked out on `base_branch` at the new merge commit. +#[allow(clippy::too_many_arguments)] +pub async fn merge_issue( + repo_path: &Path, + worktree_path: &Path, + loop_branch: &str, + base_branch: &str, + base_commit: &str, + validation_commands: &[String], + iteration_timeout_secs: Option, +) -> Result { + // 1. Base must still exist; its current tip tells us whether it advanced. + let verify = run_git( + repo_path, + &[ + "rev-parse", + "--verify", + "--quiet", + &format!("refs/heads/{base_branch}"), + ], + ) + .await?; + if !verify.status.success() { + return Ok(MergeOutcome::BaseGone); + } + let base_tip = stdout_trimmed(&verify); + + // 2. Base advanced since trigger → fold it into the loop branch and re-validate + // on the integrated tree before landing. + if base_tip != base_commit { + let integrate = run_git(worktree_path, &["merge", "--no-edit", base_branch]).await?; + if !integrate.status.success() { + let detail = combined_output(&integrate); + let _ = run_git(worktree_path, &["merge", "--abort"]).await; + return Ok(MergeOutcome::Conflict { + stage: "integrate", + detail, + }); + } + let commands: Vec = validation_commands + .iter() + .filter(|c| !c.trim().is_empty()) + .cloned() + .collect(); + if !commands.is_empty() { + let report = validation::run_validation( + worktree_path, + &commands, + iteration_timeout_secs.map(Duration::from_secs), + ) + .await?; + if !report.passed() { + return Ok(MergeOutcome::RevalidationFailed { + output: report.output, + }); + } + } + } + + // 3. Land the loop branch on the base branch, in the base repo's working tree. + // Refuse modified/staged TRACKED files — never clobber uncommitted human + // state — but tolerate untracked files (harmless to the merge; git itself + // refuses if an incoming file would overwrite one). + if !is_clean_tracked(repo_path).await? { + return Ok(MergeOutcome::BaseDirty); + } + let original_branch = current_branch(repo_path).await?; + if original_branch != base_branch { + // `--no-overwrite-ignore`: abort rather than silently clobber a locally + // gitignored file if the base branch tracks that path (git's default would + // overwrite ignored files on checkout). Non-ignored untracked files are + // refused by git regardless. + let checkout = + run_git(repo_path, &["checkout", "--no-overwrite-ignore", base_branch]).await?; + if !checkout.status.success() { + return Err(LoopError::Git(format!( + "checkout {base_branch}: {}", + stderr_of(&checkout) + ))); + } + } + let merge = run_git(repo_path, &["merge", "--no-ff", "--no-edit", loop_branch]).await?; + if !merge.status.success() { + let detail = combined_output(&merge); + let _ = run_git(repo_path, &["merge", "--abort"]).await; + if original_branch != base_branch { + let _ = run_git(repo_path, &["checkout", &original_branch]).await; + } + return Ok(MergeOutcome::Conflict { + stage: "merge", + detail, + }); + } + let merge_commit = head_commit(repo_path).await?; + Ok(MergeOutcome::Merged { merge_commit }) +} + +/// Outcome of folding an issue's frozen task commits into the integrate branch +/// (the parallel result-stage fan-in, spec §4.4). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FanInOutcome { + /// Every frozen task commit is integrated and re-validation passed; `tip` is + /// the integrate branch tip the caller CAS-lands onto the issue branch. + Integrated { tip: String }, + /// A task commit's merge conflicted. The in-progress merge (`MERGE_HEAD`) is + /// LEFT in place — NOT aborted — for a resolution agent to finish with + /// `git commit` (preserving both parents). `task_id` is the conflicting task. + Conflict { task_id: i32, detail: String }, + /// All commits merged cleanly, but re-running the issue's validation suite on + /// the integrated tree failed — the combination broke; it must not land. + RevalidationFailed { output: String }, +} + +/// Merge each frozen task commit into the integrate branch (the worktree's +/// current HEAD), in the given topological order, then re-validate the result. +/// +/// Idempotent + resumable: a commit already an ancestor of the integrate tip is +/// skipped, so re-entry after a partial fan-in (crash, or returning to finish +/// after a resolved conflict) only does the remaining work. On conflict it leaves +/// the in-progress merge in place (no `--abort`) and returns `Conflict{task_id}`; +/// the caller dispatches a resolver that MUST `git commit` to complete the merge. +/// +/// Pure git + deterministic validation — no DB, no engine state. The caller owns +/// the manifest session lock + the CAS landing. +pub async fn fan_in_tasks( + integrate_worktree: &Path, + ordered_frozen: &[(i32, String)], + validation_commands: &[String], + iteration_timeout_secs: Option, +) -> Result { + for (task_id, sha) in ordered_frozen { + // Already merged (resumable / idempotent) → skip. + let anc = run_git( + integrate_worktree, + &["merge-base", "--is-ancestor", sha, "HEAD"], + ) + .await?; + if anc.status.success() { + continue; + } + // `--no-edit` + `GIT_MERGE_AUTOEDIT=no`: never open an editor for the merge + // commit message (which would hang a headless engine). `-c user.name/email`: + // a `--no-ff` merge writes a merge commit, which needs a committer identity — + // without it git would *fail the merge* in a checkout that has no configured + // user, which we must not misread as a conflict (see below). + let name_cfg = format!("user.name={ENGINE_NAME}"); + let email_cfg = format!("user.email={ENGINE_EMAIL}"); + let merge = crate::process::tokio_command("git") + .args([ + "-c", + &name_cfg, + "-c", + &email_cfg, + "merge", + "--no-ff", + "--no-edit", + sha.as_str(), + ]) + .current_dir(integrate_worktree) + .env("GIT_MERGE_AUTOEDIT", "no") + .output() + .await + .map_err(|e| LoopError::Git(format!("git merge {sha}: {e}")))?; + if !merge.status.success() { + // A failed merge is only a *conflict* if it left a merge in progress + // (`MERGE_HEAD` + unmerged index). Other failures — a bad/missing object, + // unrelated histories, a rejecting hook — are NOT something a resolution + // agent can fix; dispatching one would spin (it would find nothing to + // resolve). Surface those as a hard error instead of a phantom conflict. + if integrate_in_progress(integrate_worktree).await { + // Leave the in-progress merge for a resolution agent; DO NOT abort. + return Ok(FanInOutcome::Conflict { + task_id: *task_id, + detail: combined_output(&merge), + }); + } + return Err(LoopError::Git(format!( + "fan-in merge of task {task_id} ({sha}) failed without a conflict: {}", + combined_output(&merge) + ))); + } + } + + let commands: Vec = validation_commands + .iter() + .filter(|c| !c.trim().is_empty()) + .cloned() + .collect(); + if !commands.is_empty() { + let report = validation::run_validation( + integrate_worktree, + &commands, + iteration_timeout_secs.map(Duration::from_secs), + ) + .await?; + if !report.passed() { + return Ok(FanInOutcome::RevalidationFailed { + output: report.output, + }); + } + } + let tip = head_commit(integrate_worktree).await?; + Ok(FanInOutcome::Integrated { tip }) +} + +/// Whether the worktree has a merge in progress (`MERGE_HEAD` exists) — a conflict +/// left for a resolver, or a merge mid-commit. Lets the driver tell "resume the +/// in-flight merge" from "start a fresh fan-in". +pub async fn integrate_in_progress(worktree: &Path) -> bool { + run_git(worktree, &["rev-parse", "--verify", "--quiet", "MERGE_HEAD"]) + .await + .map(|o| o.status.success()) + .unwrap_or(false) +} + +/// Atomically advance `branch` from `expected_old` to `new` via +/// `git update-ref refs/heads/{branch} `: succeeds +/// (`Ok(true)`) only if the branch still points at `expected_old`, else a +/// lost-CAS race (`Ok(false)`). Touches only the ref — never a working tree (so +/// the caller must `reset --hard` the issue worktree to the new tip afterward). +pub async fn cas_advance_branch( + repo_path: &Path, + branch: &str, + new: &str, + expected_old: &str, +) -> Result { + let refname = format!("refs/heads/{branch}"); + let out = run_git(repo_path, &["update-ref", &refname, new, expected_old]).await?; + if out.status.success() { + return Ok(true); + } + // A non-zero exit is NOT automatically a lost CAS — `update-ref` also fails on + // lock contention, a bad object, or ref corruption. Treating those as "the + // branch moved" would wrongly discard the whole integration (including any + // conflict-resolution commits). Disambiguate by re-reading the ref: only a tip + // that actually moved off `expected_old` is a genuine lost CAS (`Ok(false)`); + // anything else is a hard error the caller must surface, not swallow. + let cur = run_git(repo_path, &["rev-parse", "--verify", "--quiet", &refname]).await?; + let cur = stdout_trimmed(&cur); + if cur != expected_old { + Ok(false) + } else { + Err(LoopError::Git(format!( + "update-ref {branch} (ref still at expected tip): {}", + stderr_of(&out) + ))) + } +} + +/// Whether `ancestor` is an ancestor of `descendant` in `repo` (`merge-base +/// --is-ancestor`: exit 0 = yes). Operates on the object store, so any path inside +/// the repo works. Backs the fan-in's "already landed" detection. +pub async fn is_ancestor( + repo: &Path, + ancestor: &str, + descendant: &str, +) -> Result { + let out = run_git(repo, &["merge-base", "--is-ancestor", ancestor, descendant]).await?; + Ok(out.status.success()) +} + +/// Remove the worktree directory and its administrative entry (best-effort +/// `--force` to tolerate a dirty tree). The branch is left intact — call +/// [`delete_branch`] separately for paths that should also drop it. +pub async fn remove_worktree(repo_path: &Path, worktree_path: &Path) -> Result<(), LoopError> { + let wt = path_str(worktree_path); + let out = run_git(repo_path, &["worktree", "remove", "--force", &wt]).await?; + if !out.status.success() { + return Err(LoopError::Git(format!( + "worktree remove: {}", + stderr_of(&out) + ))); + } + Ok(()) +} + +/// Delete an engine-owned `loop/*` branch after its worktree has been removed (a +/// branch checked out in a worktree cannot be deleted). `force` selects `-D` +/// (unconditional — for permanent issue/space deletion, which discards unmerged +/// WIP by user intent) versus `-d` (safe — git refuses unless the branch is +/// already merged, used after a successful landing as a guard that we never drop +/// unmerged work). Call sites treat this as best-effort: a missing branch or a +/// safe-delete refusal is not fatal (the create path reconciles any leftover). +pub async fn delete_branch(repo_path: &Path, branch: &str, force: bool) -> Result<(), LoopError> { + let flag = if force { "-D" } else { "-d" }; + let out = run_git(repo_path, &["branch", flag, branch]).await?; + if !out.status.success() { + return Err(LoopError::Git(format!( + "branch {flag} {branch}: {}", + stderr_of(&out) + ))); + } + Ok(()) +} + +/// `(path, branch)` for every worktree registered in `repo_path` +/// (`git worktree list --porcelain`); `branch` is `None` for a detached worktree. +/// Backs the per-issue subtree sweeps below. +pub async fn list_worktrees(repo_path: &Path) -> Result)>, LoopError> { + let out = run_git(repo_path, &["worktree", "list", "--porcelain"]).await?; + if !out.status.success() { + return Err(LoopError::Git(format!("worktree list: {}", stderr_of(&out)))); + } + let text = String::from_utf8_lossy(&out.stdout); + let mut result = Vec::new(); + let mut cur_path: Option = None; + let mut cur_branch: Option = None; + for line in text.lines() { + if let Some(p) = line.strip_prefix("worktree ") { + if let Some(path) = cur_path.take() { + result.push((path, cur_branch.take())); + } + cur_path = Some(p.to_string()); + cur_branch = None; + } else if let Some(b) = line.strip_prefix("branch ") { + cur_branch = Some(b.trim().trim_start_matches("refs/heads/").to_string()); + } + } + if let Some(path) = cur_path.take() { + result.push((path, cur_branch.take())); + } + Ok(result) +} + +/// Best-effort canonical path (resolves symlinks like macOS `/var`→`/private/var`, +/// so `git worktree list`'s real paths compare equal to our data-dir paths); +/// falls back to the raw string when the path no longer exists. +fn canon(p: &str) -> String { + std::fs::canonicalize(p) + .map(|c| c.to_string_lossy().to_string()) + .unwrap_or_else(|_| p.to_string()) +} + +/// Whether `path` is one of an issue's per-task / integrate worktrees — a sibling +/// of `issue_worktree` at `{issue_worktree}-tasks*` or `{issue_worktree}-integrate`. +/// The `-` after the seq disambiguates `issue-1` from `issue-10`. Both sides are +/// canonicalized so a symlinked temp/data dir doesn't defeat the prefix match. +fn is_issue_subtree(path: &str, issue_worktree: &Path) -> bool { + let base = canon(&issue_worktree.to_string_lossy()); + let p = canon(path); + p.starts_with(&format!("{base}-tasks")) || p.starts_with(&format!("{base}-integrate")) +} + +/// Reset every per-task + integrate worktree of an issue to its branch HEAD — +/// boot recovery's clean-tree restore for parallel work (discards only +/// uncommitted crash residue; committed task checkpoints survive). Best-effort. +pub async fn reset_issue_subtree(repo_path: &Path, issue_worktree: &Path) -> Result<(), LoopError> { + for (path, _) in list_worktrees(repo_path).await? { + if is_issue_subtree(&path, issue_worktree) { + let p = Path::new(&path); + if p.exists() { + // NEVER reset a worktree with a merge in progress: the integrate + // worktree's `MERGE_HEAD` (a fan-in conflict awaiting / under a + // resolver) IS the state to preserve, and this sweep runs OUTSIDE + // the fan-in's in-flight gate. `reset --hard` would discard the + // in-progress merge and force the whole conflict to be re-resolved. + // The fan-in's own recovery (`integrate_in_progress`) handles it. + if integrate_in_progress(p).await { + continue; + } + let _ = reset_to_head(p).await; + } + } + } + Ok(()) +} + +/// Remove every per-task + integrate worktree of an issue and (when +/// `delete_branches`) force-delete their branches. Best-effort — used by cancel +/// (keep branches for audit) / merge teardown / permanent delete (drop branches). +pub async fn remove_issue_subtree( + repo_path: &Path, + issue_worktree: &Path, + delete_branches: bool, +) -> Result<(), LoopError> { + for (path, branch) in list_worktrees(repo_path).await? { + if is_issue_subtree(&path, issue_worktree) { + let _ = remove_worktree(repo_path, Path::new(&path)).await; + if delete_branches { + if let Some(b) = branch { + let _ = delete_branch(repo_path, &b, true).await; + } + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::entities::folder::FolderKind; + use crate::db::entities::loop_issue::IssuePriority; + use crate::db::service::loop_service; + use crate::db::test_helpers::{fresh_disk_db, seed_folder}; + use crate::models::loops::IssueConfig; + use std::process::Command as StdCommand; + + fn git(dir: &Path, args: &[&str]) { + let st = StdCommand::new("git") + .args(args) + .current_dir(dir) + .status() + .expect("spawn git"); + assert!(st.success(), "git {args:?} failed"); + } + + fn init_repo(dir: &Path) { + git(dir, &["init", "-q"]); + git(dir, &["config", "user.email", "t@example.com"]); + git(dir, &["config", "user.name", "tester"]); + std::fs::write(dir.join("README.md"), "hello\n").unwrap(); + git(dir, &["add", "-A"]); + git(dir, &["commit", "-q", "-m", "init"]); + } + + /// Build a repo + db + space + issue and return (db, data_dir, issue_id, + /// space_id, seq). Keeps the tempdirs alive via the returned guards. + async fn setup() -> ( + crate::db::AppDatabase, + tempfile::TempDir, + tempfile::TempDir, + i32, + i32, + i32, + ) { + let repo = tempfile::tempdir().unwrap(); + init_repo(repo.path()); + let data = tempfile::tempdir().unwrap(); + let db = fresh_disk_db(data.path()).await; + let folder_id = seed_folder(&db, &repo.path().to_string_lossy()).await; + let space = loop_service::space::create_space(&db.conn, "S", folder_id) + .await + .unwrap(); + let issue = loop_service::issue::create_issue( + &db.conn, + space.id, + "Build it", + "do the thing", + IssuePriority::Medium, + Some(&IssueConfig::default()), + ) + .await + .unwrap(); + (db, repo, data, issue.row.id, space.id, issue.row.seq_no) + } + + async fn mk_done_task( + conn: &DatabaseConnection, + space_id: i32, + issue_id: i32, + title: &str, + ) -> i32 { + loop_service::artifact::create_artifact( + conn, + space_id, + issue_id, + loop_artifact::ArtifactKind::Task, + title, + loop_artifact::ArtifactStatus::Done, + crate::db::entities::loop_artifact_revision::ActorKind::Agent, + None, + ) + .await + .unwrap() + .id + } + + async fn set_contribution( + conn: &DatabaseConnection, + id: i32, + kind: loop_artifact::ContributionKind, + commit: Option, + ) { + let mut am = loop_artifact::Entity::find_by_id(id) + .one(conn) + .await + .unwrap() + .unwrap() + .into_active_model(); + am.contribution_kind = Set(kind); + am.fan_in_commit = Set(commit); + am.update(conn).await.unwrap(); + } + + async fn dep_link(conn: &DatabaseConnection, space_id: i32, from: i32, to: i32) { + // DependsOn: from = successor, to = predecessor. + loop_service::link::create_link(conn, space_id, from, to, LinkKind::DependsOn, None) + .await + .unwrap(); + } + + /// D12: `task_base_ref` recurses through no-op predecessors to the first delta's + /// frozen commit (or the issue branch tip at the chain root), and still errors + /// when a delta predecessor lacks a frozen commit (invariant breach). + #[tokio::test] + async fn task_base_ref_recurses_through_no_op_predecessors() { + let (db, _repo, _data, issue_id, space_id, seq) = setup().await; + let conn = &db.conn; + let commit_a = "a".repeat(40); + + // Chain A(delta, frozen) ← B(no_op) ← C: C's base is A's frozen commit. + let a = mk_done_task(conn, space_id, issue_id, "A").await; + let b = mk_done_task(conn, space_id, issue_id, "B").await; + let c = mk_done_task(conn, space_id, issue_id, "C").await; + set_contribution(conn, a, loop_artifact::ContributionKind::Delta, Some(commit_a.clone())).await; + set_contribution(conn, b, loop_artifact::ContributionKind::NoOp, None).await; + dep_link(conn, space_id, b, a).await; + dep_link(conn, space_id, c, b).await; + assert_eq!(task_base_ref(conn, space_id, seq, c).await.unwrap(), commit_a); + + // A no-op chain rooted at a task with no predecessor resolves to the issue tip. + let d = mk_done_task(conn, space_id, issue_id, "D").await; + let e = mk_done_task(conn, space_id, issue_id, "E").await; + set_contribution(conn, d, loop_artifact::ContributionKind::NoOp, None).await; + dep_link(conn, space_id, e, d).await; + assert_eq!( + task_base_ref(conn, space_id, seq, e).await.unwrap(), + format!("loop/{space_id}/issue-{seq}") + ); + + // A delta predecessor with NO frozen commit is an invariant breach → error. + let f = mk_done_task(conn, space_id, issue_id, "F").await; // delta (default), commit NULL + let g = mk_done_task(conn, space_id, issue_id, "G").await; + dep_link(conn, space_id, g, f).await; + assert!(task_base_ref(conn, space_id, seq, g).await.is_err()); + } + + #[tokio::test] + async fn ensure_worktree_creates_branch_dir_folder_and_records_base() { + let (db, _repo, data, issue_id, space_id, seq) = setup().await; + + let ctx = ensure_worktree(&db.conn, data.path(), issue_id) + .await + .unwrap(); + + assert!(ctx.worktree_path.is_dir(), "worktree dir exists"); + assert_eq!(ctx.branch, format!("loop/{space_id}/issue-{seq}")); + assert!(!ctx.base_branch.is_empty()); + assert_eq!(ctx.base_commit.len(), 40, "full sha recorded"); + + // Folder registered as hidden loop_worktree, parented to the repo folder. + let folder = folder_service::get_folder_by_id(&db.conn, ctx.worktree_folder_id) + .await + .unwrap() + .expect("worktree folder row"); + assert_eq!(folder.kind, FolderKind::LoopWorktree); + + // Issue back-references the worktree and its base. + let issue = loop_service::issue::get_issue(&db.conn, issue_id) + .await + .unwrap() + .unwrap(); + assert_eq!(issue.worktree_folder_id, Some(ctx.worktree_folder_id)); + assert_eq!(issue.base_commit.as_deref(), Some(ctx.base_commit.as_str())); + + // Idempotent: second call re-attaches the same folder, no new worktree. + let ctx2 = ensure_worktree(&db.conn, data.path(), issue_id) + .await + .unwrap(); + assert_eq!(ctx2.worktree_folder_id, ctx.worktree_folder_id); + } + + #[tokio::test] + async fn ensure_worktree_reconciles_leftover_branch_and_dir() { + let (db, repo, data, issue_id, space_id, seq) = setup().await; + let branch = format!("loop/{space_id}/issue-{seq}"); + + // A prior life left the branch behind (teardown keeps it; a DB reset reuses + // the same name). Point it at the *old* HEAD, then advance the base so we + // can prove the re-created worktree starts from the current HEAD, not the + // stale branch tip. + git(repo.path(), &["branch", &branch]); + let stale_tip = git_out(repo.path(), &["rev-parse", &branch]); + std::fs::write(repo.path().join("advance.txt"), "more\n").unwrap(); + git(repo.path(), &["add", "-A"]); + git(repo.path(), &["commit", "-q", "-m", "base advance"]); + let new_head = git_out(repo.path(), &["rev-parse", "HEAD"]); + assert_ne!(stale_tip, new_head, "base advanced past the stale branch"); + + // An orphaned directory also sits exactly where the worktree will go. + let wt = data + .path() + .join("loop-worktrees") + .join(space_id.to_string()) + .join(format!("issue-{seq}")); + std::fs::create_dir_all(&wt).unwrap(); + std::fs::write(wt.join("junk.txt"), "leftover\n").unwrap(); + + // Was fatal: "a branch named 'loop/.../issue-...' already exists". + let ctx = ensure_worktree(&db.conn, data.path(), issue_id) + .await + .unwrap(); + assert_eq!(ctx.branch, branch); + // Branch reset to the current base HEAD; worktree checked out there. + assert_eq!(ctx.base_commit, new_head); + assert_eq!(git_out(&ctx.worktree_path, &["rev-parse", "HEAD"]), new_head); + // The orphaned dir was wiped and recreated clean. + assert!(!ctx.worktree_path.join("junk.txt").exists()); + } + + #[tokio::test] + async fn delete_branch_safe_refuses_unmerged_force_removes() { + let (db, repo, data, issue_id, _space, _seq) = setup().await; + let ctx = ensure_worktree(&db.conn, data.path(), issue_id) + .await + .unwrap(); + loop_commit(&ctx.worktree_path, "feature.txt", "work\n").await; + + // A branch checked out in a worktree can't be deleted — remove it first. + remove_worktree(repo.path(), &ctx.worktree_path) + .await + .unwrap(); + + // Safe delete refuses an unmerged branch (the guard behind the merge path)… + assert!(delete_branch(repo.path(), &ctx.branch, false) + .await + .is_err()); + assert!(branch_exists(repo.path(), &ctx.branch)); + // …force delete drops it (the permanent-delete path). + delete_branch(repo.path(), &ctx.branch, true) + .await + .unwrap(); + assert!(!branch_exists(repo.path(), &ctx.branch)); + } + + #[tokio::test] + async fn checkpoint_commits_changes_then_noops_when_clean() { + let (db, _repo, data, issue_id, _space, _seq) = setup().await; + let ctx = ensure_worktree(&db.conn, data.path(), issue_id) + .await + .unwrap(); + + std::fs::write(ctx.worktree_path.join("feature.txt"), "work\n").unwrap(); + let sha = checkpoint(&ctx.worktree_path, "loop: checkpoint") + .await + .unwrap(); + assert!(sha.is_some(), "non-empty diff produces a commit"); + + let again = checkpoint(&ctx.worktree_path, "loop: checkpoint") + .await + .unwrap(); + assert!(again.is_none(), "clean tree produces no commit"); + } + + #[tokio::test] + async fn reset_to_head_discards_uncommitted_keeps_committed() { + let (db, _repo, data, issue_id, _space, _seq) = setup().await; + let ctx = ensure_worktree(&db.conn, data.path(), issue_id) + .await + .unwrap(); + + // Commit one file, then leave a second uncommitted + an untracked file. + std::fs::write(ctx.worktree_path.join("kept.txt"), "keep\n").unwrap(); + checkpoint(&ctx.worktree_path, "loop: keep") + .await + .unwrap() + .expect("committed"); + std::fs::write(ctx.worktree_path.join("kept.txt"), "dirty\n").unwrap(); + std::fs::write(ctx.worktree_path.join("scratch.txt"), "temp\n").unwrap(); + + reset_to_head(&ctx.worktree_path).await.unwrap(); + + assert_eq!( + std::fs::read_to_string(ctx.worktree_path.join("kept.txt")).unwrap(), + "keep\n", + "committed file restored to HEAD" + ); + assert!( + !ctx.worktree_path.join("scratch.txt").exists(), + "untracked file removed" + ); + } + + fn git_out(dir: &Path, args: &[&str]) -> String { + let out = StdCommand::new("git") + .args(args) + .current_dir(dir) + .output() + .expect("spawn git"); + assert!( + out.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8_lossy(&out.stdout).trim().to_string() + } + + fn branch_exists(repo: &Path, branch: &str) -> bool { + StdCommand::new("git") + .args([ + "rev-parse", + "--verify", + "--quiet", + &format!("refs/heads/{branch}"), + ]) + .current_dir(repo) + .status() + .expect("spawn git") + .success() + } + + /// One loop commit (a feature file) checkpointed onto the issue branch. + async fn loop_commit(worktree_path: &Path, file: &str, body: &str) { + std::fs::write(worktree_path.join(file), body).unwrap(); + checkpoint(worktree_path, &format!("loop: {file}")) + .await + .unwrap() + .expect("committed"); + } + + fn parent_count(repo: &Path) -> usize { + git_out(repo, &["log", "-1", "--format=%P"]) + .split_whitespace() + .count() + } + + #[tokio::test] + async fn merge_clean_base_unchanged_lands_loop() { + let (db, repo, data, issue_id, _space, _seq) = setup().await; + let ctx = ensure_worktree(&db.conn, data.path(), issue_id) + .await + .unwrap(); + loop_commit(&ctx.worktree_path, "feature.txt", "work\n").await; + + let outcome = merge_issue( + repo.path(), + &ctx.worktree_path, + &ctx.branch, + &ctx.base_branch, + &ctx.base_commit, + &[], + None, + ) + .await + .unwrap(); + + assert!(matches!(outcome, MergeOutcome::Merged { .. })); + // The base repo (on the base branch) now carries the loop's work behind a + // no-ff merge commit (two parents). + assert!(repo.path().join("feature.txt").exists()); + assert_eq!(parent_count(repo.path()), 2, "--no-ff merge commit"); + } + + #[tokio::test] + async fn merge_stale_base_integrates_then_lands() { + let (db, repo, data, issue_id, _space, _seq) = setup().await; + let ctx = ensure_worktree(&db.conn, data.path(), issue_id) + .await + .unwrap(); + loop_commit(&ctx.worktree_path, "feature.txt", "work\n").await; + + // Advance the base branch (a non-conflicting file) after the worktree was cut. + std::fs::write(repo.path().join("base-new.txt"), "base\n").unwrap(); + git(repo.path(), &["add", "-A"]); + git(repo.path(), &["commit", "-q", "-m", "base advance"]); + + let outcome = merge_issue( + repo.path(), + &ctx.worktree_path, + &ctx.branch, + &ctx.base_branch, + &ctx.base_commit, + &[], + None, + ) + .await + .unwrap(); + + assert!(matches!(outcome, MergeOutcome::Merged { .. })); + // Both the advanced base file and the loop work are present on the base. + assert!(repo.path().join("base-new.txt").exists()); + assert!(repo.path().join("feature.txt").exists()); + } + + #[tokio::test] + async fn merge_conflict_integrate_aborts_and_preserves_base() { + let (db, repo, data, issue_id, _space, _seq) = setup().await; + let ctx = ensure_worktree(&db.conn, data.path(), issue_id) + .await + .unwrap(); + // Loop and base both edit README differently → integrate conflicts. + loop_commit(&ctx.worktree_path, "README.md", "loop change\n").await; + std::fs::write(repo.path().join("README.md"), "base change\n").unwrap(); + git(repo.path(), &["add", "-A"]); + git(repo.path(), &["commit", "-q", "-m", "base readme"]); + + let outcome = merge_issue( + repo.path(), + &ctx.worktree_path, + &ctx.branch, + &ctx.base_branch, + &ctx.base_commit, + &[], + None, + ) + .await + .unwrap(); + + assert!(matches!( + outcome, + MergeOutcome::Conflict { stage: "integrate", .. } + )); + // Worktree restored (merge aborted) and the base branch untouched. + assert!(is_clean(&ctx.worktree_path).await.unwrap()); + assert_eq!( + std::fs::read_to_string(repo.path().join("README.md")).unwrap(), + "base change\n" + ); + assert_eq!(parent_count(repo.path()), 1, "no merge landed on base"); + } + + #[tokio::test] + async fn merge_revalidation_failure_does_not_land() { + let (db, repo, data, issue_id, _space, _seq) = setup().await; + let ctx = ensure_worktree(&db.conn, data.path(), issue_id) + .await + .unwrap(); + loop_commit(&ctx.worktree_path, "feature.txt", "work\n").await; + // Advance base on a different file so integration is clean. + std::fs::write(repo.path().join("base-new.txt"), "base\n").unwrap(); + git(repo.path(), &["add", "-A"]); + git(repo.path(), &["commit", "-q", "-m", "base advance"]); + + // A validation command that exits non-zero (git is available cross-platform). + let cmds = vec!["git rev-parse --verify refs/heads/no-such-ref".to_string()]; + let outcome = merge_issue( + repo.path(), + &ctx.worktree_path, + &ctx.branch, + &ctx.base_branch, + &ctx.base_commit, + &cmds, + None, + ) + .await + .unwrap(); + + assert!(matches!(outcome, MergeOutcome::RevalidationFailed { .. })); + // The loop work never reached the base branch. + assert!(!repo.path().join("feature.txt").exists()); + } + + #[tokio::test] + async fn merge_dirty_base_refuses() { + let (db, repo, data, issue_id, _space, _seq) = setup().await; + let ctx = ensure_worktree(&db.conn, data.path(), issue_id) + .await + .unwrap(); + loop_commit(&ctx.worktree_path, "feature.txt", "work\n").await; + // Modify a TRACKED file in the base repo (README.md is committed by + // init_repo) — a real hazard a merge could clobber. + std::fs::write(repo.path().join("README.md"), "locally modified\n").unwrap(); + + let outcome = merge_issue( + repo.path(), + &ctx.worktree_path, + &ctx.branch, + &ctx.base_branch, + &ctx.base_commit, + &[], + None, + ) + .await + .unwrap(); + + assert!(matches!(outcome, MergeOutcome::BaseDirty)); + assert!(!repo.path().join("feature.txt").exists(), "nothing landed"); + } + + #[tokio::test] + async fn merge_untracked_base_lands() { + let (db, repo, data, issue_id, _space, _seq) = setup().await; + let ctx = ensure_worktree(&db.conn, data.path(), issue_id) + .await + .unwrap(); + loop_commit(&ctx.worktree_path, "feature.txt", "work\n").await; + // An UNTRACKED file in the base repo must NOT block the merge — it is + // harmless to a --no-ff landing (the common dev-checkout case). + std::fs::write(repo.path().join("scratch.txt"), "untracked\n").unwrap(); + + let outcome = merge_issue( + repo.path(), + &ctx.worktree_path, + &ctx.branch, + &ctx.base_branch, + &ctx.base_commit, + &[], + None, + ) + .await + .unwrap(); + + assert!(matches!(outcome, MergeOutcome::Merged { .. })); + assert!(repo.path().join("feature.txt").exists(), "loop work landed"); + // The untracked file is left untouched. + assert!(repo.path().join("scratch.txt").exists()); + } + + #[tokio::test] + async fn merge_base_gone() { + let (db, repo, data, issue_id, _space, _seq) = setup().await; + let ctx = ensure_worktree(&db.conn, data.path(), issue_id) + .await + .unwrap(); + loop_commit(&ctx.worktree_path, "feature.txt", "work\n").await; + + let outcome = merge_issue( + repo.path(), + &ctx.worktree_path, + &ctx.branch, + "no-such-base", + &ctx.base_commit, + &[], + None, + ) + .await + .unwrap(); + + assert!(matches!(outcome, MergeOutcome::BaseGone)); + } + + // ---- Per-task worktrees (Phase 1) ---- + + async fn mk_task( + db: &crate::db::AppDatabase, + space_id: i32, + issue_id: i32, + title: &str, + ) -> i32 { + use crate::db::entities::loop_artifact::{ArtifactKind, ArtifactStatus}; + use crate::db::entities::loop_artifact_revision::ActorKind; + loop_service::artifact::create_artifact( + &db.conn, + space_id, + issue_id, + ArtifactKind::Task, + title, + ArtifactStatus::Pending, + ActorKind::Agent, + None, + ) + .await + .unwrap() + .id + } + + async fn set_fan_in_commit(db: &crate::db::AppDatabase, task_id: i32, sha: &str) { + let row = loop_artifact::Entity::find_by_id(task_id) + .one(&db.conn) + .await + .unwrap() + .unwrap(); + let mut active = row.into_active_model(); + active.fan_in_commit = Set(Some(sha.to_string())); + active.update(&db.conn).await.unwrap(); + } + + #[tokio::test] + async fn ensure_task_worktree_creates_from_issue_head() { + let (db, _repo, data, issue_id, space_id, seq) = setup().await; + // The issue worktree (and its branch) must exist first — root tasks cut + // from the issue branch tip. + let issue_ctx = ensure_worktree(&db.conn, data.path(), issue_id).await.unwrap(); + let issue_head = git_out(&issue_ctx.worktree_path, &["rev-parse", "HEAD"]); + + let task = mk_task(&db, space_id, issue_id, "T").await; + let ctx = ensure_task_worktree(&db.conn, data.path(), issue_id, task) + .await + .unwrap(); + + assert!(ctx.worktree_path.is_dir()); + assert_eq!(ctx.branch, format!("loop/{space_id}/issue-{seq}-task-{task}")); + assert_eq!(git_out(&ctx.worktree_path, &["rev-parse", "HEAD"]), issue_head); + // Sibling of the issue worktree, never nested under it. + assert!(!ctx.worktree_path.starts_with(&issue_ctx.worktree_path)); + } + + #[tokio::test] + async fn ensure_task_worktree_creates_from_predecessor_frozen_sha() { + let (db, _repo, data, issue_id, space_id, _seq) = setup().await; + let issue_ctx = ensure_worktree(&db.conn, data.path(), issue_id).await.unwrap(); + // The frozen sha the predecessor "produced". + loop_commit(&issue_ctx.worktree_path, "pred.txt", "pred work\n").await; + let frozen = git_out(&issue_ctx.worktree_path, &["rev-parse", "HEAD"]); + + let pred = mk_task(&db, space_id, issue_id, "pred").await; + set_fan_in_commit(&db, pred, &frozen).await; + // Advance the issue branch PAST the frozen sha, to prove the successor + // cuts from the FROZEN commit, never the live tip. + loop_commit(&issue_ctx.worktree_path, "more.txt", "drift\n").await; + let live_tip = git_out(&issue_ctx.worktree_path, &["rev-parse", "HEAD"]); + assert_ne!(frozen, live_tip); + + let succ = mk_task(&db, space_id, issue_id, "succ").await; + loop_service::link::create_link(&db.conn, space_id, succ, pred, LinkKind::DependsOn, None) + .await + .unwrap(); + let ctx = ensure_task_worktree(&db.conn, data.path(), issue_id, succ) + .await + .unwrap(); + + assert_eq!( + git_out(&ctx.worktree_path, &["rev-parse", "HEAD"]), + frozen, + "successor cut from predecessor's frozen sha, not the live tip" + ); + } + + #[tokio::test] + async fn ensure_task_worktree_reattach_validates_identity() { + let (db, _repo, data, issue_id, space_id, _seq) = setup().await; + ensure_worktree(&db.conn, data.path(), issue_id).await.unwrap(); + let task = mk_task(&db, space_id, issue_id, "T").await; + let ctx1 = ensure_task_worktree(&db.conn, data.path(), issue_id, task) + .await + .unwrap(); + + // Corrupt identity: switch the worktree onto a different branch. + git(&ctx1.worktree_path, &["checkout", "-b", "rogue"]); + assert_ne!( + git_out(&ctx1.worktree_path, &["rev-parse", "--abbrev-ref", "HEAD"]), + ctx1.branch + ); + + // Re-attach detects the mismatch and rebuilds onto the task branch. + let ctx2 = ensure_task_worktree(&db.conn, data.path(), issue_id, task) + .await + .unwrap(); + assert_eq!( + git_out(&ctx2.worktree_path, &["rev-parse", "--abbrev-ref", "HEAD"]), + ctx2.branch, + "rebuilt onto the task branch" + ); + } + + #[tokio::test] + async fn ensure_task_worktree_no_b_on_live_branch() { + let (db, _repo, data, issue_id, space_id, _seq) = setup().await; + ensure_worktree(&db.conn, data.path(), issue_id).await.unwrap(); + let task = mk_task(&db, space_id, issue_id, "T").await; + let ctx1 = ensure_task_worktree(&db.conn, data.path(), issue_id, task) + .await + .unwrap(); + loop_commit(&ctx1.worktree_path, "feature.txt", "work\n").await; + let committed = git_out(&ctx1.worktree_path, &["rev-parse", "HEAD"]); + + // Re-attach must NOT rewind a task branch carrying committed work. + let ctx2 = ensure_task_worktree(&db.conn, data.path(), issue_id, task) + .await + .unwrap(); + assert_eq!( + ctx2.worktree_folder_id, ctx1.worktree_folder_id, + "same folder reused" + ); + assert_eq!( + git_out(&ctx2.worktree_path, &["rev-parse", "HEAD"]), + committed, + "committed work preserved (no -B rewind)" + ); + assert!(ctx2.worktree_path.join("feature.txt").exists()); + } + + // ---- Fan-in (Phase 1) ---- + + /// Two independent task commits (distinct files) off `base`, plus an + /// `integrate` branch at `base` checked out. Returns (base, sha_a, sha_b). + fn two_independent_tasks(dir: &Path) -> (String, String, String) { + let base = git_out(dir, &["rev-parse", "HEAD"]); + git(dir, &["checkout", "-q", "-b", "taskA", &base]); + std::fs::write(dir.join("a.txt"), "A\n").unwrap(); + git(dir, &["add", "-A"]); + git(dir, &["commit", "-q", "-m", "A"]); + let sha_a = git_out(dir, &["rev-parse", "HEAD"]); + git(dir, &["checkout", "-q", "-b", "taskB", &base]); + std::fs::write(dir.join("b.txt"), "B\n").unwrap(); + git(dir, &["add", "-A"]); + git(dir, &["commit", "-q", "-m", "B"]); + let sha_b = git_out(dir, &["rev-parse", "HEAD"]); + git(dir, &["checkout", "-q", "-b", "integrate", &base]); + (base, sha_a, sha_b) + } + + #[tokio::test] + async fn fan_in_clean_two_branches() { + let dir = tempfile::tempdir().unwrap(); + init_repo(dir.path()); + let (_base, a, b) = two_independent_tasks(dir.path()); + let out = fan_in_tasks(dir.path(), &[(1, a), (2, b)], &[], None) + .await + .unwrap(); + let tip = match out { + FanInOutcome::Integrated { tip } => tip, + o => panic!("expected Integrated, got {o:?}"), + }; + assert_eq!(git_out(dir.path(), &["rev-parse", "HEAD"]), tip); + assert!(dir.path().join("a.txt").exists() && dir.path().join("b.txt").exists()); + } + + #[tokio::test] + async fn fan_in_skips_already_merged() { + let dir = tempfile::tempdir().unwrap(); + init_repo(dir.path()); + let (_base, a, b) = two_independent_tasks(dir.path()); + let first = fan_in_tasks(dir.path(), &[(1, a.clone()), (2, b.clone())], &[], None) + .await + .unwrap(); + let tip1 = match first { + FanInOutcome::Integrated { tip } => tip, + o => panic!("{o:?}"), + }; + // Re-run the same set → all ancestors → skipped, tip unchanged. + let again = fan_in_tasks(dir.path(), &[(1, a), (2, b)], &[], None) + .await + .unwrap(); + assert_eq!( + again, + FanInOutcome::Integrated { tip: tip1 }, + "already-merged commits are skipped (idempotent)" + ); + } + + #[tokio::test] + async fn fan_in_resume_after_partial() { + let dir = tempfile::tempdir().unwrap(); + init_repo(dir.path()); + let (_base, a, b) = two_independent_tasks(dir.path()); + // Partial: integrate only A. + fan_in_tasks(dir.path(), &[(1, a.clone())], &[], None) + .await + .unwrap(); + assert!(dir.path().join("a.txt").exists() && !dir.path().join("b.txt").exists()); + // Re-enter with [A, B]: A skipped, only B merged. + let out = fan_in_tasks(dir.path(), &[(1, a), (2, b)], &[], None) + .await + .unwrap(); + assert!(matches!(out, FanInOutcome::Integrated { .. })); + assert!( + dir.path().join("b.txt").exists(), + "resume integrated the remaining task" + ); + } + + #[tokio::test] + async fn fan_in_conflict_returns_task() { + let dir = tempfile::tempdir().unwrap(); + init_repo(dir.path()); + let base = git_out(dir.path(), &["rev-parse", "HEAD"]); + // Both tasks edit the SAME file differently → the second merge conflicts. + git(dir.path(), &["checkout", "-q", "-b", "taskA", &base]); + std::fs::write(dir.path().join("README.md"), "A\n").unwrap(); + git(dir.path(), &["add", "-A"]); + git(dir.path(), &["commit", "-q", "-m", "A"]); + let a = git_out(dir.path(), &["rev-parse", "HEAD"]); + git(dir.path(), &["checkout", "-q", "-b", "taskB", &base]); + std::fs::write(dir.path().join("README.md"), "B\n").unwrap(); + git(dir.path(), &["add", "-A"]); + git(dir.path(), &["commit", "-q", "-m", "B"]); + let b = git_out(dir.path(), &["rev-parse", "HEAD"]); + git(dir.path(), &["checkout", "-q", "-b", "integrate", &base]); + + let out = fan_in_tasks(dir.path(), &[(1, a), (2, b)], &[], None) + .await + .unwrap(); + match out { + FanInOutcome::Conflict { task_id, .. } => { + assert_eq!(task_id, 2, "the conflicting (second) task is reported") + } + o => panic!("expected Conflict, got {o:?}"), + } + // The in-progress merge is LEFT for a resolver (not aborted). + assert!( + integrate_in_progress(dir.path()).await, + "MERGE_HEAD preserved for the resolution agent" + ); + } + + #[tokio::test] + async fn fan_in_revalidation_fail() { + let dir = tempfile::tempdir().unwrap(); + init_repo(dir.path()); + let (_base, a, _b) = two_independent_tasks(dir.path()); + // A validation command that exits non-zero (git is cross-platform). + let cmds = vec!["git rev-parse --verify refs/heads/no-such-ref".to_string()]; + let out = fan_in_tasks(dir.path(), &[(1, a)], &cmds, None) + .await + .unwrap(); + assert!(matches!(out, FanInOutcome::RevalidationFailed { .. })); + } + + #[tokio::test] + async fn cas_advance_branch_atomic() { + let dir = tempfile::tempdir().unwrap(); + init_repo(dir.path()); + let base = git_out(dir.path(), &["rev-parse", "HEAD"]); + git(dir.path(), &["checkout", "-q", "-b", "feature", &base]); + std::fs::write(dir.path().join("f.txt"), "f\n").unwrap(); + git(dir.path(), &["add", "-A"]); + git(dir.path(), &["commit", "-q", "-m", "f"]); + let new = git_out(dir.path(), &["rev-parse", "HEAD"]); + git(dir.path(), &["branch", "target", &base]); + + // CAS base→new applies. + assert!(cas_advance_branch(dir.path(), "target", &new, &base) + .await + .unwrap()); + assert_eq!(git_out(dir.path(), &["rev-parse", "target"]), new); + // A stale expected_old now misses, leaving the ref unchanged. + assert!(!cas_advance_branch(dir.path(), "target", &base, &base) + .await + .unwrap()); + assert_eq!( + git_out(dir.path(), &["rev-parse", "target"]), + new, + "ref unchanged after a CAS miss" + ); + } + + // ---- Per-issue subtree lifecycle (Phase 1) ---- + + #[tokio::test] + async fn remove_issue_subtree_removes_task_and_integrate() { + let (db, repo, data, issue_id, space_id, _seq) = setup().await; + let issue_ctx = ensure_worktree(&db.conn, data.path(), issue_id).await.unwrap(); + let t1 = mk_task(&db, space_id, issue_id, "T1").await; + let task_ctx = ensure_task_worktree(&db.conn, data.path(), issue_id, t1) + .await + .unwrap(); + let integ_ctx = + ensure_integrate_worktree(&db.conn, data.path(), issue_id, &issue_ctx.base_commit) + .await + .unwrap(); + assert!(task_ctx.worktree_path.is_dir() && integ_ctx.worktree_path.is_dir()); + + remove_issue_subtree(repo.path(), &issue_ctx.worktree_path, true) + .await + .unwrap(); + + assert!(!task_ctx.worktree_path.exists(), "task worktree removed"); + assert!(!integ_ctx.worktree_path.exists(), "integrate worktree removed"); + assert!(issue_ctx.worktree_path.is_dir(), "issue worktree untouched"); + assert!(!branch_exists(repo.path(), &task_ctx.branch)); + assert!(!branch_exists(repo.path(), &integ_ctx.branch)); + assert!( + branch_exists(repo.path(), &issue_ctx.branch), + "issue branch kept" + ); + } + + #[tokio::test] + async fn reset_issue_subtree_restores_task_worktrees() { + let (db, repo, data, issue_id, space_id, _seq) = setup().await; + let issue_ctx = ensure_worktree(&db.conn, data.path(), issue_id).await.unwrap(); + let t1 = mk_task(&db, space_id, issue_id, "T1").await; + let task_ctx = ensure_task_worktree(&db.conn, data.path(), issue_id, t1) + .await + .unwrap(); + // Commit work, then leave uncommitted residue (simulating a crash). + loop_commit(&task_ctx.worktree_path, "kept.txt", "keep\n").await; + std::fs::write(task_ctx.worktree_path.join("kept.txt"), "dirty\n").unwrap(); + std::fs::write(task_ctx.worktree_path.join("scratch.txt"), "tmp\n").unwrap(); + + reset_issue_subtree(repo.path(), &issue_ctx.worktree_path) + .await + .unwrap(); + + assert_eq!( + std::fs::read_to_string(task_ctx.worktree_path.join("kept.txt")).unwrap(), + "keep\n", + "committed work restored to HEAD" + ); + assert!( + !task_ctx.worktree_path.join("scratch.txt").exists(), + "uncommitted residue discarded" + ); + } + + #[tokio::test] + async fn fan_in_nonconflict_failure_is_hard_error() { + let dir = tempfile::tempdir().unwrap(); + init_repo(dir.path()); + let base = git_out(dir.path(), &["rev-parse", "HEAD"]); + git(dir.path(), &["checkout", "-q", "-b", "integrate", &base]); + // A merge of a non-existent object fails WITHOUT leaving a merge in progress + // (no MERGE_HEAD). That is NOT a conflict a resolver could fix — it must + // surface as a hard error, not a phantom `Conflict`. + let bogus = "0".repeat(40); + let out = fan_in_tasks(dir.path(), &[(7, bogus)], &[], None).await; + assert!(out.is_err(), "non-conflict merge failure is a hard error"); + assert!( + !integrate_in_progress(dir.path()).await, + "no merge left in progress" + ); + } + + #[tokio::test] + async fn cas_advance_branch_hard_error_distinct_from_lost_cas() { + let dir = tempfile::tempdir().unwrap(); + init_repo(dir.path()); + let base = git_out(dir.path(), &["rev-parse", "HEAD"]); + git(dir.path(), &["branch", "target", &base]); + // `update-ref` fails (the new value is not a real object) but the ref is + // STILL at `expected_old` → a hard error, NOT a lost-CAS `Ok(false)` (which + // would wrongly discard a real integration over a transient git fault). + // (A non-zero hex — the all-zero OID is git's delete sentinel.) + let bogus = "deadbeef".repeat(5); // 40 hex chars, not a real object + let res = cas_advance_branch(dir.path(), "target", &bogus, &base).await; + assert!(res.is_err(), "bad-object update-ref is a hard error"); + assert_eq!( + git_out(dir.path(), &["rev-parse", "target"]), + base, + "ref unchanged" + ); + } + + #[tokio::test] + async fn reset_issue_subtree_preserves_in_progress_merge() { + let (db, repo, data, issue_id, _space, _seq) = setup().await; + let issue_ctx = ensure_worktree(&db.conn, data.path(), issue_id).await.unwrap(); + let integ = + ensure_integrate_worktree(&db.conn, data.path(), issue_id, &issue_ctx.base_commit) + .await + .unwrap(); + + // Two commits that edit the SAME file → merging the second into the + // integrate worktree conflicts and leaves MERGE_HEAD. + let base = issue_ctx.base_commit.clone(); + git(repo.path(), &["checkout", "-q", "-b", "tmpA", &base]); + std::fs::write(repo.path().join("README.md"), "A\n").unwrap(); + git(repo.path(), &["add", "-A"]); + git(repo.path(), &["commit", "-q", "-m", "A"]); + let a = git_out(repo.path(), &["rev-parse", "HEAD"]); + git(repo.path(), &["checkout", "-q", "-b", "tmpB", &base]); + std::fs::write(repo.path().join("README.md"), "B\n").unwrap(); + git(repo.path(), &["add", "-A"]); + git(repo.path(), &["commit", "-q", "-m", "B"]); + let b = git_out(repo.path(), &["rev-parse", "HEAD"]); + + let out = fan_in_tasks(&integ.worktree_path, &[(1, a), (2, b)], &[], None) + .await + .unwrap(); + assert!(matches!(out, FanInOutcome::Conflict { .. })); + assert!( + integrate_in_progress(&integ.worktree_path).await, + "MERGE_HEAD set by the conflict" + ); + + // Boot recovery's subtree reset must PRESERVE the in-progress merge so the + // fan-in can recover it — a `reset --hard` would force a full re-resolve. + reset_issue_subtree(repo.path(), &issue_ctx.worktree_path) + .await + .unwrap(); + assert!( + integrate_in_progress(&integ.worktree_path).await, + "in-progress merge preserved (not reset) across boot recovery" + ); + } +} diff --git a/src-tauri/src/models/loop_phase.rs b/src-tauri/src/models/loop_phase.rs new file mode 100644 index 0000000000..ce72a1e097 --- /dev/null +++ b/src-tauri/src/models/loop_phase.rs @@ -0,0 +1,124 @@ +//! The single authoritative loop-phase taxonomy. +//! +//! A loop issue advances through six ordered macro phases. Both the DAG process +//! graph (`ProcessGraph`) and the stage pipeline rail derive their phase grouping +//! from the two total functions here, so the macro pipeline is defined in exactly +//! one place and can never drift between the two views. +//! +//! There are deliberately **two** classifiers — one for artifacts, one for +//! iterations — because some stages run without producing a node (`triage` sets +//! the route but yields no artifact; `finalize` produces a `result` whose +//! iteration `target` is NULL). Conflating them is the bug Codex #B2 flagged. +//! +//! This taxonomy is process-derived and is **not persisted**; it is mirrored in +//! `src/lib/loop-phase.ts` (kept in sync by parity tests on both sides — there is +//! no codegen). Each classifier is an exhaustive `match` with no `_` arm, so a new +//! `ArtifactKind`/`Stage` variant fails to compile until its phase is assigned. + +use serde::{Deserialize, Serialize}; + +use crate::db::entities::loop_artifact::ArtifactKind; +use crate::db::entities::loop_iteration::Stage; + +/// The six ordered macro phases of a loop issue. Declaration order **is** phase +/// order: `Ord` ranks `Issue < Requirement < Design < Implement < Result < +/// Reflect`, which the connector folding relies on to normalize every lineage +/// edge to a canonical `earlier → later` direction. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, +)] +#[serde(rename_all = "snake_case")] +pub enum LoopPhase { + Issue, + Requirement, + Design, + Implement, + Result, + Reflect, +} + +/// Which phase **container** an artifact node lives in. Total over `ArtifactKind`. +/// `task` and `review` both land in `Implement` (a review folds into the task it +/// reviews); `reflection` closes the trace in `Reflect`. +pub fn artifact_phase(kind: ArtifactKind) -> LoopPhase { + match kind { + ArtifactKind::Issue => LoopPhase::Issue, + ArtifactKind::Requirement => LoopPhase::Requirement, + ArtifactKind::Design => LoopPhase::Design, + ArtifactKind::Task | ArtifactKind::Review => LoopPhase::Implement, + ArtifactKind::Result => LoopPhase::Result, + ArtifactKind::Reflection => LoopPhase::Reflect, + } +} + +/// Which phase an **iteration / session** (ghost or sessionRef) belongs to. Total +/// over `Stage`. Deliberately distinct from [`artifact_phase`]: `triage` has no +/// artifact (sits in `Issue`), and `plan`/`implement`/`review` all advance the +/// `Implement` phase, while `finalize` produces the `Result`. +pub fn iteration_phase(stage: Stage) -> LoopPhase { + match stage { + Stage::Triage => LoopPhase::Issue, + Stage::Refine => LoopPhase::Requirement, + Stage::Design => LoopPhase::Design, + Stage::Plan | Stage::Implement | Stage::Review => LoopPhase::Implement, + Stage::Finalize => LoopPhase::Result, + Stage::Reflect => LoopPhase::Reflect, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use sea_orm::Iterable; + + /// Declaration order must equal phase order (the connector folding depends on + /// `Ord` to normalize lineage edges to `earlier → later`). + #[test] + fn phase_order_is_declaration_order() { + assert!(LoopPhase::Issue < LoopPhase::Requirement); + assert!(LoopPhase::Requirement < LoopPhase::Design); + assert!(LoopPhase::Design < LoopPhase::Implement); + assert!(LoopPhase::Implement < LoopPhase::Result); + assert!(LoopPhase::Result < LoopPhase::Reflect); + } + + /// Exhaustive: every `ArtifactKind` maps to the spec's phase. Iterating the + /// enum means a newly-added kind makes this test fail (not just `artifact_phase`'s + /// match), forcing both the mapping and its assertion to be updated together. + #[test] + fn artifact_phase_maps_every_kind() { + for kind in ArtifactKind::iter() { + let phase = artifact_phase(kind); + let expected = match kind { + ArtifactKind::Issue => LoopPhase::Issue, + ArtifactKind::Requirement => LoopPhase::Requirement, + ArtifactKind::Design => LoopPhase::Design, + ArtifactKind::Task => LoopPhase::Implement, + ArtifactKind::Review => LoopPhase::Implement, + ArtifactKind::Result => LoopPhase::Result, + ArtifactKind::Reflection => LoopPhase::Reflect, + }; + assert_eq!(phase, expected, "artifact_phase({kind:?})"); + } + } + + /// Exhaustive: every `Stage` maps to the spec's phase, including the + /// artifact-less stages (`triage` → Issue, `finalize` → Result). + #[test] + fn iteration_phase_maps_every_stage() { + for stage in Stage::iter() { + let phase = iteration_phase(stage); + let expected = match stage { + Stage::Triage => LoopPhase::Issue, + Stage::Refine => LoopPhase::Requirement, + Stage::Design => LoopPhase::Design, + Stage::Plan => LoopPhase::Implement, + Stage::Implement => LoopPhase::Implement, + Stage::Review => LoopPhase::Implement, + Stage::Finalize => LoopPhase::Result, + Stage::Reflect => LoopPhase::Reflect, + }; + assert_eq!(phase, expected, "iteration_phase({stage:?})"); + } + } +} diff --git a/src-tauri/src/models/loops.rs b/src-tauri/src/models/loops.rs new file mode 100644 index 0000000000..fb8a081bbc --- /dev/null +++ b/src-tauri/src/models/loops.rs @@ -0,0 +1,662 @@ +//! DTOs for the loop engineering subsystem. Field names are snake_case so the +//! serialized JSON matches the TypeScript mirrors in `src/lib/types.ts`. Entity +//! enums are reused directly (single source of truth for the wire vocabulary). + +use std::collections::BTreeMap; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use super::agent::AgentType; +use crate::db::entities::loop_artifact::{ + ArtifactKind, ArtifactStatus, ContributionKind, ReviewVerdict, +}; +use crate::db::entities::loop_artifact_revision::ActorKind; +use crate::db::entities::loop_criterion::CriterionKind; +use crate::db::entities::loop_criterion_check::CheckVerdict; +use crate::db::entities::loop_gate_decision::GateOutcome; +use crate::db::entities::loop_inbox_item::{InboxKind, InboxStatus}; +use crate::db::entities::loop_issue::{IssuePriority, IssueRoute, IssueStatus, PauseReason}; +use crate::db::entities::loop_iteration::{ + IterationOutcome, IterationStatus, LaunchedBy, Stage, +}; +use crate::db::entities::loop_link::LinkKind; +use crate::db::entities::loop_memory::{MemoryKind, MemoryStatus, TrustTier}; + +/// An agent plus the same startup mode/config knobs the regular sub-agent +/// settings expose. Used both for each per-stage agent override (a field of +/// [`StageAgents`]) and for each reviewer in a task's review round. Always +/// serialized and parsed as an object (`{"agent": "...", ...}`); empty +/// mode/config are skipped on the wire. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AgentSpec { + pub agent: AgentType, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mode_id: Option, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub config_values: BTreeMap, +} + +/// Historical name retained as an alias to avoid churn at reviewer call sites. +pub type ReviewerSpec = AgentSpec; + +/// The `{"inherit": true}` reviewer form. The bool is always `true`; its +/// presence is what distinguishes inherit from a concrete [`AgentSpec`]. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ReviewerInherit { + pub inherit: bool, +} + +/// One reviewer in [`IssueConfig::reviewers`]: a concrete [`AgentSpec`] object, +/// or the `{"inherit": true}` marker that defers to the issue's default agent at +/// dispatch. Untagged — the `Inherit` arm requires an `inherit` key (which an +/// `AgentSpec` object never carries), so an agent object always parses as `Spec`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(untagged)] +pub enum ReviewerEntry { + /// `{"inherit": true}` — use the issue's default agent. + Inherit(ReviewerInherit), + /// A concrete agent + its startup mode/config. + Spec(AgentSpec), +} + +/// How a task's review round aggregates its reviewer verdicts. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReviewPassRule { + /// Any fail → rework. + Unanimous, + /// Pass if more than half pass. + Majority, +} + +/// Per-stage agent overrides. `default` is required and is used for any stage +/// without an explicit override. There is intentionally no `review` field — +/// reviewers are configured via [`IssueConfig::reviewers`], which resolve their +/// inherit markers against `default`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct StageAgents { + pub default: AgentSpec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub triage: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub refine: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub design: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plan: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub implement: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub finalize: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reflect: Option, +} + +impl StageAgents { + /// The agent spec for a stage: its override if set, else `default`. `Review` + /// resolves to `default` (review dispatch uses the reviewers list, not this). + pub fn for_stage(&self, stage: Stage) -> &AgentSpec { + let o: Option<&AgentSpec> = match stage { + Stage::Triage => self.triage.as_ref(), + Stage::Refine => self.refine.as_ref(), + Stage::Design => self.design.as_ref(), + Stage::Plan => self.plan.as_ref(), + Stage::Implement => self.implement.as_ref(), + Stage::Finalize => self.finalize.as_ref(), + Stage::Reflect => self.reflect.as_ref(), + Stage::Review => None, + }; + o.unwrap_or(&self.default) + } +} + +/// Serde default for [`IssueConfig::oscillation_limit`] (D14). Tolerates older +/// stored configs that predate the field, and is the baseline when none is set. +fn default_oscillation_limit() -> u32 { + 2 +} + +/// Per-issue Loop Contract knobs (stored JSON-encoded in `loop_issue.config`, +/// or in `loop_space.default_config` for the space default). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IssueConfig { + /// Per-stage agents (`default` + optional single-stage overrides). + pub agents: StageAgents, + /// Deterministic verification commands, run in the worktree after implement. + pub validation_commands: Vec, + /// Reviewers to run per task (one review iteration each); the count of + /// concurrent reviews = `reviewers.len()`. Each entry is a concrete + /// [`AgentSpec`] or an inherit marker (defers to `agents.default`). Required + /// non-empty — see [`IssueConfig::validate`]. + pub reviewers: Vec, + /// How reviewer verdicts aggregate. + pub review_pass_rule: ReviewPassRule, + /// Node rework cap before the no-progress breaker trips. + pub max_attempts: u32, + /// D14: consecutive same-signature blocked epochs before a task is promoted + /// from an ordinary retryable `no_progress` card to an `oscillation` card (a + /// deterministic failure plain retry can't fix — needs an explicit human exit). + /// `0` = off (no oscillation breaker), honoring "no artificial limits". Defaults + /// to 2; `#[serde(default)]` keeps configs stored before this field readable. + #[serde(default = "default_oscillation_limit")] + pub oscillation_limit: u32, + /// When false (default), result merge requires human approval. + pub auto_merge: bool, + /// Human override of the triage-decided route, if any. + pub force_route: Option, + /// Optional per-iteration wall-clock cap (none = unlimited). + pub iteration_timeout_secs: Option, + /// Optional per-turn token soft cap (none = unlimited). + pub token_budget_per_turn: Option, + /// Optional watchdog: file a `stalled` inbox card when an iteration has been + /// in flight (turn running, not yet settled) for at least this many seconds. + /// `tokens_used` only lands at settle, so there is no mid-turn progress + /// counter to diff — elapsed-since-start is the honest in-flight signal. None + /// = off = no alert (honors "no artificial limits"). Never auto-cancels — + /// only surfaces to the human, who decides whether to step in. + pub stall_alert_secs: Option, +} + +impl Default for IssueConfig { + fn default() -> Self { + Self { + agents: StageAgents { + default: AgentSpec { + agent: AgentType::ClaudeCode, + mode_id: None, + config_values: BTreeMap::new(), + }, + triage: None, + refine: None, + design: None, + plan: None, + implement: None, + finalize: None, + reflect: None, + }, + validation_commands: Vec::new(), + // One reviewer that inherits the default agent. + reviewers: vec![ReviewerEntry::Inherit(ReviewerInherit { inherit: true })], + review_pass_rule: ReviewPassRule::Unanimous, + max_attempts: 6, + oscillation_limit: 2, + auto_merge: false, + force_route: None, + iteration_timeout_secs: None, + token_budget_per_turn: None, + stall_alert_secs: None, + } + } +} + +impl IssueConfig { + /// Resolve each reviewer slot to a concrete agent: an inherit marker becomes + /// `agents.default` (carrying its mode/config); a concrete entry passes + /// through unchanged. + pub fn effective_reviewers(&self) -> Vec { + self.reviewers + .iter() + .map(|e| match e { + ReviewerEntry::Spec(s) => s.clone(), + ReviewerEntry::Inherit(_) => self.agents.default.clone(), + }) + .collect() + } + + /// Validate a config before it is stored (D7): reject shapes the engine could + /// never dispatch on. An empty reviewer list would leave every task with no + /// review round. `max_attempts == 0` is intentionally allowed — the engine + /// reads it as "unlimited / no no-progress breaker" (honoring "no artificial + /// limits"), so it is a valid setting, not an error. + pub fn validate(&self) -> Result<(), &'static str> { + if self.reviewers.is_empty() { + return Err("reviewers must not be empty"); + } + Ok(()) + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct LoopSpaceSummary { + pub id: i32, + pub name: String, + pub folder_id: i32, + pub folder_path: Option, + /// True when the bound folder is soft-deleted or missing (read-only space). + pub detached: bool, + pub issue_count: i64, + pub running_count: i64, + /// Pending-inbox attention rolled up across the space's issues (D6/D7). + /// `blocking` = approval/blocked/budget/question; `notice` = reflection_failed. + pub blocking_count: i64, + pub notice_count: i64, + pub last_activity_at: Option>, + pub created_at: DateTime, + /// Space default issue config (parsed). Always present — every space stores a + /// concrete config that inheriting issues resolve against. + pub default_config: IssueConfig, +} + +/// One space's pending-inbox attention, for the global sidebar badge (D7). +#[derive(Debug, Clone, Serialize)] +pub struct LoopSpaceAttention { + pub space_id: i32, + pub blocking: i64, + pub notice: i64, +} + +/// Cross-space attention rollup powering the always-visible "who needs me" badge +/// (D6/D7). `get_loop_attention` returns it; the sidebar subscribes to refresh it. +#[derive(Debug, Clone, Serialize)] +pub struct LoopAttention { + pub total_blocking: i64, + pub total_notice: i64, + pub per_space: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct LoopIssueRow { + pub id: i32, + pub space_id: i32, + pub seq_no: i32, + pub title: String, + pub priority: IssuePriority, + pub status: IssueStatus, + pub pause_reason: Option, + pub route: IssueRoute, + pub token_used: i64, + pub token_budget: Option, + /// Pending-inbox attention for this issue (D6/D7), same split as the space. + pub blocking_count: i64, + pub notice_count: i64, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize)] +pub struct LoopIssueDetail { + #[serde(flatten)] + pub row: LoopIssueRow, + pub description: String, + /// The issue's own config, or `None` to inherit the space default. The + /// resolved effective config is computed at read time, not stored here. + pub config: Option, + pub worktree_folder_id: Option, + pub base_branch: Option, + pub base_commit: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct LoopArtifactRow { + pub id: i32, + pub issue_id: i32, + pub issue_seq: i32, + pub kind: ArtifactKind, + pub title: String, + pub status: ArtifactStatus, + pub origin: ActorKind, + pub produced_by_iteration_id: Option, + pub verdict: Option, + pub attempt: i32, + /// D12: delta vs agent-declared no-op for a Done task (drives the drawer badge). + pub contribution_kind: ContributionKind, + pub sort: i32, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize)] +pub struct LoopRevision { + pub id: i32, + pub seq: i32, + pub content: String, + pub actor_kind: ActorKind, + pub iteration_id: Option, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Serialize)] +pub struct LoopCriterionRow { + pub id: i32, + pub label: String, + pub text: String, + pub sort: i32, + pub kind: CriterionKind, +} + +#[derive(Debug, Clone, Serialize)] +pub struct LoopLinkRow { + pub id: i32, + pub from_artifact_id: i32, + pub to_artifact_id: i32, + pub kind: LinkKind, + /// For design→requirement `derives_from` edges: the requirement revision this + /// design derived from (lineage content snapshot). `None` for other edges. + pub source_revision_id: Option, +} + +/// One criterion-level coverage edge: `task_artifact_id` claims it satisfies +/// `criterion_id` (an acceptance criterion on some requirement). +#[derive(Debug, Clone, Serialize)] +pub struct LoopCoverageRow { + pub id: i32, + pub task_artifact_id: i32, + pub criterion_id: i32, +} + +/// One reviewer's structured pass/fail of one criterion (§3.4). `iteration_id` +/// is the stable reviewer identity for per-criterion quorum aggregation; +/// `scope_artifact_id` is the artifact judged (a task, or the result for the +/// integration gate). +#[derive(Debug, Clone, Serialize)] +pub struct LoopCriterionCheckRow { + pub id: i32, + pub criterion_id: i32, + pub iteration_id: i32, + pub scope_artifact_id: i32, + pub verdict: CheckVerdict, + pub evidence: String, +} + +/// Immutable gate-decision audit row: the aggregated `outcome` of a gate over +/// `target_artifact_id` at `(stage, attempt)`, plus the check ids it aggregated. +#[derive(Debug, Clone, Serialize)] +pub struct LoopGateDecisionRow { + pub id: i32, + pub target_artifact_id: i32, + pub stage: String, + pub attempt: i32, + pub outcome: GateOutcome, + pub input_check_ids: Vec, + pub created_at: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct LoopArtifactDetail { + #[serde(flatten)] + pub row: LoopArtifactRow, + pub revisions: Vec, + pub criteria: Vec, + pub links: Vec, +} + +/// The iteration that produced an artifact, resolved within the issue (P3 agent +/// facet). Carried on [`LoopDagView`] so the graph can overlay the producing +/// agent/session and the per-artifact attempt count without an extra round-trip. +/// +/// Only emitted for artifacts whose `produced_by_iteration_id` resolves to an +/// iteration in THIS issue — orphan / cross-issue references are omitted, and the +/// frontend infers an unresolved producer from "facet on, but no ref for this +/// node". Always a resolved reference, so `iteration_id`/`stage`/`status` are +/// non-null. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "snake_case")] +pub struct ArtifactIterationRef { + pub artifact_id: i32, + pub iteration_id: i32, + pub stage: Stage, + pub status: IterationStatus, + pub outcome: Option, + /// From `conversation.agent_type` (the serde wire form of `AgentType`); `None` + /// when the iteration has no conversation. The frontend treats it as its + /// `AgentType` union (with an icon fallback for unknown values). + pub agent_type: Option, + /// `None` when the iteration has no conversation (no session to open). + pub conversation_id: Option, + /// Per-kind, issue-bounded attempt count: task/requirement/design/reflection = + /// iterations targeting the artifact; result = finalize iterations; review = 1. + pub attempt_count: i32, +} + +/// Per-issue DAG payload (nodes + edges) for the graph/board views. +#[derive(Debug, Clone, Serialize)] +pub struct LoopDagView { + pub artifacts: Vec, + pub links: Vec, + /// Criterion-level coverage edges across this issue (task → criterion). + pub coverage: Vec, + /// Per-criterion review checks across this issue (the trace matrix). + pub criterion_checks: Vec, + /// Immutable gate decisions across this issue (task review + integration). + pub gate_decisions: Vec, + /// In-flight (`queued`|`running`) iterations — drives real-time ghost nodes + + /// the stage rail (spec D1). Output artifacts don't exist yet for these, so + /// they're carried alongside the landed DAG, not as artifacts. + pub live_iterations: Vec, + /// Resolved producing-iteration references for this issue's artifacts (P3 agent + /// facet). ALWAYS present — `[]` when nothing resolves — so the frontend can + /// detect the capability via `Array.isArray`. Older servers omit the field + /// entirely, which the frontend reads as "facet unavailable". + pub artifact_iteration_refs: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct LoopIterationRow { + pub id: i32, + pub issue_id: i32, + pub issue_seq: i32, + pub stage: Stage, + pub target_artifact_id: Option, + pub target_title: Option, + pub conversation_id: Option, + /// Producing agent, joined from `conversation.agent_type` (P3 facet). `None` + /// when the iteration has no conversation, or on older servers that omit it. + pub agent_type: Option, + pub status: IterationStatus, + pub launched_by: LaunchedBy, + pub attempt: i32, + pub tokens_used: i64, + /// Why the run ended (D11). `None` while in flight or for a settled implement + /// run before its checkpoint; the UI renders no outcome badge for `None`. + pub outcome: Option, + pub created_at: DateTime, + pub started_at: Option>, + pub ended_at: Option>, +} + +#[derive(Debug, Clone, Serialize)] +pub struct LoopValidationRunRow { + pub id: i32, + pub task_artifact_id: i32, + pub iteration_id: Option, + pub commands: Vec, + pub exit_codes: Vec, + pub passed: bool, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Serialize)] +pub struct LoopInboxItemRow { + pub id: i32, + pub issue_id: i32, + pub issue_seq: i32, + pub iteration_id: Option, + pub kind: InboxKind, + pub subject_key: String, + pub payload: serde_json::Value, + pub status: InboxStatus, + /// The artifact this card concerns, resolved at read time (D9). `None` for + /// issue-level cards with no backing artifact (budget, coverage gap, …). + pub subject_artifact_id: Option, + /// That artifact's title, so the card is self-contained without a second fetch. + pub subject_title: Option, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Serialize)] +pub struct LoopMemoryRow { + pub id: i32, + pub kind: MemoryKind, + pub source: ActorKind, + pub title: String, + pub summary: Option, + pub content: String, + pub trust_tier: TrustTier, + pub status: MemoryStatus, + pub superseded_by: Option, + pub source_issue_id: Option, + pub source_artifact_id: Option, + pub produced_by_iteration_id: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// Coarse cache-invalidation event (`loop://changed`). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LoopChanged { + pub v: u32, + pub space_id: i32, + pub issue_id: Option, + pub subject_kind: String, + pub subject_id: i32, + pub kind: String, +} + +/// Event name for [`LoopChanged`]. Lives beside the payload so both the command +/// layer (CRUD writes) and the engine (autonomous dispatch/settle) emit the same +/// channel without one depending on the other. +pub const LOOP_CHANGED_EVENT: &str = "loop://changed"; + +#[cfg(test)] +mod tests { + use super::*; + + /// A per-stage agent spec with no mode/config override. + fn bare(agent: AgentType) -> AgentSpec { + AgentSpec { + agent, + mode_id: None, + config_values: BTreeMap::new(), + } + } + + /// A `StageAgents` with the given default and no single-stage overrides. + fn stage_agents(default: AgentSpec) -> StageAgents { + StageAgents { + default, + triage: None, + refine: None, + design: None, + plan: None, + implement: None, + finalize: None, + reflect: None, + } + } + + #[test] + fn effective_reviewers_prefers_explicit_list() { + let cfg = IssueConfig { + reviewers: vec![ReviewerEntry::Spec(ReviewerSpec { + agent: AgentType::Gemini, + mode_id: Some("auto".to_string()), + config_values: BTreeMap::new(), + })], + ..IssueConfig::default() + }; + let r = cfg.effective_reviewers(); + assert_eq!(r.len(), 1); + assert_eq!(r[0].agent, AgentType::Gemini); + assert_eq!(r[0].mode_id.as_deref(), Some("auto")); + } + + #[test] + fn effective_reviewers_resolves_inherit_to_default() { + // An inherit entry resolves to `agents.default` (carrying its + // mode/config); concrete entries pass through unchanged. + let cfg = IssueConfig { + agents: stage_agents(AgentSpec { + agent: AgentType::Codex, + mode_id: Some("auto".to_string()), + config_values: BTreeMap::new(), + }), + reviewers: vec![ + ReviewerEntry::Inherit(ReviewerInherit { inherit: true }), + ReviewerEntry::Spec(bare(AgentType::Gemini)), + ], + ..IssueConfig::default() + }; + let r = cfg.effective_reviewers(); + assert_eq!(r.len(), 2); + assert_eq!(r[0].agent, AgentType::Codex); // inherit → default + assert_eq!(r[0].mode_id.as_deref(), Some("auto")); + assert_eq!(r[1].agent, AgentType::Gemini); // concrete passthrough + } + + #[test] + fn reviewer_entry_parses_object_and_inherit_forms() { + // A full object and the inherit marker parse; the inherit marker + // round-trips as `{"inherit":true}`. Bare strings no longer parse. + let json = r#"[{"agent":"gemini","mode_id":"auto"},{"inherit":true}]"#; + let entries: Vec = serde_json::from_str(json).unwrap(); + assert_eq!(entries.len(), 2); + assert!(matches!( + &entries[0], + ReviewerEntry::Spec(s) + if s.agent == AgentType::Gemini && s.mode_id.as_deref() == Some("auto") + )); + assert!(matches!(&entries[1], ReviewerEntry::Inherit(_))); + assert_eq!( + serde_json::to_string(&entries[1]).unwrap(), + r#"{"inherit":true}"# + ); + // A bare agent string is rejected. + assert!(serde_json::from_str::(r#""codex""#).is_err()); + } + + #[test] + fn issue_config_round_trips_clean_no_v_no_count() { + let cfg = IssueConfig::default(); + let json = serde_json::to_string(&cfg).unwrap(); + assert!(!json.contains("\"v\""), "no version tag: {json}"); + assert!(!json.contains("reviewer_count"), "no reviewer_count: {json}"); + let back: IssueConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(back.reviewers.len(), 1); + assert_eq!(back.agents.default.agent, AgentType::ClaudeCode); + assert_eq!(back.review_pass_rule, ReviewPassRule::Unanimous); + } + + #[test] + fn agents_object_form_only() { + // Bare strings are no longer accepted for stage agents. + let bad = r#"{"default":"codex"}"#; + assert!(serde_json::from_str::(bad).is_err()); + let ok = r#"{"default":{"agent":"codex"},"implement":{"agent":"gemini","mode_id":"auto"}}"#; + let a: StageAgents = serde_json::from_str(ok).unwrap(); + assert_eq!(a.for_stage(Stage::Implement).agent, AgentType::Gemini); + assert_eq!(a.for_stage(Stage::Implement).mode_id.as_deref(), Some("auto")); + assert_eq!(a.for_stage(Stage::Plan).agent, AgentType::Codex); // falls back to default + assert_eq!(a.for_stage(Stage::Review).agent, AgentType::Codex); // review → default + } + + #[test] + fn validate_rejects_empty_reviewers() { + let cfg = IssueConfig { + reviewers: vec![], + ..IssueConfig::default() + }; + assert!(cfg.validate().is_err()); + assert!(IssueConfig::default().validate().is_ok()); + } + + #[test] + fn agent_spec_parses_full_object_and_round_trips() { + let mut cv = BTreeMap::new(); + cv.insert("reasoning".to_string(), "high".to_string()); + let spec = AgentSpec { + agent: AgentType::Gemini, + mode_id: Some("plan".into()), + config_values: cv, + }; + let json = serde_json::to_string(&spec).unwrap(); + let back: AgentSpec = serde_json::from_str(&json).unwrap(); + assert_eq!(back, spec); + // Empty extras serialize to the minimal object form. + let bare = AgentSpec { + agent: AgentType::Codex, + mode_id: None, + config_values: BTreeMap::new(), + }; + assert_eq!(serde_json::to_string(&bare).unwrap(), r#"{"agent":"codex"}"#); + } +} diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index e81c669b78..4d048f341c 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -2,6 +2,8 @@ pub mod agent; pub mod chat_channel; pub mod conversation; pub mod folder; +pub mod loop_phase; +pub mod loops; pub mod message; pub mod model_provider; pub mod pet; diff --git a/src-tauri/src/observability.rs b/src-tauri/src/observability.rs new file mode 100644 index 0000000000..0a91b8b4b1 --- /dev/null +++ b/src-tauri/src/observability.rs @@ -0,0 +1,14 @@ +//! Process-wide structured logging init (§2.10a). Idempotent — safe to call once +//! from each binary's startup; a second call is ignored (`try_init`). The loop +//! engine emits `tracing` events with structured fields (issue/iteration ids, +//! errors) so an operator can follow a run; everything else stays at `info`. +//! +//! The filter is env-overridable via `RUST_LOG` (e.g. +//! `RUST_LOG=codeg_lib::loop_engine=trace`); absent that, the engine logs at +//! `debug` and the rest of the process at `info`. +pub fn init_tracing() { + use tracing_subscriber::{fmt, EnvFilter}; + let filter = EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new("info,codeg_lib::loop_engine=debug")); + let _ = fmt().with_env_filter(filter).with_target(true).try_init(); +} diff --git a/src-tauri/src/parsers/codex.rs b/src-tauri/src/parsers/codex.rs index 71808c72ed..a0b7fefb65 100644 --- a/src-tauri/src/parsers/codex.rs +++ b/src-tauri/src/parsers/codex.rs @@ -2164,6 +2164,58 @@ mod tests { let _ = fs::remove_file(path); } + /// D11 regression: a loop iteration's headless Codex rollout begins with a + /// `developer` environment message before the real `user` briefing, then an + /// assistant reply + tool calls. The turn builder must NOT be stranded by the + /// leading developer message — it must still produce turns (so a settled + /// iteration's dialog renders agent messages, not an empty transcript). This + /// mirrors the record shape observed in real `~/.codex/sessions` loop rollouts. + #[test] + fn parse_detail_loop_rollout_shape_produces_turns() { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time ok") + .as_nanos(); + let path: PathBuf = env::temp_dir().join(format!("codeg-codex-loop-{nanos}.jsonl")); + + let content = concat!( + "{\"timestamp\":\"2026-03-01T10:00:00Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"loop-1\",\"cwd\":\"/tmp/wt\"}}\n", + "{\"timestamp\":\"2026-03-01T10:00:00.100Z\",\"type\":\"turn_context\",\"payload\":{\"model\":\"gpt-5-codex\"}}\n", + // Leading developer environment message (NOT a user turn boundary). + "{\"timestamp\":\"2026-03-01T10:00:00.200Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"developer\",\"content\":[{\"type\":\"input_text\",\"text\":\"/tmp/wt\"}]}}\n", + // The real briefing, sent as a user message — the turn boundary. + "{\"timestamp\":\"2026-03-01T10:00:00.300Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"## Briefing\\nImplement the widget.\"}]}}\n", + "{\"timestamp\":\"2026-03-01T10:00:05.000Z\",\"type\":\"event_msg\",\"payload\":{\"type\":\"agent_message\",\"message\":\"Implemented the widget.\"}}\n", + "{\"timestamp\":\"2026-03-01T10:00:05.001Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Implemented the widget.\"}]}}\n", + "{\"timestamp\":\"2026-03-01T10:00:05.100Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"function_call\",\"name\":\"shell\",\"arguments\":\"{}\",\"call_id\":\"c1\"}}\n", + "{\"timestamp\":\"2026-03-01T10:00:05.200Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"function_call_output\",\"call_id\":\"c1\",\"output\":\"ok\"}}\n" + ); + fs::write(&path, content).expect("write test jsonl"); + + let parser = CodexParser::new(); + let detail = parser + .parse_conversation_detail(&path, "loop-1") + .expect("parse detail ok"); + + // The whole point: a settled loop rollout is NOT an empty transcript. + assert!( + !detail.turns.is_empty(), + "loop rollout must produce turns despite the leading developer message" + ); + let assistant = detail + .turns + .iter() + .find(|t| matches!(t.role, TurnRole::Assistant)) + .expect("assistant turn present"); + let rendered = format!("{:?}", assistant.blocks); + assert!( + rendered.contains("Implemented the widget."), + "assistant turn carries the agent's message" + ); + + let _ = fs::remove_file(path); + } + #[test] fn codex_home_env_overrides_default_home() { let resolved = resolve_codex_home_dir_from( diff --git a/src-tauri/src/web/handlers/acp.rs b/src-tauri/src/web/handlers/acp.rs index c338c54ae1..4b5cfb7a28 100644 --- a/src-tauri/src/web/handlers/acp.rs +++ b/src-tauri/src/web/handlers/acp.rs @@ -89,6 +89,7 @@ pub async fn acp_connect( emitter, params.preferred_mode_id, params.preferred_config_values.unwrap_or_default(), + None, // not a loop iteration ) .await .map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?; diff --git a/src-tauri/src/web/handlers/loops.rs b/src-tauri/src/web/handlers/loops.rs new file mode 100644 index 0000000000..ae64c2d850 --- /dev/null +++ b/src-tauri/src/web/handlers/loops.rs @@ -0,0 +1,536 @@ +use std::sync::Arc; + +use axum::{extract::Extension, Json}; +use serde::Deserialize; + +use crate::app_error::AppCommandError; +use crate::app_state::AppState; +use crate::commands::loops as core; +use crate::db::entities::loop_inbox_item::InboxStatus; +use crate::db::entities::loop_issue::{IssuePriority, IssueStatus}; +use crate::db::entities::loop_iteration::Stage; +use crate::db::entities::loop_memory::{MemoryKind, MemoryStatus}; +use crate::models::loops::{ + IssueConfig, LoopArtifactDetail, LoopArtifactRow, LoopAttention, LoopDagView, LoopInboxItemRow, + LoopIssueDetail, LoopIssueRow, LoopIterationRow, LoopMemoryRow, LoopSpaceSummary, + LoopValidationRunRow, +}; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IdParam { + pub id: i32, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SpaceIdParam { + pub space_id: i32, +} + +// ─── Spaces ────────────────────────────────────────────────────────────── + +pub async fn list_loop_spaces( + Extension(state): Extension>, +) -> Result>, AppCommandError> { + Ok(Json(core::list_loop_spaces_core(&state.db.conn).await?)) +} + +pub async fn get_loop_engine_health( + Extension(state): Extension>, +) -> Result, AppCommandError> { + Ok(Json( + core::get_loop_engine_health_core(&state.loop_engine).await?, + )) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateSpaceParams { + pub name: String, + pub folder_id: i32, +} + +pub async fn create_loop_space( + Extension(state): Extension>, + Json(p): Json, +) -> Result, AppCommandError> { + Ok(Json( + core::create_loop_space_core(&state.db.conn, &state.emitter, p.name, p.folder_id).await?, + )) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateSpaceParams { + pub id: i32, + pub name: String, +} + +pub async fn update_loop_space( + Extension(state): Extension>, + Json(p): Json, +) -> Result, AppCommandError> { + Ok(Json( + core::update_loop_space_core(&state.db.conn, &state.emitter, p.id, p.name).await?, + )) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SetSpaceDefaultConfigParams { + pub id: i32, + pub config: IssueConfig, +} + +pub async fn set_loop_space_default_config( + Extension(state): Extension>, + Json(p): Json, +) -> Result, AppCommandError> { + core::set_loop_space_default_config_core(&state.db.conn, &state.emitter, p.id, p.config).await?; + Ok(Json(())) +} + +pub async fn delete_loop_space( + Extension(state): Extension>, + Json(p): Json, +) -> Result, AppCommandError> { + core::delete_loop_space_core(&state.db.conn, &state.emitter, p.id).await?; + Ok(Json(())) +} + +// ─── Issues ────────────────────────────────────────────────────────────── + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListIssuesParams { + pub space_id: i32, + pub statuses: Option>, +} + +pub async fn list_loop_issues( + Extension(state): Extension>, + Json(p): Json, +) -> Result>, AppCommandError> { + Ok(Json( + core::list_loop_issues_core(&state.db.conn, p.space_id, p.statuses).await?, + )) +} + +pub async fn get_loop_issue( + Extension(state): Extension>, + Json(p): Json, +) -> Result>, AppCommandError> { + Ok(Json(core::get_loop_issue_core(&state.db.conn, p.id).await?)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateIssueParams { + pub space_id: i32, + pub title: String, + pub description: String, + pub priority: IssuePriority, + pub config: Option, +} + +pub async fn create_loop_issue( + Extension(state): Extension>, + Json(p): Json, +) -> Result, AppCommandError> { + Ok(Json( + core::create_loop_issue_core( + &state.db.conn, + &state.emitter, + p.space_id, + p.title, + p.description, + p.priority, + p.config, + ) + .await?, + )) +} + +pub async fn delete_loop_issue( + Extension(state): Extension>, + Json(p): Json, +) -> Result, AppCommandError> { + core::delete_loop_issue_core(&state.db.conn, &state.emitter, p.id).await?; + Ok(Json(())) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateIssueConfigParams { + pub id: i32, + /// `None` = inherit the space default. + pub config: Option, + pub token_budget: Option, +} + +pub async fn update_loop_issue_config( + Extension(state): Extension>, + Json(p): Json, +) -> Result, AppCommandError> { + core::update_loop_issue_config_core( + &state.db.conn, + &state.emitter, + p.id, + p.config, + p.token_budget, + ) + .await?; + Ok(Json(())) +} + +// ─── Engine actions (trigger / pause / resume / cancel) ───────────────────── + +pub async fn trigger_loop_issue( + Extension(state): Extension>, + Json(p): Json, +) -> Result, AppCommandError> { + core::trigger_loop_issue_core(&state.db.conn, &state.emitter, &state.loop_engine, p.id).await?; + Ok(Json(())) +} + +pub async fn pause_loop_issue( + Extension(state): Extension>, + Json(p): Json, +) -> Result, AppCommandError> { + core::pause_loop_issue_core(&state.db.conn, &state.emitter, &state.loop_engine, p.id).await?; + Ok(Json(())) +} + +pub async fn resume_loop_issue( + Extension(state): Extension>, + Json(p): Json, +) -> Result, AppCommandError> { + core::resume_loop_issue_core(&state.db.conn, &state.emitter, &state.loop_engine, p.id).await?; + Ok(Json(())) +} + +pub async fn cancel_loop_issue( + Extension(state): Extension>, + Json(p): Json, +) -> Result, AppCommandError> { + core::cancel_loop_issue_core(&state.db.conn, &state.emitter, &state.loop_engine, p.id).await?; + Ok(Json(())) +} + +pub async fn retry_loop_issue( + Extension(state): Extension>, + Json(p): Json, +) -> Result, AppCommandError> { + core::retry_loop_issue_core(&state.db.conn, &state.emitter, &state.loop_engine, p.id).await?; + Ok(Json(())) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TaskIdParam { + pub task_id: i32, +} + +pub async fn force_complete_loop_task( + Extension(state): Extension>, + Json(p): Json, +) -> Result, AppCommandError> { + core::force_complete_loop_task_core( + &state.db.conn, + &state.emitter, + &state.loop_engine, + p.task_id, + ) + .await?; + Ok(Json(())) +} + +pub async fn override_loop_oscillation( + Extension(state): Extension>, + Json(p): Json, +) -> Result, AppCommandError> { + core::override_loop_oscillation_core( + &state.db.conn, + &state.emitter, + &state.loop_engine, + p.task_id, + ) + .await?; + Ok(Json(())) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AddBudgetParams { + pub id: i32, + pub additional: i64, +} + +pub async fn add_loop_issue_budget( + Extension(state): Extension>, + Json(p): Json, +) -> Result, AppCommandError> { + core::add_loop_issue_budget_core( + &state.db.conn, + &state.emitter, + &state.loop_engine, + p.id, + p.additional, + ) + .await?; + Ok(Json(())) +} + +pub async fn approve_loop_merge( + Extension(state): Extension>, + Json(p): Json, +) -> Result, AppCommandError> { + core::approve_loop_merge_core(&state.db.conn, &state.emitter, &state.loop_engine, p.id).await?; + Ok(Json(())) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RejectMergeParams { + pub id: i32, + pub comment: Option, +} + +pub async fn reject_loop_merge( + Extension(state): Extension>, + Json(p): Json, +) -> Result, AppCommandError> { + core::reject_loop_merge_core( + &state.db.conn, + &state.emitter, + &state.loop_engine, + p.id, + p.comment, + ) + .await?; + Ok(Json(())) +} + +pub async fn approve_loop_design( + Extension(state): Extension>, + Json(p): Json, +) -> Result, AppCommandError> { + core::approve_loop_design_core(&state.db.conn, &state.emitter, &state.loop_engine, p.id).await?; + Ok(Json(())) +} + +pub async fn reject_loop_design( + Extension(state): Extension>, + Json(p): Json, +) -> Result, AppCommandError> { + core::reject_loop_design_core( + &state.db.conn, + &state.emitter, + &state.loop_engine, + p.id, + p.comment, + ) + .await?; + Ok(Json(())) +} + +// ─── Artifacts / DAG ─────────────────────────────────────────────────────── + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IssueIdParam { + pub issue_id: i32, +} + +pub async fn get_loop_dag( + Extension(state): Extension>, + Json(p): Json, +) -> Result, AppCommandError> { + Ok(Json(core::get_loop_dag_core(&state.db.conn, p.issue_id).await?)) +} + +pub async fn list_loop_artifacts( + Extension(state): Extension>, + Json(p): Json, +) -> Result>, AppCommandError> { + Ok(Json( + core::list_loop_artifacts_core(&state.db.conn, p.space_id).await?, + )) +} + +pub async fn get_loop_artifact( + Extension(state): Extension>, + Json(p): Json, +) -> Result>, AppCommandError> { + Ok(Json(core::get_loop_artifact_core(&state.db.conn, p.id).await?)) +} + +// ─── Iterations ──────────────────────────────────────────────────────────── + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListIterationsParams { + pub space_id: i32, + pub issue_id: Option, +} + +pub async fn list_loop_iterations( + Extension(state): Extension>, + Json(p): Json, +) -> Result>, AppCommandError> { + Ok(Json( + core::list_loop_iterations_core(&state.db.conn, p.space_id, p.issue_id).await?, + )) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ArtifactIdParam { + pub artifact_id: i32, +} + +pub async fn get_loop_artifact_iterations( + Extension(state): Extension>, + Json(p): Json, +) -> Result>, AppCommandError> { + Ok(Json( + core::get_loop_artifact_iterations_core(&state.db.conn, p.artifact_id).await?, + )) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PhaseIterationsParams { + pub issue_id: i32, + pub stage: Stage, +} + +pub async fn get_loop_phase_iterations( + Extension(state): Extension>, + Json(p): Json, +) -> Result>, AppCommandError> { + Ok(Json( + core::get_loop_phase_iterations_core(&state.db.conn, p.issue_id, p.stage).await?, + )) +} + +pub async fn list_loop_validations( + Extension(state): Extension>, + Json(p): Json, +) -> Result>, AppCommandError> { + Ok(Json( + core::list_loop_validations_core(&state.db.conn, p.space_id).await?, + )) +} + +// ─── Inbox ───────────────────────────────────────────────────────────────── + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListInboxParams { + pub space_id: i32, + pub status: Option, +} + +pub async fn list_loop_inbox( + Extension(state): Extension>, + Json(p): Json, +) -> Result>, AppCommandError> { + Ok(Json( + core::list_loop_inbox_core(&state.db.conn, p.space_id, p.status).await?, + )) +} + +pub async fn get_loop_attention( + Extension(state): Extension>, +) -> Result, AppCommandError> { + Ok(Json(core::get_loop_attention_core(&state.db.conn).await?)) +} + +pub async fn dismiss_loop_inbox( + Extension(state): Extension>, + Json(p): Json, +) -> Result, AppCommandError> { + core::dismiss_loop_inbox_core(&state.db.conn, &state.emitter, p.id).await?; + Ok(Json(())) +} + +// ─── Memory ──────────────────────────────────────────────────────────────── + +pub async fn list_loop_memory( + Extension(state): Extension>, + Json(p): Json, +) -> Result>, AppCommandError> { + Ok(Json( + core::list_loop_memory_core(&state.db.conn, p.space_id).await?, + )) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateMemoryParams { + pub space_id: i32, + pub kind: MemoryKind, + pub title: String, + pub content: String, +} + +pub async fn create_loop_memory( + Extension(state): Extension>, + Json(p): Json, +) -> Result, AppCommandError> { + Ok(Json( + core::create_loop_memory_core( + &state.db.conn, + &state.emitter, + p.space_id, + p.kind, + p.title, + p.content, + ) + .await?, + )) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateMemoryParams { + pub space_id: i32, + pub id: i32, + pub title: String, + pub content: String, + pub status: MemoryStatus, +} + +pub async fn update_loop_memory( + Extension(state): Extension>, + Json(p): Json, +) -> Result, AppCommandError> { + core::update_loop_memory_core( + &state.db.conn, + &state.emitter, + p.space_id, + p.id, + p.title, + p.content, + p.status, + ) + .await?; + Ok(Json(())) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteMemoryParams { + pub space_id: i32, + pub id: i32, +} + +pub async fn delete_loop_memory( + Extension(state): Extension>, + Json(p): Json, +) -> Result, AppCommandError> { + core::delete_loop_memory_core(&state.db.conn, &state.emitter, p.space_id, p.id).await?; + Ok(Json(())) +} diff --git a/src-tauri/src/web/handlers/mod.rs b/src-tauri/src/web/handlers/mod.rs index 163023d41d..f18f683ddc 100644 --- a/src-tauri/src/web/handlers/mod.rs +++ b/src-tauri/src/web/handlers/mod.rs @@ -12,6 +12,7 @@ pub mod files; pub mod folder_commands; pub mod folders; pub mod git; +pub mod loops; pub mod mcp; pub mod model_provider; pub mod pet; diff --git a/src-tauri/src/web/mod.rs b/src-tauri/src/web/mod.rs index d657cd9fbb..d41d8d0077 100644 --- a/src-tauri/src/web/mod.rs +++ b/src-tauri/src/web/mod.rs @@ -831,6 +831,12 @@ pub(crate) async fn do_start_web_server_tauri( .state::() .inner() .clone(), + // Reuse the desktop-managed loop engine so HTTP-side trigger/pause + // commands drive the very drivers the desktop process is running. + loop_engine: app + .state::>() + .inner() + .clone(), }); // See do_start_web_server_with_state for rationale on the reset. diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index c47432ee61..eb3e2b5801 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -56,6 +56,83 @@ pub fn build_router( "/list_child_conversations", post(handlers::conversations::list_child_conversations), ) + // ─── Loop engineering ─── + .route("/list_loop_spaces", post(handlers::loops::list_loop_spaces)) + .route("/create_loop_space", post(handlers::loops::create_loop_space)) + .route("/update_loop_space", post(handlers::loops::update_loop_space)) + .route( + "/set_loop_space_default_config", + post(handlers::loops::set_loop_space_default_config), + ) + .route("/delete_loop_space", post(handlers::loops::delete_loop_space)) + .route("/list_loop_issues", post(handlers::loops::list_loop_issues)) + .route("/get_loop_issue", post(handlers::loops::get_loop_issue)) + .route("/create_loop_issue", post(handlers::loops::create_loop_issue)) + .route("/delete_loop_issue", post(handlers::loops::delete_loop_issue)) + .route( + "/update_loop_issue_config", + post(handlers::loops::update_loop_issue_config), + ) + .route("/trigger_loop_issue", post(handlers::loops::trigger_loop_issue)) + .route("/pause_loop_issue", post(handlers::loops::pause_loop_issue)) + .route("/resume_loop_issue", post(handlers::loops::resume_loop_issue)) + .route("/cancel_loop_issue", post(handlers::loops::cancel_loop_issue)) + .route("/retry_loop_issue", post(handlers::loops::retry_loop_issue)) + .route( + "/force_complete_loop_task", + post(handlers::loops::force_complete_loop_task), + ) + .route( + "/override_loop_oscillation", + post(handlers::loops::override_loop_oscillation), + ) + .route( + "/add_loop_issue_budget", + post(handlers::loops::add_loop_issue_budget), + ) + .route("/approve_loop_merge", post(handlers::loops::approve_loop_merge)) + .route("/reject_loop_merge", post(handlers::loops::reject_loop_merge)) + .route("/approve_loop_design", post(handlers::loops::approve_loop_design)) + .route("/reject_loop_design", post(handlers::loops::reject_loop_design)) + .route("/get_loop_dag", post(handlers::loops::get_loop_dag)) + .route( + "/get_loop_engine_health", + post(handlers::loops::get_loop_engine_health), + ) + .route( + "/list_loop_artifacts", + post(handlers::loops::list_loop_artifacts), + ) + .route("/get_loop_artifact", post(handlers::loops::get_loop_artifact)) + .route( + "/list_loop_iterations", + post(handlers::loops::list_loop_iterations), + ) + .route( + "/get_loop_artifact_iterations", + post(handlers::loops::get_loop_artifact_iterations), + ) + .route( + "/get_loop_phase_iterations", + post(handlers::loops::get_loop_phase_iterations), + ) + .route( + "/list_loop_validations", + post(handlers::loops::list_loop_validations), + ) + .route("/list_loop_inbox", post(handlers::loops::list_loop_inbox)) + .route( + "/get_loop_attention", + post(handlers::loops::get_loop_attention), + ) + .route( + "/dismiss_loop_inbox", + post(handlers::loops::dismiss_loop_inbox), + ) + .route("/list_loop_memory", post(handlers::loops::list_loop_memory)) + .route("/create_loop_memory", post(handlers::loops::create_loop_memory)) + .route("/update_loop_memory", post(handlers::loops::update_loop_memory)) + .route("/delete_loop_memory", post(handlers::loops::delete_loop_memory)) .route( "/get_delegation_settings", post(handlers::delegation::get_delegation_settings), diff --git a/src-tauri/tests/delegation_e2e_uds.rs b/src-tauri/tests/delegation_e2e_uds.rs index ebf541237e..0f83c389a5 100644 --- a/src-tauri/tests/delegation_e2e_uds.rs +++ b/src-tauri/tests/delegation_e2e_uds.rs @@ -19,7 +19,7 @@ use codeg_lib::acp::delegation::broker::{ ConversationDepthLookup, DelegationBroker, DelegationConfig, }; use codeg_lib::acp::delegation::listener::{ - DelegationListener, ParentSessionLookup, TokenEntry, TokenRegistry, + DelegationListener, LoopIngestAccess, ParentSessionLookup, TokenEntry, TokenRegistry, }; use codeg_lib::acp::delegation::spawner::{mock::MockSpawner, ConnectionSpawner}; use codeg_lib::acp::delegation::transport::{ @@ -64,6 +64,21 @@ impl codeg_lib::acp::feedback::SessionFeedbackAccess for NoFeedback { async fn commit_feedback_delivered(&self, _parent_connection_id: &str, _ids: Vec) {} } +/// No-op loop-ingest access — this e2e suite exercises delegation/feedback/ask, +/// not loop submissions. Passed as the 6th `DelegationListener::new` arg. +struct NoLoopIngest; +#[async_trait] +impl LoopIngestAccess for NoLoopIngest { + async fn loop_ingest( + &self, + _token: &str, + _tool: &str, + _payload: &serde_json::Value, + ) -> Result { + Err("loop ingest not wired in this test".into()) + } +} + /// Controllable question access for the ask round-trip test: `register_question` /// parks a sender keyed by a freshly-minted id; the test pops it via /// `take_pending` and resolves it, exactly as a user answering the card would. @@ -150,6 +165,7 @@ async fn end_to_end_uds_happy_path() { Arc::new(FixedParent(1)) as Arc, Arc::new(NoFeedback) as Arc, Arc::new(StubQuestions::default()) as Arc, + Arc::new(NoLoopIngest) as Arc, ); // PID-scoped socket inside the OS temp dir — no clashes across test bins. @@ -264,6 +280,7 @@ async fn end_to_end_uds_batch_status() { Arc::new(FixedParent(1)) as Arc, Arc::new(NoFeedback) as Arc, Arc::new(StubQuestions::default()) as Arc, + Arc::new(NoLoopIngest) as Arc, ); let dir = tempfile::tempdir().unwrap(); @@ -349,6 +366,7 @@ async fn end_to_end_uds_invalid_token_rejected() { Arc::new(FixedParent(1)) as Arc, Arc::new(NoFeedback) as Arc, Arc::new(StubQuestions::default()) as Arc, + Arc::new(NoLoopIngest) as Arc, ); let dir = tempfile::tempdir().unwrap(); @@ -413,6 +431,7 @@ async fn end_to_end_uds_ask_question_round_trip() { Arc::new(FixedParent(1)) as Arc, Arc::new(NoFeedback) as Arc, questions.clone() as Arc, + Arc::new(NoLoopIngest) as Arc, ); let dir = tempfile::tempdir().unwrap(); @@ -550,6 +569,7 @@ async fn end_to_end_uds_ask_revoked_after_register_declines() { Arc::new(FixedParent(1)) as Arc, Arc::new(NoFeedback) as Arc, questions as Arc, + Arc::new(NoLoopIngest) as Arc, ); let dir = tempfile::tempdir().unwrap(); diff --git a/src-tauri/tests/delegation_e2e_windows.rs b/src-tauri/tests/delegation_e2e_windows.rs index 512983cfc0..5f1fcf7ca4 100644 --- a/src-tauri/tests/delegation_e2e_windows.rs +++ b/src-tauri/tests/delegation_e2e_windows.rs @@ -15,7 +15,7 @@ use codeg_lib::acp::delegation::broker::{ ConversationDepthLookup, DelegationBroker, DelegationConfig, }; use codeg_lib::acp::delegation::listener::{ - DelegationListener, ParentSessionLookup, TokenEntry, TokenRegistry, + DelegationListener, LoopIngestAccess, ParentSessionLookup, TokenEntry, TokenRegistry, }; use codeg_lib::acp::delegation::spawner::{mock::MockSpawner, ConnectionSpawner}; use codeg_lib::acp::delegation::transport::{ @@ -55,6 +55,20 @@ impl codeg_lib::acp::feedback::SessionFeedbackAccess for NoFeedback { async fn commit_feedback_delivered(&self, _parent_connection_id: &str, _ids: Vec) {} } +/// No-op loop-ingest access — this e2e suite exercises delegation, not loops. +struct NoLoopIngest; +#[async_trait] +impl LoopIngestAccess for NoLoopIngest { + async fn loop_ingest( + &self, + _token: &str, + _tool: &str, + _payload: &serde_json::Value, + ) -> Result { + Err("loop ingest not wired in this test".into()) + } +} + /// No-op question access — this e2e suite exercises delegation, not asks. struct NoQuestions; #[async_trait] @@ -157,6 +171,7 @@ async fn end_to_end_named_pipe_happy_path() { Arc::new(FixedParent(1)) as Arc, Arc::new(NoFeedback) as Arc, Arc::new(NoQuestions) as Arc, + Arc::new(NoLoopIngest) as Arc, ); let pipe = unique_pipe("happy"); @@ -257,6 +272,7 @@ async fn end_to_end_named_pipe_back_to_back_requests() { Arc::new(FixedParent(1)) as Arc, Arc::new(NoFeedback) as Arc, Arc::new(NoQuestions) as Arc, + Arc::new(NoLoopIngest) as Arc, ); let pipe = unique_pipe("repeat"); diff --git a/src/app/workspace/layout.tsx b/src/app/workspace/layout.tsx index f12ac893ec..1c0afe7622 100644 --- a/src/app/workspace/layout.tsx +++ b/src/app/workspace/layout.tsx @@ -2,12 +2,14 @@ import { Suspense, + lazy, useMemo, useCallback, useEffect, useRef, useState, } from "react" +import { Loader2 } from "lucide-react" import type { ImperativePanelGroupHandle } from "react-resizable-panels" import { FolderTitleBar } from "@/components/layout/folder-title-bar" import { useIsActiveChatMode } from "@/hooks/use-is-active-chat-mode" @@ -30,6 +32,7 @@ import { import { DelegationProvider } from "@/contexts/delegation-context" import { ConversationRuntimeProvider } from "@/contexts/conversation-runtime-context" import { TabProvider, useTabContext } from "@/contexts/tab-context" +import { useLoopNav } from "@/hooks/use-loop-nav" import { SessionStatsProvider } from "@/contexts/session-stats-context" import { SidebarProvider, useSidebarContext } from "@/contexts/sidebar-context" import { SearchDialogProvider } from "@/contexts/search-dialog-context" @@ -78,6 +81,12 @@ function WorkspaceDocumentTitle() { return null } +const LoopsWorkbench = lazy(() => + import("@/components/loops/loops-workbench").then((m) => ({ + default: m.LoopsWorkbench, + })) +) + const TOAST_DURATION_MS = 15000 const WORKSPACE_PANEL_GROUP_ID = "workspace-panel-group" const WORKSPACE_CONVERSATION_PANEL_ID = "workspace-conversation-panel" @@ -396,6 +405,7 @@ function FolderWorkspaceShell({ children }: { children: React.ReactNode }) { maxHeight: terminalMaxHeight, setHeight: setTerminalHeight, } = useTerminalContext() + const { nav } = useLoopNav() const shellGroupRef = useRef(null) const mainGroupRef = useRef(null) @@ -726,43 +736,66 @@ function FolderWorkspaceShell({ children }: { children: React.ReactNode }) { ref={mainContainerRef} className="flex h-full min-h-0 flex-col overflow-hidden" > - - - {children} - - - - - -
- -
-
-
+ + {children} + + + + + +
+ +
+
+ + + + {nav.loops ? ( +
+ + +
+ } + > + + + + ) : null} @@ -790,6 +823,32 @@ function FolderWorkspaceShell({ children }: { children: React.ReactNode }) { ) } +/** + * Selecting a chat tab (the active tab id changing) drops the workspace back to + * the chat surface — but, unlike the old localStorage view, it leaves loop nav + * (space/issue/tab) in the URL so returning to loops restores it. Lives inside + * TabProvider so it can read the active tab id. + */ +function LoopsTabSync({ children }: { children: React.ReactNode }) { + const { activeTabId } = useTabContext() + const { nav, exitLoops } = useLoopNav() + const prevTabRef = useRef(undefined) + useEffect(() => { + // Skip the initial mount (review NB2) so a deep-linked loops view is NOT + // exited on hydration — only a genuine later tab switch drops to chat. + if (prevTabRef.current === undefined) { + prevTabRef.current = activeTabId + return + } + if (prevTabRef.current !== activeTabId) { + prevTabRef.current = activeTabId + // exitLoops is a URL write (not a React setState), so no set-state-in-effect. + if (nav.loops) exitLoops() + } + }, [activeTabId, nav.loops, exitLoops]) + return <>{children} +} + function FolderLayoutShell({ children }: { children: React.ReactNode }) { const isMobile = useIsMobile() @@ -835,9 +894,11 @@ function WorkspaceLayoutInner({ children }: { children: React.ReactNode }) { - - {children} - + + + {children} + + diff --git a/src/components/layout/sidebar.test.tsx b/src/components/layout/sidebar.test.tsx index de3ddee4ba..c5be6b0f07 100644 --- a/src/components/layout/sidebar.test.tsx +++ b/src/components/layout/sidebar.test.tsx @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest" import { Sidebar } from "./sidebar" import enMessages from "@/i18n/messages/en.json" +import { DEFAULT_LOOP_NAV } from "@/lib/loop-nav" // Stable spies + mutable active-folder, referenced from the hoisted mock // factories below (vi.mock is hoisted above imports). @@ -11,6 +12,7 @@ const spies = vi.hoisted(() => ({ openNewConversationTab: vi.fn(), openChatModeTab: vi.fn(), setSearchOpen: vi.fn(), + toggleLoops: vi.fn(), })) const mockState = vi.hoisted(() => ({ activeFolder: { id: 7, path: "/x" } as { id: number; path: string } | null, @@ -36,6 +38,17 @@ vi.mock("@/contexts/tab-context", () => ({ vi.mock("@/contexts/search-dialog-context", () => ({ useSearchDialog: () => ({ open: false, setOpen: spies.setSearchOpen }), })) +vi.mock("@/hooks/use-loop-nav", () => ({ + useLoopNav: () => ({ + nav: { ...DEFAULT_LOOP_NAV }, + toggleLoops: spies.toggleLoops, + }), +})) +// The Loops entry shows an attention badge fed by this hook; stub it so the test +// doesn't reach the transport (the badge's own behavior is covered elsewhere). +vi.mock("@/hooks/use-loop-attention-badge", () => ({ + useLoopAttentionBadge: () => ({ totalBlocking: 0, totalNotice: 0 }), +})) vi.mock("@/hooks/use-is-mac", () => ({ useIsMac: () => false })) vi.mock("@/hooks/use-shortcut-settings", () => ({ useShortcutSettings: () => ({ diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 3ee02a6b9a..d33fafb03b 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -8,9 +8,13 @@ import { Funnel, Search, SquarePen, + Workflow, } from "lucide-react" import { useTranslations } from "next-intl" import { useActiveFolder } from "@/contexts/active-folder-context" +import { useLoopNav } from "@/hooks/use-loop-nav" +import { useLoopAttentionBadge } from "@/hooks/use-loop-attention-badge" +import { AttentionBadges } from "@/components/loops/attention-badges" import { useSidebarContext } from "@/contexts/sidebar-context" import { useTabContext } from "@/contexts/tab-context" import { useSearchDialog } from "@/contexts/search-dialog-context" @@ -63,6 +67,8 @@ export function Sidebar() { const { activeFolder } = useActiveFolder() const { openNewConversationTab, openChatModeTab } = useTabContext() const { setOpen: setSearchOpen } = useSearchDialog() + const { nav, toggleLoops } = useLoopNav() + const loopAttention = useLoopAttentionBadge() const isMac = useIsMac() const { shortcuts } = useShortcutSettings() const isMobile = useIsMobile() @@ -240,6 +246,29 @@ export function Sidebar() { {searchShortcutLabel} ) : null} + {!isMobile ? ( + + ) : null} {/* On mobile, clicking a conversation card auto-closes the Sheet */} diff --git a/src/components/loops/artifact-drawer.test.tsx b/src/components/loops/artifact-drawer.test.tsx new file mode 100644 index 0000000000..70a98f4ffb --- /dev/null +++ b/src/components/loops/artifact-drawer.test.tsx @@ -0,0 +1,324 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { ArtifactDrawer } from "./artifact-drawer" +import type { LoopArtifactDetail, LoopIssueDetail } from "@/lib/types" + +// next-intl: stable identity translator that echoes the key (project mock +// convention) — assertions match key strings / verbatim content, not English. +const stableT = (key: string) => key +vi.mock("next-intl", () => ({ useTranslations: () => stableT })) + +vi.mock("sonner", () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +vi.mock("@/components/loops/loop-realtime-context", () => ({ + useLoopRealtime: () => ({ register: () => () => {} }), +})) + +// MessageResponse (Streamdown) pulls in the link-safety hook (workspace +// context) and heavy markdown deps jsdom lacks; stub it to a passthrough that +// renders the raw content so content assertions stay simple. +vi.mock("@/components/ai-elements/message", () => ({ + MessageResponse: ({ children }: { children: string }) => ( +
{children}
+ ), +})) + +const getLoopArtifact = vi.fn() +const getLoopIssue = vi.fn() +const getLoopDag = vi.fn() +const approveLoopDesign = vi.fn().mockResolvedValue(undefined) +const rejectLoopDesign = vi.fn().mockResolvedValue(undefined) +const approveLoopMerge = vi.fn().mockResolvedValue(undefined) +const rejectLoopMerge = vi.fn().mockResolvedValue(undefined) +const listLoopIterations = vi.fn().mockResolvedValue([]) +const listLoopInbox = vi.fn().mockResolvedValue([]) +vi.mock("@/lib/loops-api", () => ({ + getLoopArtifact: (...a: unknown[]) => getLoopArtifact(...a), + getLoopIssue: (...a: unknown[]) => getLoopIssue(...a), + getLoopDag: (...a: unknown[]) => getLoopDag(...a), + approveLoopDesign: (...a: unknown[]) => approveLoopDesign(...a), + rejectLoopDesign: (...a: unknown[]) => rejectLoopDesign(...a), + approveLoopMerge: (...a: unknown[]) => approveLoopMerge(...a), + rejectLoopMerge: (...a: unknown[]) => rejectLoopMerge(...a), + listLoopIterations: (...a: unknown[]) => listLoopIterations(...a), + listLoopInbox: (...a: unknown[]) => listLoopInbox(...a), +})) + +// The drawer reads `nav.space` to scope its iteration/inbox lookups and opens +// sessions through the overlays context; stub both so the body renders solo. +vi.mock("@/hooks/use-loop-nav", () => ({ + useLoopNav: () => ({ nav: { space: 1 } }), +})) +const openIteration = vi.fn() +vi.mock("@/components/loops/loop-overlays-context", () => ({ + useLoopOverlays: () => ({ openIteration }), +})) + +// Radix Sheet/Dialog portal through to document.body and need browser APIs jsdom +// lacks; stub them as plain wrappers that honor `open` so the drawer's own +// structure (sections, diffs, gates) is what's under test. +vi.mock("@/components/ui/sheet", () => ({ + Sheet: ({ open, children }: { open: boolean; children: React.ReactNode }) => + open ?
{children}
: null, + SheetContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + SheetHeader: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + SheetTitle: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + SheetDescription: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})) +vi.mock("@/components/ui/dialog", () => ({ + Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) => + open ?
{children}
: null, + DialogContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogHeader: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogFooter: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogTitle: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})) +vi.mock("@/components/ui/scroll-area", () => ({ + ScrollArea: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})) + +function artifact(over: Partial): LoopArtifactDetail { + return { + id: 1, + issue_id: 5, + issue_seq: 1, + kind: "task", + title: "Artifact", + status: "done", + origin: "agent", + produced_by_iteration_id: null, + verdict: null, + contribution_kind: "delta", + attempt: 0, + sort: 0, + updated_at: "2026-06-14T00:00:00Z", + revisions: [], + criteria: [], + links: [], + ...over, + } +} + +function issue(status: LoopIssueDetail["status"]): LoopIssueDetail { + return { + id: 5, + space_id: 1, + seq_no: 1, + title: "Issue", + priority: "medium", + status, + pause_reason: null, + route: "full", + token_used: 0, + token_budget: null, + blocking_count: 0, + notice_count: 0, + created_at: "2026-06-14T00:00:00Z", + updated_at: "2026-06-14T00:00:00Z", + description: "", + config: null, + worktree_folder_id: null, + base_branch: null, + base_commit: null, + } +} + +beforeEach(() => { + vi.clearAllMocks() + getLoopIssue.mockResolvedValue(issue("running")) + getLoopDag.mockResolvedValue({ + artifacts: [], + links: [], + coverage: [], + criterion_checks: [], + gate_decisions: [], + }) +}) + +describe("ArtifactDrawer", () => { + it("renders content, criteria, and a review verdict", async () => { + getLoopArtifact.mockResolvedValue( + artifact({ + kind: "review", + verdict: "fail", + produced_by_iteration_id: 42, + revisions: [ + { + id: 10, + seq: 1, + content: "review body text", + actor_kind: "agent", + iteration_id: 42, + created_at: "2026-06-14T00:00:00Z", + }, + ], + criteria: [ + { + id: 1, + label: "C1", + text: "must compile", + sort: 0, + kind: "acceptance", + }, + ], + }) + ) + render( {}} />) + + expect(await screen.findByText("review body text")).toBeInTheDocument() + expect(screen.getByText("C1")).toBeInTheDocument() + expect(screen.getByText("fail")).toBeInTheDocument() // verdict badge + expect(screen.getByText("producedBy")).toBeInTheDocument() // linked iteration + // A review is not a gate — no approve/merge controls. + expect(screen.queryByText("approve")).not.toBeInTheDocument() + expect(screen.queryByText("merge")).not.toBeInTheDocument() + }) + + it("shows covered-by and an uncovered warning on a requirement", async () => { + getLoopArtifact.mockResolvedValue( + artifact({ + id: 7, + kind: "requirement", + title: "R1", + criteria: [ + { + id: 100, + label: "AC-1", + text: "alpha", + sort: 0, + kind: "acceptance", + }, + { id: 101, label: "AC-2", text: "beta", sort: 1, kind: "acceptance" }, + ], + }) + ) + getLoopDag.mockResolvedValue({ + artifacts: [{ id: 200, title: "Build alpha", kind: "task" }], + links: [], + // AC-1 (id 100) is covered; AC-2 (id 101) is not. + coverage: [{ id: 1, task_artifact_id: 200, criterion_id: 100 }], + // AC-1 has a passing task-review check; AC-2 has none. + criterion_checks: [ + { + id: 1, + criterion_id: 100, + iteration_id: 1, + scope_artifact_id: 200, + verdict: "pass", + evidence: "ok", + }, + ], + gate_decisions: [], + }) + render( {}} />) + + expect(await screen.findByText("coveredBy")).toBeInTheDocument() // AC-1 + expect(screen.getByText("uncovered")).toBeInTheDocument() // AC-2 + // Each criterion shows its typed kind badge (echoed key under the mock). + expect(screen.getAllByText("acceptance").length).toBeGreaterThan(0) + }) + + it("renders a colored line diff between adjacent revisions", async () => { + getLoopArtifact.mockResolvedValue( + artifact({ + kind: "design", + status: "done", + revisions: [ + { + id: 1, + seq: 1, + content: "alpha", + actor_kind: "agent", + iteration_id: null, + created_at: "2026-06-14T00:00:00Z", + }, + { + id: 2, + seq: 2, + content: "alpha\nbeta", + actor_kind: "agent", + iteration_id: null, + created_at: "2026-06-14T00:01:00Z", + }, + ], + }) + ) + render( {}} />) + + // The added line is tagged with the add color; the unchanged line is context. + const added = await screen.findByText("beta") + expect(added).toHaveClass("text-emerald-700") + expect(screen.getByText("alpha")).toHaveClass("text-muted-foreground") + }) + + it("approves a design gate via approveLoopDesign(issue_id)", async () => { + getLoopArtifact.mockResolvedValue( + artifact({ kind: "design", status: "awaiting_approval", issue_id: 5 }) + ) + render( {}} />) + + fireEvent.click(await screen.findByText("approve")) + await waitFor(() => expect(approveLoopDesign).toHaveBeenCalledWith(5)) + // Re-loads after the action so the resolved status reflects. + await waitFor(() => expect(getLoopArtifact).toHaveBeenCalledTimes(2)) + }) + + it("rejects a design gate with a comment", async () => { + getLoopArtifact.mockResolvedValue( + artifact({ kind: "design", status: "awaiting_approval", issue_id: 5 }) + ) + render( {}} />) + + fireEvent.click(await screen.findByText("reject")) + const box = await screen.findByPlaceholderText("rejectPlaceholder") + fireEvent.change(box, { target: { value: "needs work" } }) + fireEvent.click(screen.getByText("submitReject")) + await waitFor(() => + expect(rejectLoopDesign).toHaveBeenCalledWith(5, "needs work") + ) + }) + + it("shows the merge gate for a result whose issue is running", async () => { + getLoopArtifact.mockResolvedValue( + artifact({ kind: "result", status: "done", issue_id: 7 }) + ) + getLoopIssue.mockResolvedValue(issue("running")) + render( {}} />) + + fireEvent.click(await screen.findByText("merge")) + await waitFor(() => expect(approveLoopMerge).toHaveBeenCalledWith(7)) + }) + + it("hides the merge gate once the issue is no longer running", async () => { + getLoopArtifact.mockResolvedValue( + artifact({ kind: "result", status: "done", issue_id: 7 }) + ) + getLoopIssue.mockResolvedValue(issue("done")) + render( {}} />) + + // Wait for the issue fetch to resolve, then assert no merge control. + await waitFor(() => expect(getLoopIssue).toHaveBeenCalled()) + expect(screen.queryByText("merge")).not.toBeInTheDocument() + }) +}) diff --git a/src/components/loops/artifact-drawer.tsx b/src/components/loops/artifact-drawer.tsx new file mode 100644 index 0000000000..38ff7884ab --- /dev/null +++ b/src/components/loops/artifact-drawer.tsx @@ -0,0 +1,855 @@ +"use client" + +import { useRef, useState } from "react" +import { useTranslations } from "next-intl" +import { toast } from "sonner" +import { Loader2, MessageSquare, TriangleAlert } from "lucide-react" + +import { + approveLoopDesign, + approveLoopMerge, + getLoopArtifact, + getLoopArtifactIterations, + getLoopDag, + getLoopIssue, + getLoopPhaseIterations, + listLoopInbox, + rejectLoopDesign, + rejectLoopMerge, +} from "@/lib/loops-api" +import { AgentIcon } from "@/components/agent-icon" +import { toErrorMessage } from "@/lib/app-error" +import { diffLines, type DiffLine } from "@/lib/line-diff" +import { + acceptanceOrdinalMap, + coveringTaskTitles, + criterionCheckMap, + taskCovers, + type CriterionOrdinal, +} from "@/lib/loop-coverage" +import { buildAttentionMap } from "@/lib/loop-attention" +import type { + LoopArtifactDetail, + LoopCriterionCheckRow, + LoopGateDecisionRow, + LoopInboxItemRow, + LoopIssueDetail, + LoopIterationRow, + LoopRevision, +} from "@/lib/types" +import { useLoopResource } from "@/hooks/use-loop-resource" +import { useLoopNav } from "@/hooks/use-loop-nav" +import { useLoopOverlays } from "@/components/loops/loop-overlays-context" +import { IterationStatusBadge } from "@/components/loops/issue-badges" +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Skeleton } from "@/components/ui/skeleton" +import { Textarea } from "@/components/ui/textarea" +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { MessageResponse } from "@/components/ai-elements/message" + +type Gate = "design" | "merge" + +function payloadObj(p: unknown): Record { + return p && typeof p === "object" ? (p as Record) : {} +} + +/** A per-criterion verdict pill — glyph PLUS the verdict word (never color- or + * glyph-only) so the trace is accessible. The glyph is decorative (`aria-hidden`); + * the visible label carries the meaning. */ +function CriterionVerdict({ + check, + label, +}: { + check: LoopCriterionCheckRow + label: string +}) { + const pass = check.verdict === "pass" + return ( + + {pass ? "✓" : "✗"} + {label} + + ) +} + +/** Criterion-level coverage view for the drawer (computed from the issue DAG). */ +interface CoverageView { + // Requirement drawer: acceptance criterion id → covering task titles + // (empty array ⇒ that criterion is uncovered). + coveredBy: Record + // Task drawer: the acceptance criteria this task covers (ordinal + text). + covers: CriterionOrdinal[] +} + +interface ArtifactDrawerData { + detail: LoopArtifactDetail | null + // Loaded only for a `result`, to tell a live merge gate (issue running) from + // an already-merged or blocked one. + issue: LoopIssueDetail | null + // Loaded for requirement/task artifacts to render the coverage matrix. + coverage: CoverageView | null + // Latest reviewer check per criterion id (the per-criterion verdict glyph in + // the coverage matrix). Empty for artifacts that show no matrix. + checks: Map + // The gate decision for THIS artifact's own target: a task's review gate or a + // result's integration (finalize) gate. Null when none recorded yet. + gateDecision: LoopGateDecisionRow | null + // The targeted, bounded iteration history for this node (spec §4.4): a node's + // own targeting iterations, or an Issue/Result phase's artifact-less sessions. + iterations: LoopIterationRow[] + // True when the iteration-history commands are absent (older server) — the + // iteration section degrades to an "unsupported" note instead of a timeline. + iterationsUnsupported: boolean + // This artifact's pending inbox cards (D10): a card concerning it shows inline + // with a jump to its session. + inboxCards: LoopInboxItemRow[] +} + +const EMPTY_DRAWER: ArtifactDrawerData = { + detail: null, + issue: null, + coverage: null, + checks: new Map(), + gateDecision: null, + iterations: [], + iterationsUnsupported: false, + inboxCards: [], +} + +/** The most recent gate decision for a target+stage (highest attempt, then id). */ +function latestDecisionFor( + decisions: LoopGateDecisionRow[], + targetId: number, + stage: string +): LoopGateDecisionRow | null { + return ( + decisions + .filter((d) => d.target_artifact_id === targetId && d.stage === stage) + .sort((a, b) => b.attempt - a.attempt || b.id - a.id)[0] ?? null + ) +} + +/** + * Read-only drawer for a single artifact, plus the two human gates the loop + * routes through it: + * + * - **content** — the latest revision's text; + * - **revision history** — each adjacent revision rendered as a colored + * line diff (so a human can see what a rework or a rejection note changed); + * - **acceptance criteria** + (for a `review` artifact) its **verdict** and + * per-criterion findings; + * - **linked iteration** — which iteration produced the artifact; + * - **gates** — a `design` awaiting approval shows approve / reject (with an + * optional comment); a `result` whose issue is still running shows merge / + * reject. No other manual status controls — the engine owns every other + * transition. + * + * The body is keyed by `artifactId` so switching artifacts remounts it (fresh + * skeleton, no stale content or gate flashing the previous artifact); while a + * given artifact is open the body stays live via the realtime provider, so an + * engine rework/approval updates it without reopening. + */ +export function ArtifactDrawer({ + artifactId, + onClose, +}: { + artifactId: number | null + onClose: () => void +}) { + const t = useTranslations("Loops.artifactDrawer") + return ( + !o && onClose()}> + + {artifactId != null ? ( + + ) : ( + // A title must exist for a11y even during the close-out animation, + // when no artifact body is mounted. + + {t("loading")} + + {t("loading")} + + + )} + + + ) +} + +function ArtifactDrawerBody({ artifactId }: { artifactId: number }) { + const t = useTranslations("Loops.artifactDrawer") + const tKind = useTranslations("Loops.artifactKind") + const tStatus = useTranslations("Loops.artifactStatus") + const tVerdict = useTranslations("Loops.reviewVerdict") + const tActor = useTranslations("Loops.actorKind") + const tCriterionKind = useTranslations("Loops.criterionKind") + const tCoverage = useTranslations("Loops.coverage") + const tCheckVerdict = useTranslations("Loops.checkVerdict") + const tGateOutcome = useTranslations("Loops.gateOutcome") + const tGate = useTranslations("Loops.inbox") + const tCommon = useTranslations("Loops.common") + const tToasts = useTranslations("Loops.toasts") + const tStage = useTranslations("Loops.stage") + + // The drawer lives at the workbench level, bound to `?artifact=` — `nav.space` + // is the space this artifact belongs to (you can only open it from inside its + // space), so it scopes the iterations/inbox lookups below. + const { nav } = useLoopNav() + const spaceId = nav.space + const { openIteration } = useLoopOverlays() + + const [busy, setBusy] = useState(false) + const [rejecting, setRejecting] = useState(null) + const [comment, setComment] = useState("") + + // The artifact's issue is immutable, so narrow the match the instant we learn + // it — right after getLoopArtifact, BEFORE the optional getLoopIssue fetch. + // Broad before the first load (over-fetch, never a miss). A ref keeps the + // match closure free of an async-data dependency. + // EXCEPTION to useLoopResource's "never key the match on loaded data" rule: + // sound ONLY because issue_id is immutable for a given artifact — do not copy + // this for mutable scope. + const issueRef = useRef(null) + const { data, loading, refetch } = useLoopResource( + async () => { + const detail = await getLoopArtifact(artifactId) + if (detail) issueRef.current = detail.issue_id // immutable → narrow now + let issue: LoopIssueDetail | null = null + let coverage: CoverageView | null = null + let checks: Map = new Map() + let gateDecision: LoopGateDecisionRow | null = null + let iterations: LoopIterationRow[] = [] + let iterationsUnsupported = false + let inboxCards: LoopInboxItemRow[] = [] + if (detail && spaceId != null) { + // Targeted, bounded iteration history (spec §4.4): task / requirement / + // design / reflection load the iterations that targeted this artifact; an + // Issue node loads its triage sessions and a Result node its finalize + // sessions (both artifact-less, fetched by phase). Older servers lack + // these commands — degrade the section, never fail the drawer. + try { + iterations = + detail.kind === "issue" + ? await getLoopPhaseIterations(detail.issue_id, "triage") + : detail.kind === "result" + ? await getLoopPhaseIterations(detail.issue_id, "finalize") + : await getLoopArtifactIterations(detail.id) + } catch { + iterationsUnsupported = true + } + inboxCards = await listLoopInbox(spaceId, "pending") + .then((rows) => { + const byNode = buildAttentionMap( + rows.filter((r) => r.issue_id === detail.issue_id) + ) + return byNode.get(`artifact:${detail.id}`) ?? [] + }) + .catch(() => []) + } + if (detail && detail.kind === "result") { + issue = await getLoopIssue(detail.issue_id).catch(() => null) + } + // The coverage matrix, the per-criterion verdict trace, and the gate + // decision all read the issue DAG; load it for the kinds that surface them. + if ( + detail && + (detail.kind === "requirement" || + detail.kind === "task" || + detail.kind === "result") + ) { + const dag = await getLoopDag(detail.issue_id).catch(() => null) + if (dag) { + // Per-criterion verdict (latest check) + this target's gate decision + // (a task's review gate / a result's integration finalize gate). + checks = criterionCheckMap(dag.criterion_checks, dag.gate_decisions) + gateDecision = latestDecisionFor( + dag.gate_decisions, + detail.id, + detail.kind === "result" ? "finalize" : "review" + ) + if (detail.kind === "requirement") { + const coveredBy: Record = {} + for (const c of detail.criteria) { + if (c.kind === "acceptance") { + coveredBy[c.id] = coveringTaskTitles( + c.id, + dag.coverage, + dag.artifacts + ) + } + } + coverage = { coveredBy, covers: [] } + } else if (detail.kind === "task") { + // Done requirements only — matches the backend ordinal source, so the + // R{i}.AC{j} shown here lines up with what `covers` recorded. + const reqIds = dag.artifacts + .filter((a) => a.kind === "requirement" && a.status === "done") + .map((a) => a.id) + const reqDetails = ( + await Promise.all( + reqIds.map((id) => getLoopArtifact(id).catch(() => null)) + ) + ).filter((d): d is LoopArtifactDetail => d != null) + coverage = { + coveredBy: {}, + covers: taskCovers( + detail.id, + dag.coverage, + acceptanceOrdinalMap(reqDetails) + ), + } + } + } + } + return { + detail, + issue, + coverage, + checks, + gateDecision, + iterations, + iterationsUnsupported, + inboxCards, + } + }, + { + match: (e) => issueRef.current == null || e.issue_id === issueRef.current, + initial: EMPTY_DRAWER, + deps: [artifactId, spaceId], + } + ) + const detail = data.detail + const issue = data.issue + + // The iteration that produced this artifact (for the "open producing session" + // button) and every iteration that targeted it (D10), newest first. + const producer = + detail?.produced_by_iteration_id != null + ? (data.iterations.find( + (it) => it.id === detail.produced_by_iteration_id + ) ?? null) + : null + // The iteration-history command already scopes these (a node's own targeting + // iterations, or an Issue/Result phase's artifact-less sessions); just order + // them newest-first. + const targetingIterations = [...data.iterations].sort((a, b) => b.id - a.id) + + // Open an iteration's read-only session in the shared viewer (when it has a + // bound conversation). Labels the viewer with this artifact's issue context. + const openSession = (it: LoopIterationRow) => { + if (it.conversation_id == null || !detail) return + openIteration({ + conversationId: it.conversation_id, + agentType: it.agent_type ?? undefined, + outcome: it.outcome, + issueContext: { + spaceId: spaceId ?? 0, + issueId: detail.issue_id, + issueSeq: detail.issue_seq, + stage: it.stage, + }, + }) + } + + // A concise label for a related inbox card, reusing the inbox's own kind + // strings (the rich card with cause/humanized failure lives in the inbox pane). + const inboxKindLabel = (item: LoopInboxItemRow): string => { + switch (item.kind) { + case "approval": + return payloadObj(item.payload).gate === "merge" + ? tGate("gateMerge") + : tGate("gateDesign") + case "blocked": + return tGate("kindBlocked") + case "budget_exhausted": + return tGate("kindBudget") + case "question": + return tGate("kindQuestion") + case "reflection_failed": + return tGate("kindReflectFailed") + default: + return item.kind + } + } + + // Newest revision first; the latest drives the content section. + const revisions: LoopRevision[] = detail + ? [...detail.revisions].sort((a, b) => b.seq - a.seq) + : [] + const latest = revisions[0] + + const designGate = + detail?.kind === "design" && detail.status === "awaiting_approval" + const mergeGate = detail?.kind === "result" && issue?.status === "running" + + const run = async (fn: () => Promise) => { + setBusy(true) + try { + await fn() + toast.success(tToasts("inboxResolved")) + } catch (err) { + toast.error(tToasts("actionFailed", { message: toErrorMessage(err) })) + } finally { + // Reconcile with backend truth after every action (success OR failure), so a + // stale gate / status converges even when the action was a no-op conflict. + refetch() + setBusy(false) + } + } + + const approve = () => { + if (!detail) return + if (designGate) void run(() => approveLoopDesign(detail.issue_id)) + else if (mergeGate) void run(() => approveLoopMerge(detail.issue_id)) + } + + const confirmReject = () => { + if (!detail) return + const gate = rejecting + const text = comment.trim() || undefined + setRejecting(null) + setComment("") + if (gate === "design") + void run(() => rejectLoopDesign(detail.issue_id, text)) + else if (gate === "merge") + void run(() => rejectLoopMerge(detail.issue_id, text)) + } + + return ( + <> + + + {detail?.title ?? t("loading")} + + +
+ {detail && ( + <> + {tKind(detail.kind)} + {tStatus(detail.status)} + {detail.kind === "task" && + detail.contribution_kind === "no_op" && ( + + {t("noOpContribution")} + + )} + {detail.kind === "review" && detail.verdict && ( + + {tVerdict(detail.verdict)} + + )} + {detail.revisions.length > 0 && ( + + {t("revisionCount", { count: detail.revisions.length })} + + )} + + )} +
+
+
+ + + {loading ? ( +
+ + + +
+ ) : !detail ? ( +

{t("noContent")}

+ ) : ( +
+
+ {latest && latest.content.trim().length > 0 ? ( + // Agent/human-authored markdown, rendered through the same + // safe Streamdown pipeline as chat (no raw HTML, links routed + // through link-safety) — never raw `dangerouslySetInnerHTML`. +
+ {latest.content} +
+ ) : ( +

+ {t("noContent")} +

+ )} +
+ + {detail.criteria.length > 0 && ( +
+
    + {detail.criteria.map((c) => { + // Coverage line: only meaningful for a requirement's + // acceptance criteria (which tasks claim them). + const tasks = + detail.kind === "requirement" && c.kind === "acceptance" + ? data.coverage?.coveredBy[c.id] + : undefined + const check = + c.kind === "acceptance" + ? data.checks.get(c.id) + : undefined + return ( +
  • +
    + + {tCriterionKind(c.kind)} + + {c.label} + {check && ( + + )} +
    + {c.text ? ( +

    {c.text}

    + ) : null} + {tasks !== undefined && + (tasks.length > 0 ? ( +

    + {tCoverage("coveredBy", { + tasks: tasks.join(", "), + })} +

    + ) : ( +

    + {tCoverage("uncovered")} +

    + ))} +
  • + ) + })} +
+
+ )} + + {detail.kind === "task" && + data.coverage && + data.coverage.covers.length > 0 && ( +
+
    + {data.coverage.covers.map((c) => ( +
  • + {c.ordinal} + + {" — "} + {c.text} + +
  • + ))} +
+
+ )} + + {/* Gate decision: the canonical per-criterion outcome the engine + recorded for this target (a task's review gate / a result's + integration finalize gate). */} + {data.gateDecision && ( +
+
+ + {tGateOutcome(data.gateDecision.outcome)} + + + {tCoverage("aggregatedChecks", { + count: data.gateDecision.input_check_ids.length, + })} + +
+
+ )} + + {revisions.length > 1 && ( +
+
+ {revisions.slice(0, -1).map((rev, i) => { + const prev = revisions[i + 1] + return ( + + ) + })} +
+
+ )} + + {detail.produced_by_iteration_id != null && ( +
+ {producer && producer.conversation_id != null ? ( + + ) : ( +

+ {t("producedBy", { id: detail.produced_by_iteration_id })} +

+ )} +
+ )} + + {data.iterationsUnsupported ? ( +
+

+ {t("iterationsUnsupported")} +

+
+ ) : ( + targetingIterations.length > 0 && ( +
+
    + {targetingIterations.map((it) => ( +
  • + {it.agent_type && ( + + )} + + {tStage(it.stage)} + + + {it.attempt > 0 && ( + + {t("attempt", { n: it.attempt })} + + )} + {it.conversation_id != null && ( + + )} +
  • + ))} +
+
+ ) + )} + + {data.inboxCards.length > 0 && ( +
+
    + {data.inboxCards.map((card) => { + const it = + card.iteration_id != null + ? data.iterations.find( + (x) => x.id === card.iteration_id + ) + : undefined + return ( +
  • + + + {inboxKindLabel(card)} + + {it && it.conversation_id != null && ( + + )} +
  • + ) + })} +
+
+ )} +
+ )} +
+ + {(designGate || mergeGate) && ( +
+

+ {designGate ? t("gateDesignPrompt") : t("gateMergePrompt")} +

+
+ + +
+
+ )} + + { + if (!o) { + setRejecting(null) + setComment("") + } + }} + > + + + {tGate("rejectTitle")} + +