summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-02-09 21:21:10 +0000
committersoryu <soryu@soryu.co>2026-02-09 21:21:21 +0000
commit526edf672aae73c3670ab6141253bf92f1fbfe8c (patch)
treeceb32352f5f38be4662564126e135724850dbc31
parent76bb9da745f6c12c8e7e587a9096677bbf98f395 (diff)
downloadsoryu-526edf672aae73c3670ab6141253bf92f1fbfe8c.tar.gz
soryu-526edf672aae73c3670ab6141253bf92f1fbfe8c.zip
Add auto-PR creation for remote repos in contracts
-rw-r--r--makima/src/daemon/storage/mod.rs4
-rw-r--r--makima/src/daemon/storage/patch.rs179
-rw-r--r--makima/src/daemon/task/manager.rs176
-rw-r--r--makima/src/server/handlers/mesh_daemon.rs108
4 files changed, 312 insertions, 155 deletions
diff --git a/makima/src/daemon/storage/mod.rs b/makima/src/daemon/storage/mod.rs
index e5457f7..0397e91 100644
--- a/makima/src/daemon/storage/mod.rs
+++ b/makima/src/daemon/storage/mod.rs
@@ -6,6 +6,6 @@
mod patch;
pub use patch::{
- apply_patch, create_export_patch, create_patch, get_head_sha, get_parent_sha, ExportPatchResult,
- PatchError,
+ apply_patch, create_export_patch, create_patch, get_head_sha, get_merge_base_sha,
+ get_parent_sha, ExportPatchResult, PatchError,
};
diff --git a/makima/src/daemon/storage/patch.rs b/makima/src/daemon/storage/patch.rs
index b374d15..05c45a3 100644
--- a/makima/src/daemon/storage/patch.rs
+++ b/makima/src/daemon/storage/patch.rs
@@ -161,6 +161,99 @@ pub async fn get_head_sha(worktree_path: &Path) -> Result<String, PatchError> {
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
+/// Resolve the merge-base SHA for diffing against the main/master branch.
+///
+/// Tries in order:
+/// 1. Upstream tracking branch merge-base
+/// 2. Common branches: origin/main, origin/master, main, master
+/// 3. Fallback: HEAD~1
+///
+/// Returns `Err(PatchError::EmptyPatch)` if the merge-base equals HEAD (no diff).
+pub async fn get_merge_base_sha(worktree_path: &Path) -> Result<String, PatchError> {
+ // Try to get the upstream tracking branch
+ let upstream_result = Command::new("git")
+ .current_dir(worktree_path)
+ .args(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"])
+ .output()
+ .await;
+
+ let base = if let Ok(output) = upstream_result {
+ if output.status.success() {
+ let upstream = String::from_utf8_lossy(&output.stdout).trim().to_string();
+ // Get merge-base with upstream
+ let merge_base = Command::new("git")
+ .current_dir(worktree_path)
+ .args(["merge-base", "HEAD", &upstream])
+ .output()
+ .await;
+
+ if let Ok(mb_output) = merge_base {
+ if mb_output.status.success() {
+ Some(
+ String::from_utf8_lossy(&mb_output.stdout)
+ .trim()
+ .to_string(),
+ )
+ } else {
+ None
+ }
+ } else {
+ None
+ }
+ } else {
+ None
+ }
+ } else {
+ None
+ };
+
+ // Get current HEAD SHA for comparison
+ let head_sha = Command::new("git")
+ .current_dir(worktree_path)
+ .args(["rev-parse", "HEAD"])
+ .output()
+ .await
+ .ok()
+ .filter(|o| o.status.success())
+ .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string());
+
+ // If we couldn't find upstream, try common default branches
+ let base = if base.is_none() {
+ let default_branches = ["origin/main", "origin/master", "main", "master"];
+ let mut found_base = None;
+
+ for branch in default_branches {
+ let merge_base = Command::new("git")
+ .current_dir(worktree_path)
+ .args(["merge-base", "HEAD", branch])
+ .output()
+ .await;
+
+ if let Ok(output) = merge_base {
+ if output.status.success() {
+ let mb_sha = String::from_utf8_lossy(&output.stdout).trim().to_string();
+ // Skip if merge-base equals HEAD (would result in empty diff)
+ if head_sha.as_ref() != Some(&mb_sha) {
+ found_base = Some(mb_sha);
+ break;
+ }
+ }
+ }
+ }
+ found_base
+ } else {
+ // Also check upstream base
+ if base.as_ref() == head_sha.as_ref() {
+ None
+ } else {
+ base
+ }
+ };
+
+ // If still nothing, fall back to HEAD~1
+ Ok(base.unwrap_or_else(|| "HEAD~1".to_string()))
+}
+
/// Result of creating an export patch.
#[derive(Debug, Clone)]
pub struct ExportPatchResult {
@@ -192,91 +285,7 @@ pub async fn create_export_patch(
// Determine the base SHA to diff against
let resolved_base_sha = match base_sha {
Some(sha) => sha.to_string(),
- None => {
- // Try to find the merge-base with the default branch
- // First, try to get the upstream tracking branch
- let upstream_result = Command::new("git")
- .current_dir(worktree_path)
- .args(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"])
- .output()
- .await;
-
- let base = if let Ok(output) = upstream_result {
- if output.status.success() {
- let upstream = String::from_utf8_lossy(&output.stdout).trim().to_string();
- // Get merge-base with upstream
- let merge_base = Command::new("git")
- .current_dir(worktree_path)
- .args(["merge-base", "HEAD", &upstream])
- .output()
- .await;
-
- if let Ok(mb_output) = merge_base {
- if mb_output.status.success() {
- Some(String::from_utf8_lossy(&mb_output.stdout).trim().to_string())
- } else {
- None
- }
- } else {
- None
- }
- } else {
- None
- }
- } else {
- None
- };
-
- // Get current HEAD SHA for comparison
- let head_sha = Command::new("git")
- .current_dir(worktree_path)
- .args(["rev-parse", "HEAD"])
- .output()
- .await
- .ok()
- .filter(|o| o.status.success())
- .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string());
-
- // If we couldn't find upstream, try common default branches
- let base = if base.is_none() {
- let default_branches = ["origin/main", "origin/master", "main", "master"];
- let mut found_base = None;
-
- for branch in default_branches {
- let merge_base = Command::new("git")
- .current_dir(worktree_path)
- .args(["merge-base", "HEAD", branch])
- .output()
- .await;
-
- if let Ok(output) = merge_base {
- if output.status.success() {
- let mb_sha = String::from_utf8_lossy(&output.stdout).trim().to_string();
- // Skip if merge-base equals HEAD (would result in empty diff)
- if head_sha.as_ref() != Some(&mb_sha) {
- found_base = Some(mb_sha);
- break;
- }
- }
- }
- }
- found_base
- } else {
- // Also check upstream base
- if base.as_ref() == head_sha.as_ref() {
- None
- } else {
- base
- }
- };
-
- // If still nothing, get the first commit or use HEAD~1
- base.unwrap_or_else(|| {
- // This will be used, but if HEAD~1 doesn't exist (only one commit),
- // git diff will handle it gracefully
- "HEAD~1".to_string()
- })
- }
+ None => get_merge_base_sha(worktree_path).await?,
};
// Get diff stats using --stat
diff --git a/makima/src/daemon/task/manager.rs b/makima/src/daemon/task/manager.rs
index 68c5c42..a24f527 100644
--- a/makima/src/daemon/task/manager.rs
+++ b/makima/src/daemon/task/manager.rs
@@ -5548,6 +5548,11 @@ impl TaskManagerInner {
);
let _ = self.ws_tx.send(msg).await;
} else {
+ // Create completion patch before notifying server
+ if let Err(e) = self.create_completion_patch(task_id, &working_dir).await {
+ tracing::debug!(task_id = %task_id, error = %e, "No completion patch created");
+ }
+
let error = if success {
None
} else {
@@ -5825,83 +5830,52 @@ impl TaskManagerInner {
}
}
- /// Create an ephemeral patch of uncommitted changes and send to the server.
- /// This does NOT create git commits or push - patches are stored in PostgreSQL only.
+ /// Create an ephemeral patch of all changes (committed + uncommitted) since the
+ /// merge-base with main/master and send to the server.
+ /// Stages and commits any uncommitted changes, then diffs against the merge-base.
/// Returns the number of files changed on success, or an error message if nothing to patch.
async fn create_ephemeral_patch(
&self,
task_id: Uuid,
worktree_path: &std::path::Path,
) -> Result<i32, String> {
- // 1. Get current HEAD SHA (base for the patch)
- let base_sha_output = tokio::process::Command::new("git")
- .current_dir(worktree_path)
- .args(["rev-parse", "HEAD"])
- .output()
- .await
- .map_err(|e| format!("Failed to run git rev-parse: {}", e))?;
-
- if !base_sha_output.status.success() {
- let stderr = String::from_utf8_lossy(&base_sha_output.stderr);
- return Err(format!("git rev-parse failed: {}", stderr));
+ if !self.checkpoint_patches.enabled {
+ return Err("Checkpoint patches disabled".into());
}
- let base_sha = String::from_utf8_lossy(&base_sha_output.stdout).trim().to_string();
-
- // 2. Check for uncommitted changes using git status --porcelain
- let status_output = tokio::process::Command::new("git")
- .current_dir(worktree_path)
- .args(["status", "--porcelain"])
- .output()
+ // 1. Find merge-base with main/master (the fork point)
+ let base_sha = storage::get_merge_base_sha(worktree_path)
.await
- .map_err(|e| format!("Failed to run git status: {}", e))?;
-
- if !status_output.status.success() {
- let stderr = String::from_utf8_lossy(&status_output.stderr);
- return Err(format!("git status failed: {}", stderr));
- }
-
- let status = String::from_utf8_lossy(&status_output.stdout);
- if status.trim().is_empty() {
- return Err("No changes to patch".into());
- }
+ .map_err(|e| format!("Failed to get merge-base: {}", e))?;
- // Count files with changes
- let files_count = status.lines().count() as i32;
-
- // 3. Stage all changes (required for diff to include untracked files)
- let add_output = tokio::process::Command::new("git")
+ // 2. Stage and commit any uncommitted changes so they're included in the diff
+ let _ = tokio::process::Command::new("git")
.current_dir(worktree_path)
.args(["add", "-A"])
.output()
- .await
- .map_err(|e| format!("Failed to run git add: {}", e))?;
-
- if !add_output.status.success() {
- let stderr = String::from_utf8_lossy(&add_output.stderr);
- return Err(format!("git add failed: {}", stderr));
- }
+ .await;
- // 4. Create patch (diff of staged changes against HEAD)
- if !self.checkpoint_patches.enabled {
- // Reset staged changes and return
- let _ = tokio::process::Command::new("git")
- .current_dir(worktree_path)
- .args(["reset", "HEAD"])
- .output()
- .await;
- return Err("Checkpoint patches disabled".into());
- }
+ // Check if there are staged changes to commit
+ let staged_check = tokio::process::Command::new("git")
+ .current_dir(worktree_path)
+ .args(["diff", "--cached", "--quiet"])
+ .output()
+ .await;
- match storage::create_patch(worktree_path, &base_sha).await {
- Ok((compressed_patch, patch_files_count)) => {
- // Reset staged changes (we don't want to commit)
+ if let Ok(output) = staged_check {
+ if !output.status.success() {
+ // There are staged changes - commit them
let _ = tokio::process::Command::new("git")
.current_dir(worktree_path)
- .args(["reset", "HEAD"])
+ .args(["commit", "-m", "WIP: heartbeat checkpoint"])
.output()
.await;
+ }
+ }
+ // 3. Create patch (diff merge-base..HEAD captures all work)
+ match storage::create_patch(worktree_path, &base_sha).await {
+ Ok((compressed_patch, patch_files_count)) => {
// Check size limit
if compressed_patch.len() > self.checkpoint_patches.max_patch_size_bytes {
tracing::warn!(
@@ -5929,10 +5903,10 @@ impl TaskManagerInner {
let msg = DaemonMessage::CheckpointCreated {
task_id,
success: true,
- commit_sha: None, // No git commit
+ commit_sha: None,
branch_name: None,
- checkpoint_number: None, // Server will assign
- files_changed: None, // Detailed file info not tracked for ephemeral patches
+ checkpoint_number: None,
+ files_changed: None,
lines_added: None,
lines_removed: None,
error: None,
@@ -5943,18 +5917,92 @@ impl TaskManagerInner {
};
let _ = self.ws_tx.send(msg).await;
- Ok(files_count)
+ Ok(patch_files_count as i32)
}
Err(e) => {
- // Reset staged changes
+ Err(format!("Failed to create patch: {}", e))
+ }
+ }
+ }
+
+ /// Create a completion patch capturing all changes (committed + uncommitted) since
+ /// the merge-base with main/master. Sent before TaskComplete so the server always
+ /// has a recoverable patch. All errors are non-fatal (logged, not propagated).
+ async fn create_completion_patch(
+ &self,
+ task_id: Uuid,
+ worktree_path: &std::path::Path,
+ ) -> Result<(), String> {
+ // 1. Stage all changes
+ let _ = tokio::process::Command::new("git")
+ .current_dir(worktree_path)
+ .args(["add", "-A"])
+ .output()
+ .await;
+
+ // 2. Commit any staged changes so HEAD contains everything
+ let staged_check = tokio::process::Command::new("git")
+ .current_dir(worktree_path)
+ .args(["diff", "--cached", "--quiet"])
+ .output()
+ .await;
+
+ if let Ok(output) = staged_check {
+ if !output.status.success() {
let _ = tokio::process::Command::new("git")
.current_dir(worktree_path)
- .args(["reset", "HEAD"])
+ .args(["commit", "-m", "WIP: task completion checkpoint"])
.output()
.await;
- Err(format!("Failed to create patch: {}", e))
}
}
+
+ // 3. Find merge-base with main/master
+ let base_sha = storage::get_merge_base_sha(worktree_path)
+ .await
+ .map_err(|e| format!("Failed to get merge-base: {}", e))?;
+
+ // 4. Create patch (diff merge-base..HEAD)
+ let (compressed_patch, patch_files_count) = storage::create_patch(worktree_path, &base_sha)
+ .await
+ .map_err(|e| format!("Failed to create patch: {}", e))?;
+
+ // 5. Check size limit
+ if compressed_patch.len() > self.checkpoint_patches.max_patch_size_bytes {
+ return Err(format!(
+ "Patch too large: {} bytes (max: {})",
+ compressed_patch.len(),
+ self.checkpoint_patches.max_patch_size_bytes
+ ));
+ }
+
+ // 6. Send to server
+ let patch_data = base64::engine::general_purpose::STANDARD.encode(&compressed_patch);
+ let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC");
+ let msg = DaemonMessage::CheckpointCreated {
+ task_id,
+ success: true,
+ commit_sha: None,
+ branch_name: None,
+ checkpoint_number: None,
+ files_changed: None,
+ lines_added: None,
+ lines_removed: None,
+ error: None,
+ message: format!("Completion patch - {}", timestamp),
+ patch_data: Some(patch_data),
+ patch_base_sha: Some(base_sha),
+ patch_files_count: Some(patch_files_count as i32),
+ };
+ let _ = self.ws_tx.send(msg).await;
+
+ tracing::info!(
+ task_id = %task_id,
+ files_count = patch_files_count,
+ "Created completion patch"
+ );
+
+ Ok(())
}
}
diff --git a/makima/src/server/handlers/mesh_daemon.rs b/makima/src/server/handlers/mesh_daemon.rs
index 60de2e6..743a1ca 100644
--- a/makima/src/server/handlers/mesh_daemon.rs
+++ b/makima/src/server/handlers/mesh_daemon.rs
@@ -626,10 +626,7 @@ async fn compute_action_directive(
if all_tasks_done && !pr_deliverable_complete {
let done_count = task_infos.len();
return Some(format!(
- "[ACTION REQUIRED] All {} task(s) completed successfully.\n\
- You MUST now create a PR and mark the 'pull-request' deliverable as complete:\n\
- 1. Ensure all changes are merged to your branch\n\
- 2. Create PR and then call mark_deliverable_complete with deliverable_id='pull-request'",
+ "[INFO] All {} task(s) completed. System is auto-creating PR.",
done_count
));
}
@@ -638,6 +635,98 @@ async fn compute_action_directive(
None
}
+/// Automatically create a PR when all non-supervisor tasks for a contract are done.
+/// Only applies to remote-repo contracts in the "execute" phase.
+/// Fires as a best-effort operation — errors are logged but not propagated.
+async fn auto_create_pr_if_ready(
+ pool: &sqlx::PgPool,
+ state: &SharedState,
+ contract_id: Uuid,
+ owner_id: Uuid,
+) {
+ // 1. Load contract — must be remote (not local_only) and in execute phase
+ let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await {
+ Ok(Some(c)) => c,
+ _ => return,
+ };
+ if contract.local_only || contract.phase != "execute" {
+ return;
+ }
+
+ // 2. Load non-supervisor tasks — all must be done
+ let tasks = match repository::list_tasks_by_contract(pool, contract_id, owner_id).await {
+ Ok(t) => t,
+ _ => return,
+ };
+ let non_supervisor_tasks: Vec<_> = tasks.iter().filter(|t| !t.is_supervisor).collect();
+ if non_supervisor_tasks.is_empty() || !non_supervisor_tasks.iter().all(|t| t.status == "done") {
+ return;
+ }
+
+ // 3. Check pull-request deliverable not already complete
+ let completed_deliverables = contract.get_completed_deliverables(&contract.phase);
+ if completed_deliverables.contains(&"pull-request".to_string()) {
+ return;
+ }
+
+ // 4. Check at least one repository has a remote URL
+ let repos = match repository::list_contract_repositories(pool, contract_id).await {
+ Ok(r) => r,
+ _ => return,
+ };
+ if !repos.iter().any(|r| r.repository_url.is_some()) {
+ return;
+ }
+
+ // 5. Load supervisor task
+ let supervisor = match repository::get_contract_supervisor_task(pool, contract_id).await {
+ Ok(Some(s)) => s,
+ _ => return,
+ };
+
+ // Need supervisor's daemon_id to send command
+ let daemon_id = match supervisor.daemon_id {
+ Some(id) => id,
+ None => return,
+ };
+
+ // 6. Construct branch name
+ let sanitized_name: String = supervisor
+ .name
+ .chars()
+ .map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '-' })
+ .collect::<String>()
+ .to_lowercase();
+ let short_id = &supervisor.id.to_string()[..8];
+ let branch = format!("makima/{}-{}", sanitized_name, short_id);
+
+ // 7. Send CreatePR command to supervisor's daemon
+ let command = DaemonCommand::CreatePR {
+ task_id: supervisor.id,
+ title: contract.name.clone(),
+ body: contract.description.clone(),
+ base_branch: supervisor.base_branch.clone(),
+ branch,
+ };
+
+ match state.send_daemon_command(daemon_id, command).await {
+ Ok(()) => {
+ tracing::info!(
+ contract_id = %contract_id,
+ supervisor_id = %supervisor.id,
+ "Auto-PR: sent CreatePR command to supervisor daemon"
+ );
+ }
+ Err(e) => {
+ tracing::warn!(
+ contract_id = %contract_id,
+ error = %e,
+ "Auto-PR: failed to send CreatePR command"
+ );
+ }
+ }
+}
+
/// Validate an API key and return (user_id, owner_id).
async fn validate_daemon_api_key(pool: &sqlx::PgPool, key: &str) -> Result<DaemonAuthResult, String> {
let key_hash = hash_api_key(key);
@@ -1286,6 +1375,17 @@ async fn handle_daemon_connection(socket: WebSocket, state: SharedState, auth_re
}
}
+ // Auto-create PR if all tasks are done and repo is remote
+ if updated_task.status == "done" {
+ if let Some(contract_id) = updated_task.contract_id {
+ let pool_c = pool.clone();
+ let state_c = state.clone();
+ tokio::spawn(async move {
+ auto_create_pr_if_ready(&pool_c, &state_c, contract_id, owner_id).await;
+ });
+ }
+ }
+
// Record history event for task completion
let subtype = if updated_task.status == "done" { "completed" } else { "failed" };
let _ = repository::record_history_event(