diff --git a/bin/pudox-run/src/main.rs b/bin/pudox-run/src/main.rs index 670355d..4cb6566 100644 --- a/bin/pudox-run/src/main.rs +++ b/bin/pudox-run/src/main.rs @@ -257,9 +257,14 @@ async fn process_task( } } + tracing::info!("Summarizing session ({} messages)...", messages.len()); let summary = summarize_session(&messages).await?; + tracing::info!("Session summarized ({} chars)", summary.len()); + + tracing::info!("Generating PR title and description..."); let (commit_title, pull_request_description) = generate_pull_request_info(&task.description, &summary).await?; + tracing::info!("PR info ready: \"{}\"", commit_title); Ok(TaskResult { session_id: session_id.to_string(), @@ -318,11 +323,11 @@ async fn generate_pull_request_info( ); let body = serde_json::json!({ "model": "caurea/gemma-4-26B-A4B-it", - "max_tokens": 1024, + "max_tokens": 20000, "messages": [ { "role": "system", - "content": "Generate a git commit title and GitHub pull request body for a completed coding task. Output a JSON object with exactly two fields: \"commit_title\" (short imperative sentence, max 72 characters) and \"pull_request_description\" (a markdown GitHub pull request body describing what was done and why). Output only the JSON object with no surrounding text or code fences." + "content": "Generate a git commit title and GitHub pull request body for a completed coding task. Output a JSON object with exactly two fields: \"commit_title\" (short imperative sentence, max 72 characters) and \"pull_request_description\" (a markdown GitHub pull request body describing what was done and why). Output only the raw JSON object. Do not wrap it in a markdown code fence." }, { "role": "user", @@ -331,34 +336,83 @@ async fn generate_pull_request_info( ] }); - let resp: serde_json::Value = reqwest::Client::new() - .post("http://ai/v1/chat/completions") - .json(&body) - .send() - .await? - .json() - .await?; - - let content = resp["choices"][0]["message"]["content"] - .as_str() - .ok_or("no content in pull-request-info response")? - .trim() - .to_string(); + let mut last_err: Box = + "generate_pull_request_info: no attempts made".into(); - let parsed: serde_json::Value = serde_json::from_str(&content) - .map_err(|e| format!("Failed to parse PR info JSON '{}': {}", content, e))?; + for attempt in 1..=3u32 { + tracing::info!("generate_pull_request_info: attempt {}/3", attempt); + let resp: serde_json::Value = reqwest::Client::new() + .post("http://ai/v1/chat/completions") + .json(&body) + .send() + .await? + .json() + .await?; + + let raw = resp["choices"][0]["message"]["content"] + .as_str() + .unwrap_or("") + .trim() + .to_string(); + + // Strip optional ```json / ``` code fences the model sometimes adds. + let content = raw + .trim_start_matches("```json") + .trim_start_matches("```") + .trim_end_matches("```") + .trim() + .to_string(); + + if content.is_empty() { + last_err = format!( + "attempt {}: LLM returned empty content; full response: {}", + attempt, resp + ) + .into(); + tracing::warn!("{}", last_err); + continue; + } - let commit_title = parsed["commit_title"] - .as_str() - .ok_or("missing commit_title in pull-request-info response")? - .to_string(); + let parsed: serde_json::Value = match serde_json::from_str(&content) { + Ok(v) => v, + Err(e) => { + last_err = format!( + "attempt {}: failed to parse PR info JSON '{}': {}", + attempt, content, e + ) + .into(); + tracing::warn!("{}", last_err); + continue; + } + }; + + let commit_title = match parsed["commit_title"].as_str() { + Some(s) => s.to_string(), + None => { + last_err = + format!("attempt {}: missing commit_title in: {}", attempt, content).into(); + tracing::warn!("{}", last_err); + continue; + } + }; + + let pull_request_description = match parsed["pull_request_description"].as_str() { + Some(s) => s.to_string(), + None => { + last_err = format!( + "attempt {}: missing pull_request_description in: {}", + attempt, content + ) + .into(); + tracing::warn!("{}", last_err); + continue; + } + }; - let pull_request_description = parsed["pull_request_description"] - .as_str() - .ok_or("missing pull_request_description in pull-request-info response")? - .to_string(); + return Ok((commit_title, pull_request_description)); + } - Ok((commit_title, pull_request_description)) + Err(last_err) } async fn complete_chat_messages( diff --git a/bin/pudoxd/src/linear.rs b/bin/pudoxd/src/linear.rs index f3cc0da..d18243b 100644 --- a/bin/pudoxd/src/linear.rs +++ b/bin/pudoxd/src/linear.rs @@ -55,11 +55,18 @@ pub struct AgentActivityContent { pub body: Option, } +#[derive(Deserialize, Debug, Clone)] +pub struct Creator { + pub name: String, + pub email: String, +} + #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] pub struct AgentSession { pub id: String, pub issue: Option, + pub creator: Option, } #[derive(Deserialize, Debug)] diff --git a/bin/pudoxd/src/main.rs b/bin/pudoxd/src/main.rs index 7a0eb2e..3d3fc9e 100644 --- a/bin/pudoxd/src/main.rs +++ b/bin/pudoxd/src/main.rs @@ -104,10 +104,15 @@ async fn task_result_handler( ) -> impl IntoResponse { let session_id = payload.session_id.clone(); - let (exists, issue_id) = { + let (exists, issue_id, creator_name, creator_email) = { let sessions = app.sessions.read().await; let s = sessions.get(&session_id); - (s.is_some(), s.and_then(|s| s.issue_id.clone())) + ( + s.is_some(), + s.and_then(|s| s.issue_id.clone()), + s.and_then(|s| s.creator_name.clone()), + s.and_then(|s| s.creator_email.clone()), + ) }; if !exists { tracing::warn!("task/result: unknown session {}", session_id); @@ -132,6 +137,8 @@ async fn task_result_handler( dir, issue_id, payload, + creator_name, + creator_email, )); (axum::http::StatusCode::OK, "Result received").into_response() @@ -143,6 +150,8 @@ async fn handle_task_result( dir: std::path::PathBuf, issue_id: Option, payload: TaskResultPayload, + creator_name: Option, + creator_email: Option, ) { // ── 1. Git commit ────────────────────────────────────────────────────────── let branch_name = format!("pudox/{}", session_id); @@ -163,7 +172,9 @@ async fn handle_task_result( let tree_id = index.write_tree()?; let tree = repo.find_tree(tree_id)?; - let sig = git2::Signature::now("pudox", "pudox@caurea.org")?; + let name = creator_name.as_deref().unwrap_or("pudox"); + let email = creator_email.as_deref().unwrap_or("pudox@caurea.org"); + let sig = git2::Signature::now(name, email)?; let parent = repo.head()?.peel_to_commit()?; repo.commit(Some("HEAD"), &sig, &sig, &msg_clone, &tree, &[&parent])?; @@ -407,6 +418,16 @@ async fn webhook_handler(State(app): State, body: String) -> &'static str { let session_id = payload.agent_session.id.clone(); let issue_id = payload.agent_session.issue.as_ref().map(|i| i.id.clone()); let prompt_context = payload.prompt_context.clone(); + let creator_name = payload + .agent_session + .creator + .as_ref() + .map(|c| c.name.clone()); + let creator_email = payload + .agent_session + .creator + .as_ref() + .map(|c| c.email.clone()); let (tx, rx) = mpsc::channel::(32); let (sse_tx, _) = tokio::sync::broadcast::channel::(64); @@ -426,6 +447,8 @@ async fn webhook_handler(State(app): State, body: String) -> &'static str { tx: tx.clone(), issue_id, prompt_context, + creator_name, + creator_email, sse_tx, }, ); diff --git a/bin/pudoxd/src/session.rs b/bin/pudoxd/src/session.rs index c29e8fa..0ca9669 100644 --- a/bin/pudoxd/src/session.rs +++ b/bin/pudoxd/src/session.rs @@ -17,6 +17,8 @@ pub struct Session { pub tx: mpsc::Sender, pub issue_id: Option, pub prompt_context: Option, + pub creator_name: Option, + pub creator_email: Option, pub sse_tx: tokio::sync::broadcast::Sender, }