返回 CodeWhale
dispatch_runner.rs
根目录 / crates / tui / src / dispatch_runner.rs
1 //! Cloud-dispatch remote runner: confirmed job → sandbox → forge PR.
2 //!
3 //! This module is the engine behind `confirm_job` /
4 //! [`crate::cloud_dispatch::execute_dispatch`] with `confirm`: it drives one
5 //! cloud job from `launching` through a Codewhale-operated sandbox to a pull
6 //! request on the target forge (`github` | `cnb` | `gitee`), then tears the
7 //! sandbox down on completion, failure, or cancellation. The persisted
8 //! contract, credential discovery, and fail-closed membership gate stay in
9 //! [`crate::cloud_dispatch`]; nothing here re-implements them.
10 //!
11 //! Invariants:
12 //!
13 //! - one harness: the sandbox runs the same `codewhale exec --auto`
14 //! one-shot entry every local non-interactive caller uses (one
15 //! `Engine::run_turn` inside); the runner itself runs no model turn.
16 //! - credentials never widen: the sandbox credential stays inside
17 //! [`crate::cloud_dispatch::LiveDaytonaLauncher`]; forge tokens are read
18 //! from Codewhale service slots only at PR-open time and are never
19 //! printed, logged, or persisted into job records.
20 //! - fail closed: every phase that cannot honestly complete records a
21 //! `failed` (or keeps `canceled`) job with a truthful note; a PR URL is
22 //! never invented.
23 //! - teardown always runs: cancel, failure, and success all attempt sandbox
24 //! teardown; the job note records whether it succeeded.
25 //! - orphans reconcile: the TUI detaches this runner, so quitting the TUI
26 //! can orphan a live sandbox. The job record persists a create intent
27 //! before the POST, every sandbox is labeled with its job id, and
28 //! [`startup_reconcile`] fails stale active jobs and deletes labeled
29 //! sandboxes whose job no longer needs them.
30
31 use std::path::Path;
32 use std::process::Command;
33 use std::sync::Mutex;
34
35 use anyhow::{Context, Result, anyhow, bail};
36
37 use crate::cloud_dispatch::{
38 self, CloudJob, CloudJobStatus, CloudJobStore, DaytonaLauncher, Forge, HarnessCommand,
39 PatchReceipt, SANDBOX_WORKSPACE, SandboxReceipt, sanitize_error, unix_timestamp,
40 validate_outbound_origin,
41 };
42 use crate::dependencies::ExternalTool;
43
44 /// Ceiling for PR titles (forges truncate longer titles).
45 const MAX_TITLE_CHARS: usize = 96;
46 /// Ceiling for the PR body, kept well under forge limits.
47 const MAX_BODY_CHARS: usize = 6_000;
48 /// Harness timeout for one cloud-agent turn.
49 const HARNESS_TIMEOUT_SECS: u32 = 3_600;
50
51 /// The pull request the forge opened (or that `gh` reported).
52 #[derive(Debug, Clone, PartialEq, Eq)]
53 pub struct PrOpened {
54 pub url: String,
55 /// SHA actually applied and pushed — not the sandbox `patch.head_sha`.
56 pub head_sha: String,
57 }
58
59 /// Forge seam: raise the agent's branch and open the PR. Tests inject a
60 /// recorder; production uses [`LiveForgePr`].
61 pub trait ForgePr {
62 fn open(&self, job: &CloudJob, patch: &PatchReceipt) -> Result<PrOpened>;
63 }
64
65 /// Run one confirmed job end to end.
66 ///
67 /// Requires the job to be `launching` (or `running`, for a resumed runner):
68 /// a `proposed` job is refused — confirmation is the caller's explicit act,
69 /// never the runner's. Every phase persists its transition so `/dispatch
70 /// show` and `/jobs` stream real progress, and a `canceled` record at any
71 /// checkpoint stops the run and tears the sandbox down.
72 pub fn run_confirmed_job(
73 store: &CloudJobStore,
74 id: &str,
75 launcher: &dyn DaytonaLauncher,
76 forge: &dyn ForgePr,
77 ) -> Result<CloudJob> {
78 let mut job = store.load(id)?;
79 if !matches!(
80 job.status,
81 CloudJobStatus::Launching | CloudJobStatus::Running
82 ) {
83 bail!(
84 "Cloud job {id} is {} and cannot be run; confirm it first with `/dispatch confirm {id}`.",
85 status_word(job.status)
86 );
87 }
88 match drive(store, &mut job, launcher, forge) {
89 Ok(()) => store.load(id),
90 Err(error) => {
91 let message = sanitize_error(&error.to_string());
92 // Re-load before writing any failure: a job the user canceled
93 // stays canceled — a later run error must not overwrite the
94 // user's terminal word with `failed`. The error is appended to
95 // the note instead. And if a cancel lands inside the load→save
96 // span of the failure write, the disk record (already
97 // `canceled`, with the user's note) wins and is left alone.
98 let current = match store.load(id) {
99 Ok(mut record) if record.status == CloudJobStatus::Canceled => {
100 record.finished_unix =
101 Some(record.finished_unix.unwrap_or_else(unix_timestamp));
102 record.note = format!("{}. Run error after cancel: {message}", record.note);
103 let _ = store.save(&record);
104 record
105 }
106 loaded => {
107 let mut failed = loaded.unwrap_or_else(|_| job.clone());
108 failed.status = CloudJobStatus::Failed;
109 failed.refusal = Some(message.clone());
110 failed.finished_unix = Some(unix_timestamp());
111 failed.note = format!("Cloud agent run failed closed. {message}");
112 match store.save_unless_canceled(&failed) {
113 Ok(true) => failed,
114 _ => store.load(id).unwrap_or(failed),
115 }
116 }
117 };
118 teardown_best_effort(launcher, &current);
119 Err(error)
120 }
121 }
122 }
123
124 /// Background runner used by the CLI and TUI confirm paths. The store is
125 /// the source of truth; the thread result is intentionally dropped
126 /// (failures are recorded inside the job record). The CLI joins the handle
127 /// before exiting so a confirmed run is never orphaned mid-flight; the TUI
128 /// detaches it (the job record keeps the truth across restarts, and
129 /// `/dispatch cancel` tears a live sandbox down at any time).
130 pub fn spawn_confirmed_runner(
131 store: CloudJobStore,
132 id: String,
133 ) -> Option<std::thread::JoinHandle<()>> {
134 std::thread::Builder::new()
135 .name(format!("cw-dispatch-{id}"))
136 .spawn(move || {
137 let launcher = cloud_dispatch::LiveDaytonaLauncher;
138 let forge = LiveForgePr;
139 let _ = run_confirmed_job(&store, &id, &launcher, &forge);
140 })
141 .ok()
142 }
143
144 /// Best-effort startup reconciliation for the detached runner.
145 ///
146 /// The TUI spawns [`spawn_confirmed_runner`] detached, so quitting the TUI
147 /// or crashing mid-run orphans the record (and possibly a billing sandbox)
148 /// with nothing to reconcile it. Two passes, in order:
149 ///
150 /// 1. [`cloud_dispatch::sweep_stale_jobs`] — active records older than the
151 /// declared harness budget plus slack are failed and their recorded
152 /// sandboxes torn down;
153 /// 2. [`cloud_dispatch::reconcile_sandboxes`] — any dispatch-labeled
154 /// sandbox whose job is terminal or absent from the store is deleted by
155 /// label, covering creates whose id was never recorded.
156 ///
157 /// Never fatal and never blocks the caller's critical path beyond the
158 /// launcher's own bounded HTTP budget; returns a human receipt for the log
159 /// (empty when there was nothing to do).
160 pub fn startup_reconcile(store: &CloudJobStore, launcher: &dyn DaytonaLauncher) -> String {
161 let swept = cloud_dispatch::sweep_stale_jobs(store, launcher, cloud_dispatch::unix_timestamp());
162 let mut lines = Vec::new();
163 for job in &swept {
164 lines.push(format!(
165 "cloud dispatch startup sweep: job {} marked stale (failed) and its sandbox teardown attempted",
166 job.id
167 ));
168 }
169 match cloud_dispatch::reconcile_sandboxes(store, launcher) {
170 Ok(report) if !report.deleted.is_empty() => {
171 lines.push(format!(
172 "cloud dispatch label reconcile: deleted orphaned sandbox(es) {}",
173 report.deleted.join(", ")
174 ));
175 }
176 Ok(_) => {}
177 Err(error) => lines.push(format!(
178 "cloud dispatch label reconcile skipped: {}",
179 sanitize_error(&error.to_string())
180 )),
181 }
182 lines.join("\n")
183 }
184
185 fn drive(
186 store: &CloudJobStore,
187 job: &mut CloudJob,
188 launcher: &dyn DaytonaLauncher,
189 forge: &dyn ForgePr,
190 ) -> Result<()> {
191 // Launching → Running: create the sandbox. The intent record goes down
192 // BEFORE the POST: if the create response is slow and the client gives
193 // up (or the process dies), the sandbox may still come into being with
194 // no recorded id — `sandbox_pending` is what cancel and the label
195 // reconciler use to find and delete it by label.
196 //
197 // Every phase save below is cancel-authoritative
198 // (`save_unless_canceled`): a cancel that lands while a phase is in
199 // flight wins over the runner's read-modify-write, so a canceled job
200 // can never be resurrected into a later phase — above all never into
201 // the branch push / PR open.
202 job.sandbox_pending = true;
203 store.save(job)?;
204 let receipt = launcher.create_sandbox(job)?;
205 job.status = CloudJobStatus::Running;
206 job.sandbox_pending = false;
207 job.sandbox_id = Some(receipt.sandbox_id.clone());
208 job.note = format!(
209 "Sandbox {} created; the Codewhale cloud agent turn is running.",
210 receipt.sandbox_id
211 );
212 if !store.save_unless_canceled(job)? {
213 return finish_canceled(store, job, launcher, &receipt);
214 }
215
216 launcher.wait_ready(&receipt)?;
217 let clone_url = cloud_dispatch::validate_git_remote_url(&job.remote_url)?;
218 launcher.clone_repository(&receipt, &clone_url, SANDBOX_WORKSPACE)?;
219 if cancel_requested(store, job)? {
220 return finish_canceled(store, job, launcher, &receipt);
221 }
222
223 // One agent turn through the standard one-shot harness entry.
224 let output = launcher.run_harness(&receipt, &harness_command(job))?;
225 job.agent_summary = Some(summary_line(&output));
226 if cancel_requested(store, job)? {
227 return finish_canceled(store, job, launcher, &receipt);
228 }
229
230 // Running → OpeningPr: collect the agent's work product.
231 let patch = launcher.collect_patch(&receipt)?;
232 job.status = CloudJobStatus::OpeningPr;
233 job.base_branch = Some(patch.base_branch.clone());
234 job.head_sha = Some(patch.head_sha.clone()); // replaced with the pushed sha after `forge.open`
235 job.note = format!(
236 "Agent turn complete ({}); raising branch {} and opening the PR on {}.",
237 patch.summary,
238 job.branch,
239 job.forge.as_str()
240 );
241 if !store.save_unless_canceled(job)? {
242 return finish_canceled(store, job, launcher, &receipt);
243 }
244
245 // OpeningPr → Done: push the branch and open the PR. The cancel check
246 // is the last gate before money-adjacent side effects on the forge.
247 if cancel_requested(store, job)? {
248 return finish_canceled(store, job, launcher, &receipt);
249 }
250 let opened = forge.open(job, &patch)?;
251 job.status = CloudJobStatus::Done;
252 job.pr_url = Some(opened.url.clone());
253 job.head_sha = Some(opened.head_sha.clone());
254 job.finished_unix = Some(unix_timestamp());
255 job.note = format!(
256 "Cloud agent finished; PR opened at {}. {}",
257 opened.url,
258 teardown_note(launcher, &receipt)
259 );
260 if !store.save_unless_canceled(job)? {
261 // A cancel landed while the PR was opening. The PR may well exist —
262 // keep its URL and say exactly that rather than claiming success or
263 // silently dropping the receipt.
264 let mut canceled = store.load(&job.id)?;
265 canceled.pr_url = job.pr_url.clone();
266 canceled.agent_summary = job.agent_summary.clone();
267 canceled.finished_unix = Some(canceled.finished_unix.unwrap_or_else(unix_timestamp));
268 canceled.note = format!(
269 "Canceled as the PR was opening; it may still have landed at {}. {}",
270 opened.url,
271 teardown_note(launcher, &receipt)
272 );
273 *job = canceled.clone();
274 return store.save(&canceled).map(|_| ());
275 }
276 Ok(())
277 }
278
279 /// True when the user canceled the job mid-run.
280 fn cancel_requested(store: &CloudJobStore, job: &CloudJob) -> Result<bool> {
281 Ok(store.load(&job.id)?.status == CloudJobStatus::Canceled)
282 }
283
284 fn finish_canceled(
285 store: &CloudJobStore,
286 job: &mut CloudJob,
287 launcher: &dyn DaytonaLauncher,
288 receipt: &SandboxReceipt,
289 ) -> Result<()> {
290 let mut canceled = store.load(&job.id)?;
291 canceled.agent_summary = job.agent_summary.clone();
292 canceled.finished_unix = Some(canceled.finished_unix.unwrap_or_else(unix_timestamp));
293 canceled.note = format!("Canceled mid-run. {}", teardown_note(launcher, receipt));
294 *job = canceled.clone();
295 store.save(&canceled)
296 }
297
298 fn teardown_best_effort(launcher: &dyn DaytonaLauncher, job: &CloudJob) {
299 if let Some(sandbox_id) = job.sandbox_id.clone() {
300 let receipt = SandboxReceipt {
301 sandbox_id,
302 toolbox_url: None,
303 };
304 let _ = launcher.teardown(&receipt);
305 }
306 }
307
308 fn teardown_note(launcher: &dyn DaytonaLauncher, receipt: &SandboxReceipt) -> String {
309 match launcher.teardown(receipt) {
310 Ok(()) => "The sandbox was torn down.".to_string(),
311 Err(error) => format!(
312 "Sandbox teardown failed and may need a retry: {}",
313 sanitize_error(&error.to_string())
314 ),
315 }
316 }
317
318 /// The exact harness invocation the sandbox runs: the one-shot
319 /// `codewhale exec --auto` entry — the same single-`Engine::run_turn` path
320 /// local non-interactive callers use, never a second engine.
321 pub fn harness_command(job: &CloudJob) -> HarnessCommand {
322 HarnessCommand {
323 argv: vec![
324 "codewhale".to_string(),
325 "exec".to_string(),
326 "--auto".to_string(),
327 job.prompt.clone(),
328 ],
329 cwd: SANDBOX_WORKSPACE.to_string(),
330 timeout_secs: HARNESS_TIMEOUT_SECS,
331 }
332 }
333
334 /// First non-empty line of harness output, bounded for notes and PR bodies.
335 pub fn summary_line(output: &str) -> String {
336 // Redacted first: the sandbox env carries the account machine token, and
337 // harness output must not be able to echo it into the job record.
338 crate::cloud_dispatch::redact_machine_tokens(output)
339 .lines()
340 .map(str::trim)
341 .find(|line| !line.is_empty())
342 .map(|line| line.chars().take(200).collect())
343 .unwrap_or_else(|| "cloud agent turn completed".to_string())
344 }
345
346 /// Owner/repo slug for a forge remote URL, or `None` when the URL does not
347 /// match the job's forge. Both https and `git@host:owner/repo.git` shapes
348 /// are accepted; a trailing `.git` is stripped.
349 pub fn forge_slug(forge: Forge, remote_url: &str) -> Option<String> {
350 if cloud_dispatch::classify_url(remote_url) != Some(forge) {
351 return None;
352 }
353 // `https://host/owner/repo.git` or `git@host:owner/repo.git`
354 let repo_path = if let Some((_, rest)) = remote_url.split_once("://") {
355 // Strip the host: the slug is the two path segments after it.
356 rest.split_once('/')?.1
357 } else {
358 remote_url.split_once(':')?.1
359 };
360 let segments: Vec<&str> = repo_path.split('/').collect();
361 if segments.len() < 2 {
362 return None;
363 }
364 let repo = segments[segments.len() - 1].trim_end_matches(".git");
365 let owner = segments[segments.len() - 2];
366 if owner.is_empty() || repo.is_empty() || repo == owner {
367 return None;
368 }
369 Some(format!("{owner}/{repo}"))
370 }
371
372 /// Truthful PR title: names the agent and its own summary.
373 pub fn compose_pr_title(job: &CloudJob, patch: &PatchReceipt) -> String {
374 let summary = if patch.summary.trim().is_empty() {
375 one_line(&job.prompt, 72)
376 } else {
377 one_line(&patch.summary, 72)
378 };
379 one_line(&format!("codewhale cloud: {summary}"), MAX_TITLE_CHARS)
380 }
381
382 /// Truthful PR body: what the agent did, the receipts Codewhale has, and an
383 /// explicit No-Issue line (no tracked issue; the cloud job is the record).
384 /// Sandbox-provider names never appear — the operator is Codewhale.
385 pub fn compose_pr_body(job: &CloudJob, patch: &PatchReceipt) -> String {
386 compose_pr_body_for_head(job, patch, &patch.head_sha)
387 }
388
389 /// PR body whose `Head:` is the sha actually applied/pushed.
390 pub fn compose_pr_body_for_head(job: &CloudJob, patch: &PatchReceipt, head_sha: &str) -> String {
391 let summary = if patch.summary.trim().is_empty() {
392 "(the agent's commit list is the record)".to_string()
393 } else {
394 patch.summary.trim().to_string()
395 };
396 let body = format!(
397 "Automated change by a Codewhale cloud agent.\n\n\
398 ## What the agent did\n{summary}\n\n\
399 ## Task\n{}\n\n\
400 ## Receipts\n\
401 - Cloud job: {}\n\
402 - Sandbox: {}\n\
403 - Branch: `{}` (base `{}`)\n\
404 - Head: `{}`\n\n\
405 No-Issue: cloud dispatch {} (no tracked issue; receipts above)",
406 job.prompt,
407 job.id,
408 job.sandbox_id.as_deref().unwrap_or("(pending)"),
409 job.branch,
410 patch.base_branch,
411 head_sha,
412 job.id,
413 );
414 one_line(&body, MAX_BODY_CHARS)
415 }
416
417 /// `gh pr create` argv for the GitHub path. The body rides in a file so the
418 /// prompt text never appears in `ps` output.
419 pub fn gh_pr_create_argv(
420 slug: &str,
421 base: &str,
422 head: &str,
423 title: &str,
424 body_file: &str,
425 ) -> Vec<String> {
426 vec![
427 "pr".to_string(),
428 "create".to_string(),
429 "--repo".to_string(),
430 slug.to_string(),
431 "--base".to_string(),
432 base.to_string(),
433 "--head".to_string(),
434 head.to_string(),
435 "--title".to_string(),
436 title.to_string(),
437 "--body-file".to_string(),
438 body_file.to_string(),
439 ]
440 }
441
442 /// Gitee v5 pull-request endpoint for a slug.
443 pub fn gitee_pr_url(slug: &str) -> String {
444 format!("https://gitee.com/api/v5/repos/{slug}/pulls")
445 }
446
447 /// CNB pull-request endpoint for a slug.
448 pub fn cnb_pr_url(slug: &str) -> String {
449 format!("https://api.cnb.cool/{slug}/-/pulls")
450 }
451
452 /// Production forge opener: local git for the branch push, then the forge's
453 /// own API surface for the PR (gh for GitHub; REST for CNB and Gitee with
454 /// service-slot tokens). Fails closed on missing tooling or tokens — the
455 /// branch push is not rolled back, and no PR URL is ever invented.
456 pub struct LiveForgePr;
457
458 impl ForgePr for LiveForgePr {
459 fn open(&self, job: &CloudJob, patch: &PatchReceipt) -> Result<PrOpened> {
460 let slug = forge_slug(job.forge, &job.remote_url).ok_or_else(|| {
461 anyhow!(
462 "the {} remote {} does not resolve to an owner/repo slug",
463 job.forge.as_str(),
464 job.remote_url
465 )
466 })?;
467 let title = compose_pr_title(job, patch);
468 let dir = tempfile::tempdir().context("could not stage the cloud agent branch")?;
469 let head_sha = prepare_branch(job, patch, dir.path())?;
470 push_branch(&dir.path().join("repo"), &job.remote_url, &job.branch)?;
471 let body = compose_pr_body_for_head(job, patch, &head_sha);
472 let url = match job.forge {
473 Forge::Github => open_pr_github(&slug, job, patch, &title, &body)?,
474 Forge::Gitee => open_pr_gitee(&slug, job, patch, &title, &body)?,
475 Forge::Cnb => open_pr_cnb(&slug, job, patch, &title, &body)?,
476 };
477 Ok(PrOpened { url, head_sha })
478 }
479 }
480
481 /// Shallow-clone the target repository, apply the agent's patch on a branch,
482 /// and return the head sha. Plain git subprocess work; never forceful.
483 fn prepare_branch(job: &CloudJob, patch: &PatchReceipt, dir: &Path) -> Result<String> {
484 let remote_url = cloud_dispatch::validate_git_remote_url(&job.remote_url)?;
485 git(
486 None,
487 &[
488 "clone",
489 "--quiet",
490 "--depth",
491 "50",
492 "--",
493 &remote_url,
494 &dir.join("repo").to_string_lossy(),
495 ],
496 )
497 .context("could not clone the target repository for the cloud agent branch")?;
498 let repo = dir.join("repo");
499 let patch_path = dir.join("agent.patch");
500 std::fs::write(&patch_path, &patch.patch).context("could not stage the agent patch")?;
501 git(
502 Some(&repo),
503 &["config", "user.name", "Codewhale Cloud Agent"],
504 )
505 .context("could not set the agent identity")?;
506 git(
507 Some(&repo),
508 &["config", "user.email", "cloud-agent@codewhale.invalid"],
509 )
510 .context("could not set the agent identity")?;
511 git(Some(&repo), &["checkout", "--quiet", "-b", &job.branch])
512 .context("could not create the cloud agent branch")?;
513 git(
514 Some(&repo),
515 &["am", "--quiet", "--3way", &patch_path.to_string_lossy()],
516 )
517 .context("the agent patch did not apply cleanly onto the target branch")?;
518 git(Some(&repo), &["rev-parse", "HEAD"]).map(|out| out.trim().to_string())
519 }
520
521 /// Push the prepared branch. Plain push only — `--force` is never passed, so
522 /// an existing branch that is not a fast-forward fails closed instead of
523 /// rewriting the target's history.
524 fn push_branch(repo: &Path, remote_url: &str, branch: &str) -> Result<()> {
525 let remote_url = cloud_dispatch::validate_git_remote_url(remote_url)?;
526 if cloud_dispatch::is_forge_default_branch(branch) {
527 bail!("refusing to push onto the forge default branch {branch}");
528 }
529 if looks_like_network_remote(&remote_url) && cloud_dispatch::classify_url(&remote_url).is_none()
530 {
531 bail!("refusing to push to a non-forge remote");
532 }
533 if remote_branch_exists(&remote_url, branch)? {
534 bail!("refusing to update existing remote branch {branch}");
535 }
536 git(
537 Some(repo),
538 &[
539 "push",
540 "--quiet",
541 "--",
542 &remote_url,
543 &format!("HEAD:refs/heads/{branch}"),
544 ],
545 )
546 .map(|_| ())
547 .context(
548 "could not push the cloud agent branch (it may need credentials or the branch may have moved)",
549 )
550 }
551
552 fn looks_like_network_remote(url: &str) -> bool {
553 url.contains("://") || url.contains('@')
554 }
555
556 fn remote_branch_exists(remote_url: &str, branch: &str) -> Result<bool> {
557 let listing =
558 git(None, &["ls-remote", "--heads", "--", remote_url, branch]).unwrap_or_default();
559 Ok(listing
560 .lines()
561 .any(|line| line.contains(&format!("refs/heads/{branch}"))))
562 }
563
564 fn git(cwd: Option<&Path>, args: &[&str]) -> Result<String> {
565 let mut command = Command::new("git");
566 if let Some(cwd) = cwd {
567 command.current_dir(cwd);
568 }
569 let output = command
570 .args(args)
571 .output()
572 .context("failed to start git for the cloud agent branch")?;
573 if !output.status.success() {
574 bail!(
575 "git {} failed: {}",
576 args.first().unwrap_or(&""),
577 sanitize_error(&String::from_utf8_lossy(&output.stderr))
578 );
579 }
580 Ok(String::from_utf8_lossy(&output.stdout).to_string())
581 }
582
583 fn open_pr_github(
584 slug: &str,
585 job: &CloudJob,
586 patch: &PatchReceipt,
587 title: &str,
588 body: &str,
589 ) -> Result<String> {
590 let body_dir = tempfile::tempdir().context("could not stage the PR body")?;
591 let body_file = body_dir.path().join("body.md");
592 std::fs::write(&body_file, body).context("could not write the PR body")?;
593 let mut command = crate::dependencies::Gh::command()
594 .ok_or_else(|| anyhow!("the GitHub pull request needs the gh CLI on PATH"))?;
595 command.args(gh_pr_create_argv(
596 slug,
597 &patch.base_branch,
598 &job.branch,
599 title,
600 &body_file.to_string_lossy(),
601 ));
602 let output = command
603 .output()
604 .context("failed to start gh for the pull request")?;
605 if !output.status.success() {
606 bail!(
607 "gh pr create failed: {}",
608 sanitize_error(&String::from_utf8_lossy(&output.stderr))
609 );
610 }
611 let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
612 if !url.starts_with("https://") {
613 bail!("gh did not report a pull request URL; refusing to invent one.");
614 }
615 Ok(url)
616 }
617
618 fn open_pr_gitee(
619 slug: &str,
620 job: &CloudJob,
621 patch: &PatchReceipt,
622 title: &str,
623 body: &str,
624 ) -> Result<String> {
625 let token = read_service_token("gitee").ok_or_else(|| {
626 anyhow!("a Gitee access token is not configured in the Codewhale service slot; the branch was pushed but no pull request was opened")
627 })?;
628 let url = validate_outbound_origin(&gitee_pr_url(slug))?;
629 let response = crate::tls::reqwest_blocking_client_builder()
630 .connect_timeout(std::time::Duration::from_secs(8))
631 .timeout(std::time::Duration::from_secs(30))
632 .redirect(reqwest::redirect::Policy::none())
633 .build()
634 .context("could not initialize the Gitee client")?
635 .post(url)
636 .form(&[
637 ("access_token", token.as_str()),
638 ("title", title),
639 ("head", job.branch.as_str()),
640 ("base", patch.base_branch.as_str()),
641 ("body", body),
642 ])
643 .send()
644 .context("could not reach Gitee")?;
645 let status = response.status();
646 let text = response.text().unwrap_or_default();
647 if !status.is_success() {
648 bail!("Gitee pull request create failed (HTTP {status}).");
649 }
650 let parsed: serde_json::Value =
651 serde_json::from_str(&text).context("Gitee returned invalid JSON")?;
652 parsed
653 .get("html_url")
654 .and_then(serde_json::Value::as_str)
655 .map(str::trim)
656 .filter(|url| url.starts_with("https://"))
657 .map(|url| url.to_string())
658 .ok_or_else(|| anyhow!("Gitee did not report a pull request URL; refusing to invent one."))
659 }
660
661 fn open_pr_cnb(
662 slug: &str,
663 job: &CloudJob,
664 patch: &PatchReceipt,
665 title: &str,
666 body: &str,
667 ) -> Result<String> {
668 let token = read_service_token("cnb").ok_or_else(|| {
669 anyhow!("a CNB access token is not configured in the Codewhale service slot; the branch was pushed but no pull request was opened")
670 })?;
671 let url = validate_outbound_origin(&cnb_pr_url(slug))?;
672 let response = crate::tls::reqwest_blocking_client_builder()
673 .connect_timeout(std::time::Duration::from_secs(8))
674 .timeout(std::time::Duration::from_secs(30))
675 .redirect(reqwest::redirect::Policy::none())
676 .build()
677 .context("could not initialize the CNB client")?
678 .post(url)
679 .bearer_auth(&token)
680 .json(&serde_json::json!({
681 "title": title,
682 "head": job.branch,
683 "base": patch.base_branch,
684 "body": body,
685 }))
686 .send()
687 .context("could not reach CNB")?;
688 let status = response.status();
689 let text = response.text().unwrap_or_default();
690 if !status.is_success() {
691 bail!("CNB pull request create failed (HTTP {status}).");
692 }
693 let parsed: serde_json::Value =
694 serde_json::from_str(&text).context("CNB returned invalid JSON")?;
695 let number = parsed
696 .get("number")
697 .and_then(serde_json::Value::as_i64)
698 .filter(|number| *number > 0)
699 .ok_or_else(|| {
700 anyhow!("CNB did not report a pull request number; refusing to invent a URL.")
701 })?;
702 Ok(format!("https://cnb.cool/{slug}/-/pulls/{number}"))
703 }
704
705 /// Read a forge token from the Codewhale service slot. Never logged.
706 fn read_service_token(slot: &str) -> Option<String> {
707 codewhale_secrets::Secrets::auto_detect()
708 .get(slot)
709 .ok()
710 .flatten()
711 .map(|value| value.trim().to_string())
712 .filter(|value| !value.is_empty())
713 }
714
715 fn one_line(value: &str, max: usize) -> String {
716 let flat: String = value
717 .chars()
718 .map(|ch| {
719 if ch.is_control() && ch != '\n' {
720 ' '
721 } else {
722 ch
723 }
724 })
725 .collect();
726 if flat.chars().count() <= max {
727 flat
728 } else {
729 let mut out: String = flat.chars().take(max.saturating_sub(1)).collect();
730 out.push('…');
731 out
732 }
733 }
734
735 fn status_word(status: CloudJobStatus) -> &'static str {
736 match status {
737 CloudJobStatus::Proposed => "proposed",
738 CloudJobStatus::Refused => "refused",
739 CloudJobStatus::Launching => "launching",
740 CloudJobStatus::Running => "running",
741 CloudJobStatus::OpeningPr => "openingpr",
742 CloudJobStatus::Done => "done",
743 CloudJobStatus::Failed => "failed",
744 CloudJobStatus::Canceled => "canceled",
745 }
746 }
747
748 /// Callback fired at each launcher phase boundary (used by tests to
749 /// simulate mid-run cancellation).
750 pub type PhaseHook = Box<dyn Fn(&str) + Send + Sync>;
751
752 /// Recording launcher for offline tests. Records every phase by name and
753 /// replays canned results; `hook` fires at each phase boundary so tests can
754 /// simulate mid-run cancellation.
755 pub struct RecordingLauncher {
756 sandbox_id: String,
757 patch: PatchReceipt,
758 calls: Mutex<Vec<String>>,
759 pub hook: Option<PhaseHook>,
760 /// Sandboxes reported by `list_job_sandboxes` (the reconciler seam).
761 pub listed: Mutex<Vec<crate::cloud_dispatch::LabeledSandbox>>,
762 }
763
764 impl RecordingLauncher {
765 pub fn new(sandbox_id: &str, patch: PatchReceipt) -> Self {
766 Self {
767 sandbox_id: sandbox_id.to_string(),
768 patch,
769 calls: Mutex::new(Vec::new()),
770 hook: None,
771 listed: Mutex::new(Vec::new()),
772 }
773 }
774
775 pub fn calls(&self) -> Vec<String> {
776 self.calls
777 .lock()
778 .map(|calls| calls.clone())
779 .unwrap_or_default()
780 }
781
782 fn record(&self, phase: &str) {
783 if let Ok(mut calls) = self.calls.lock() {
784 calls.push(phase.to_string());
785 }
786 if let Some(hook) = self.hook.as_ref() {
787 hook(phase);
788 }
789 }
790 }
791
792 impl DaytonaLauncher for RecordingLauncher {
793 fn create_sandbox(&self, _job: &CloudJob) -> Result<SandboxReceipt> {
794 self.record("create");
795 Ok(SandboxReceipt {
796 sandbox_id: self.sandbox_id.clone(),
797 toolbox_url: Some("https://toolbox.example.test".to_string()),
798 })
799 }
800
801 fn wait_ready(&self, _receipt: &SandboxReceipt) -> Result<()> {
802 self.record("wait_ready");
803 Ok(())
804 }
805
806 fn clone_repository(&self, _receipt: &SandboxReceipt, url: &str, path: &str) -> Result<()> {
807 self.record("clone");
808 assert_eq!(path, SANDBOX_WORKSPACE);
809 assert!(
810 url.starts_with("https://"),
811 "live clone must be an https URL"
812 );
813 Ok(())
814 }
815
816 fn run_harness(&self, _receipt: &SandboxReceipt, _command: &HarnessCommand) -> Result<String> {
817 self.record("harness");
818 Ok("Fixed the flaky test and re-ran the suite.\nall green".to_string())
819 }
820
821 fn collect_patch(&self, _receipt: &SandboxReceipt) -> Result<PatchReceipt> {
822 self.record("collect");
823 Ok(self.patch.clone())
824 }
825
826 fn teardown(&self, _receipt: &SandboxReceipt) -> Result<()> {
827 self.record("teardown");
828 Ok(())
829 }
830
831 fn list_job_sandboxes(&self) -> Result<Vec<crate::cloud_dispatch::LabeledSandbox>> {
832 self.record("list");
833 Ok(self
834 .listed
835 .lock()
836 .map(|listed| listed.clone())
837 .unwrap_or_default())
838 }
839 }
840
841 /// Recording forge opener for offline tests.
842 pub struct RecordingForgePr {
843 pub url: String,
844 opened: Mutex<Vec<String>>,
845 }
846
847 impl RecordingForgePr {
848 pub fn new(url: &str) -> Self {
849 Self {
850 url: url.to_string(),
851 opened: Mutex::new(Vec::new()),
852 }
853 }
854
855 pub fn opened(&self) -> Vec<String> {
856 self.opened
857 .lock()
858 .map(|opened| opened.clone())
859 .unwrap_or_default()
860 }
861 }
862
863 impl ForgePr for RecordingForgePr {
864 fn open(&self, job: &CloudJob, patch: &PatchReceipt) -> Result<PrOpened> {
865 if let Ok(mut opened) = self.opened.lock() {
866 opened.push(format!("{}:{}", job.id, patch.head_sha));
867 }
868 Ok(PrOpened {
869 url: self.url.clone(),
870 head_sha: patch.head_sha.clone(),
871 })
872 }
873 }
874
875 #[cfg(test)]
876 mod tests {
877 use super::*;
878 use crate::cloud_dispatch::{
879 CloudJobStatus, CredentialSource, CredentialState, DispatchOutcome, GitRemote,
880 MachineTokenState, execute_dispatch, plan_dispatch,
881 };
882 use std::sync::Arc;
883
884 fn fixture_patch() -> PatchReceipt {
885 PatchReceipt {
886 base_branch: "main".to_string(),
887 head_sha: "abc123def4567".to_string(),
888 summary: "Fix the flaky dispatch test".to_string(),
889 patch: "From abc123 Mon Sep 17 00:00:00 2001\nSubject: [PATCH] Fix the flake\n"
890 .to_string(),
891 }
892 }
893
894 fn confirmed_job(store: &CloudJobStore) -> CloudJob {
895 let plan = plan_dispatch(
896 &[GitRemote {
897 name: "github".to_string(),
898 url: "https://github.com/org/repo.git".to_string(),
899 }],
900 "open a PR that fixes the flake",
901 Some(Forge::Github),
902 Some("codewhale/cloud-runner-test"),
903 )
904 .unwrap();
905 match execute_dispatch(
906 store,
907 plan,
908 true,
909 &CredentialState::Present {
910 source: CredentialSource::Env,
911 },
912 &MachineTokenState::Present,
913 )
914 .unwrap()
915 {
916 DispatchOutcome::Accepted(job) => job,
917 other => panic!("expected accept, got {other:?}"),
918 }
919 }
920
921 #[test]
922 fn recording_lifecycle_reaches_done_with_receipts_and_teardown() {
923 let temp = tempfile::tempdir().unwrap();
924 let store = CloudJobStore::from_path(temp.path().join("jobs"));
925 let job = confirmed_job(&store);
926 let launcher = RecordingLauncher::new("sandbox_runner_1", fixture_patch());
927 let forge = RecordingForgePr::new("https://github.com/org/repo/pull/9");
928 let finished = run_confirmed_job(&store, &job.id, &launcher, &forge).unwrap();
929
930 assert_eq!(finished.status, CloudJobStatus::Done);
931 assert_eq!(
932 finished.pr_url.as_deref(),
933 Some("https://github.com/org/repo/pull/9")
934 );
935 assert_eq!(finished.sandbox_id.as_deref(), Some("sandbox_runner_1"));
936 assert_eq!(finished.base_branch.as_deref(), Some("main"));
937 assert_eq!(finished.head_sha.as_deref(), Some("abc123def4567"));
938 assert_eq!(
939 finished.agent_summary.as_deref(),
940 Some("Fixed the flaky test and re-ran the suite.")
941 );
942 assert!(finished.finished_unix.is_some());
943 assert!(finished.note.contains("PR opened at"));
944 assert!(finished.note.contains("torn down"));
945 // Full protocol order, teardown last.
946 assert_eq!(
947 launcher.calls(),
948 vec![
949 "create",
950 "wait_ready",
951 "clone",
952 "harness",
953 "collect",
954 "teardown"
955 ]
956 );
957 assert_eq!(forge.opened(), vec![format!("{}:abc123def4567", job.id)]);
958 // The persisted record streams the same truth.
959 assert_eq!(store.load(&job.id).unwrap().status, CloudJobStatus::Done);
960 }
961
962 #[test]
963 fn cancel_during_running_tears_down_and_never_opens_the_pr() {
964 let temp = tempfile::tempdir().unwrap();
965 let root = temp.path().join("jobs");
966 let store = CloudJobStore::from_path(root.clone());
967 let job = confirmed_job(&store);
968 let forge = RecordingForgePr::new("https://github.com/org/repo/pull/9");
969 // Cancel lands while the harness turn is in flight.
970 let cancel_root = root.clone();
971 let cancel_id = job.id.clone();
972 let canceled_seen = Arc::new(std::sync::atomic::AtomicBool::new(false));
973 let seen = canceled_seen.clone();
974 let mut launcher = RecordingLauncher::new("sandbox_runner_2", fixture_patch());
975 launcher.hook = Some(Box::new(move |phase| {
976 if phase == "harness" && !seen.swap(true, std::sync::atomic::Ordering::SeqCst) {
977 let store = CloudJobStore::from_path(cancel_root.clone());
978 let mut current = store.load(&cancel_id).unwrap();
979 current.status = CloudJobStatus::Canceled;
980 store.save(&current).unwrap();
981 }
982 }));
983 let finished = run_confirmed_job(&store, &job.id, &launcher, &forge).unwrap();
984
985 assert_eq!(finished.status, CloudJobStatus::Canceled);
986 assert!(finished.pr_url.is_none());
987 assert!(finished.note.contains("Canceled mid-run"));
988 assert!(finished.note.contains("torn down"));
989 // The run stopped before collect and the forge never fired; teardown ran.
990 assert_eq!(
991 launcher.calls(),
992 vec!["create", "wait_ready", "clone", "harness", "teardown"]
993 );
994 assert!(forge.opened().is_empty());
995 assert!(canceled_seen.load(std::sync::atomic::Ordering::SeqCst));
996 }
997
998 #[test]
999 fn cancel_between_clone_and_harness_never_starts_the_turn() {
1000 let temp = tempfile::tempdir().unwrap();
1001 let root = temp.path().join("jobs");
1002 let store = CloudJobStore::from_path(root.clone());
1003 let job = confirmed_job(&store);
1004 let forge = RecordingForgePr::new("https://github.com/org/repo/pull/9");
1005 let mut launcher = RecordingLauncher::new("sandbox_runner_clone_cancel", fixture_patch());
1006 launcher.hook = Some(Box::new({
1007 let cancel = raw_cancel_from_hook(root, job.id.clone());
1008 move |phase| {
1009 if phase == "clone" {
1010 cancel(phase);
1011 }
1012 }
1013 }));
1014 let finished = run_confirmed_job(&store, &job.id, &launcher, &forge).unwrap();
1015 assert_eq!(finished.status, CloudJobStatus::Canceled);
1016 assert!(finished.pr_url.is_none());
1017 let calls = launcher.calls();
1018 assert!(
1019 calls.contains(&"clone".to_string()),
1020 "clone must have run: {calls:?}"
1021 );
1022 assert!(
1023 !calls.contains(&"harness".to_string()),
1024 "harness must not run after a post-clone cancel: {calls:?}"
1025 );
1026 assert!(forge.opened().is_empty());
1027 }
1028
1029 /// Cancels the job from inside a phase hook with a raw status flip (no)
1030 /// `finished_unix`), mirroring the reviewer's reproduction: cancel_job
1031 /// saves `canceled` while a phase is in flight.
1032 fn raw_cancel_from_hook(root: std::path::PathBuf, id: String) -> impl Fn(&str) + Send + Sync {
1033 move |_phase: &str| {
1034 let store = CloudJobStore::from_path(root.clone());
1035 let mut current = store.load(&id).unwrap();
1036 current.status = CloudJobStatus::Canceled;
1037 store.save(&current).unwrap();
1038 }
1039 }
1040
1041 #[test]
1042 fn cancel_during_create_wins_over_the_post_create_save() {
1043 let temp = tempfile::tempdir().unwrap();
1044 let root = temp.path().join("jobs");
1045 let store = CloudJobStore::from_path(root.clone());
1046 let job = confirmed_job(&store);
1047 let forge = RecordingForgePr::new("https://github.com/org/repo/pull/9");
1048 // The cancel lands while the create POST is in flight — before the
1049 // runner ever holds a receipt. The post-create phase save must
1050 // refuse to resurrect the run.
1051 let mut launcher = RecordingLauncher::new("sandbox_cancel_1", fixture_patch());
1052 launcher.hook = Some(Box::new({
1053 let cancel = raw_cancel_from_hook(root.clone(), job.id.clone());
1054 move |phase| {
1055 if phase == "create" {
1056 cancel(phase);
1057 }
1058 }
1059 }));
1060 let finished = run_confirmed_job(&store, &job.id, &launcher, &forge).unwrap();
1061
1062 assert_eq!(finished.status, CloudJobStatus::Canceled);
1063 assert!(finished.pr_url.is_none());
1064 assert!(finished.finished_unix.is_some());
1065 assert!(finished.note.contains("Canceled mid-run"));
1066 assert!(finished.note.contains("torn down"));
1067 // The run never reached readiness, the forge never fired, teardown ran.
1068 assert_eq!(launcher.calls(), vec!["create", "teardown"]);
1069 assert!(forge.opened().is_empty());
1070 let persisted = store.load(&job.id).unwrap();
1071 assert_eq!(persisted.status, CloudJobStatus::Canceled);
1072 assert!(persisted.finished_unix.is_some());
1073 }
1074
1075 #[test]
1076 fn cancel_during_collect_blocks_the_branch_raise_and_the_pr() {
1077 let temp = tempfile::tempdir().unwrap();
1078 let root = temp.path().join("jobs");
1079 let store = CloudJobStore::from_path(root.clone());
1080 let job = confirmed_job(&store);
1081 let forge = RecordingForgePr::new("https://github.com/org/repo/pull/9");
1082 // The cancel lands while the patch is being collected; the
1083 // OpeningPr phase save must refuse, so the branch is never raised
1084 // and the PR never opened.
1085 let mut launcher = RecordingLauncher::new("sandbox_cancel_2", fixture_patch());
1086 launcher.hook = Some(Box::new({
1087 let cancel = raw_cancel_from_hook(root.clone(), job.id.clone());
1088 move |phase| {
1089 if phase == "collect" {
1090 cancel(phase);
1091 }
1092 }
1093 }));
1094 let finished = run_confirmed_job(&store, &job.id, &launcher, &forge).unwrap();
1095
1096 assert_eq!(finished.status, CloudJobStatus::Canceled);
1097 assert!(finished.pr_url.is_none());
1098 assert!(finished.base_branch.is_none());
1099 assert!(finished.finished_unix.is_some());
1100 assert!(finished.note.contains("Canceled mid-run"));
1101 assert_eq!(
1102 launcher.calls(),
1103 vec![
1104 "create",
1105 "wait_ready",
1106 "clone",
1107 "harness",
1108 "collect",
1109 "teardown"
1110 ]
1111 );
1112 assert!(forge.opened().is_empty());
1113 }
1114
1115 /// Launcher whose harness step fails; earlier phases record normally.
1116 struct HarnessFailsLauncher {
1117 calls: Mutex<Vec<String>>,
1118 hook: Option<PhaseHook>,
1119 }
1120
1121 impl HarnessFailsLauncher {
1122 fn note(&self, phase: &str) {
1123 if let Ok(mut calls) = self.calls.lock() {
1124 calls.push(phase.to_string());
1125 }
1126 if let Some(hook) = self.hook.as_ref() {
1127 hook(phase);
1128 }
1129 }
1130 }
1131
1132 impl DaytonaLauncher for HarnessFailsLauncher {
1133 fn create_sandbox(&self, _job: &CloudJob) -> Result<SandboxReceipt> {
1134 self.note("create");
1135 Ok(SandboxReceipt {
1136 sandbox_id: "sandbox_err_1".to_string(),
1137 toolbox_url: None,
1138 })
1139 }
1140 fn wait_ready(&self, _receipt: &SandboxReceipt) -> Result<()> {
1141 self.note("wait_ready");
1142 Ok(())
1143 }
1144 fn clone_repository(&self, _receipt: &SandboxReceipt, url: &str, path: &str) -> Result<()> {
1145 self.note("clone");
1146 assert_eq!(path, SANDBOX_WORKSPACE);
1147 assert!(url.starts_with("https://"));
1148 Ok(())
1149 }
1150 fn run_harness(
1151 &self,
1152 _receipt: &SandboxReceipt,
1153 _command: &HarnessCommand,
1154 ) -> Result<String> {
1155 self.note("harness");
1156 bail!("harness exploded")
1157 }
1158 fn teardown(&self, _receipt: &SandboxReceipt) -> Result<()> {
1159 self.note("teardown");
1160 Ok(())
1161 }
1162 }
1163
1164 #[test]
1165 fn run_error_after_cancel_keeps_the_canceled_record() {
1166 let temp = tempfile::tempdir().unwrap();
1167 let root = temp.path().join("jobs");
1168 let store = CloudJobStore::from_path(root.clone());
1169 let job = confirmed_job(&store);
1170 let forge = RecordingForgePr::new("https://github.com/org/repo/pull/9");
1171 // The cancel lands as the harness explodes: the failure arm must not
1172 // overwrite the user's `canceled` with `failed` — the error rides
1173 // along in the note.
1174 let mut launcher = HarnessFailsLauncher {
1175 calls: Mutex::new(Vec::new()),
1176 hook: None,
1177 };
1178 launcher.hook = Some(Box::new({
1179 let cancel = raw_cancel_from_hook(root.clone(), job.id.clone());
1180 move |phase| {
1181 if phase == "harness" {
1182 cancel(phase);
1183 }
1184 }
1185 }));
1186 let error = run_confirmed_job(&store, &job.id, &launcher, &forge)
1187 .unwrap_err()
1188 .to_string();
1189 assert!(error.contains("harness exploded"), "{error}");
1190
1191 let persisted = store.load(&job.id).unwrap();
1192 assert_eq!(
1193 persisted.status,
1194 CloudJobStatus::Canceled,
1195 "a user cancel survives a later run error"
1196 );
1197 assert!(persisted.note.contains("Run error after cancel"));
1198 assert!(persisted.note.contains("harness exploded"));
1199 assert!(persisted.refusal.is_none());
1200 assert!(persisted.pr_url.is_none());
1201 assert!(persisted.finished_unix.is_some());
1202 assert!(forge.opened().is_empty());
1203 let calls = launcher.calls.lock().unwrap().clone();
1204 assert_eq!(
1205 calls,
1206 vec!["create", "wait_ready", "clone", "harness", "teardown"]
1207 );
1208 }
1209
1210 #[test]
1211 fn the_declared_harness_budget_fits_the_harness_client_budget() {
1212 let temp = tempfile::tempdir().unwrap();
1213 let store = CloudJobStore::from_path(temp.path().join("jobs"));
1214 let job = confirmed_job(&store);
1215 let command = harness_command(&job);
1216 assert_eq!(command.timeout_secs, HARNESS_TIMEOUT_SECS);
1217 assert!(
1218 cloud_dispatch::LiveDaytonaLauncher::harness_client_budget_secs(&command)
1219 >= u64::from(HARNESS_TIMEOUT_SECS),
1220 "the client that carries the harness turn must cover the declared hour"
1221 );
1222 }
1223
1224 #[test]
1225 fn sandbox_intent_is_persisted_before_the_create_post() {
1226 let temp = tempfile::tempdir().unwrap();
1227 let root = temp.path().join("jobs");
1228 let store = CloudJobStore::from_path(root.clone());
1229 let job = confirmed_job(&store);
1230 // Observed from inside the create phase: the intent must already be
1231 // on disk, so a create whose response never arrives is still
1232 // reconcilable by label.
1233 let intent_root = root.clone();
1234 let intent_id = job.id.clone();
1235 let seen_pending = Arc::new(std::sync::atomic::AtomicBool::new(false));
1236 let seen = seen_pending.clone();
1237 let mut launcher = RecordingLauncher::new("sandbox_intent_1", fixture_patch());
1238 launcher.hook = Some(Box::new(move |phase| {
1239 if phase == "create" {
1240 let store = CloudJobStore::from_path(intent_root.clone());
1241 let current = store.load(&intent_id).unwrap();
1242 assert!(current.sandbox_pending, "intent must precede the POST");
1243 seen.store(true, std::sync::atomic::Ordering::SeqCst);
1244 }
1245 }));
1246 let forge = RecordingForgePr::new("https://github.com/org/repo/pull/11");
1247 let finished = run_confirmed_job(&store, &job.id, &launcher, &forge).unwrap();
1248
1249 assert!(seen_pending.load(std::sync::atomic::Ordering::SeqCst));
1250 assert_eq!(finished.status, CloudJobStatus::Done);
1251 assert!(!finished.sandbox_pending, "intent clears once the id lands");
1252 let persisted = store.load(&job.id).unwrap();
1253 assert!(!persisted.sandbox_pending);
1254 assert_eq!(persisted.sandbox_id.as_deref(), Some("sandbox_intent_1"));
1255 }
1256
1257 #[test]
1258 fn startup_reconcile_sweeps_stale_jobs_and_deletes_orphan_sandboxes() {
1259 let temp = tempfile::tempdir().unwrap();
1260 let store = CloudJobStore::from_path(temp.path().join("jobs"));
1261 let job = confirmed_job(&store);
1262 // Age the record past the stale threshold and park it mid-run, the
1263 // state a quit/crash would leave behind.
1264 let mut stale = store.load(&job.id).unwrap();
1265 stale.status = CloudJobStatus::Running;
1266 stale.sandbox_id = Some("sandbox_orphan".to_string());
1267 stale.created_unix = stale
1268 .created_unix
1269 .saturating_sub(crate::cloud_dispatch::STALE_ACTIVE_JOB_SECS + 120);
1270 store.save(&stale).unwrap();
1271 // A sandbox labeled for a job that is not in the store at all.
1272 let launcher = RecordingLauncher::new("unused", fixture_patch());
1273 *launcher.listed.lock().unwrap() = vec![crate::cloud_dispatch::LabeledSandbox {
1274 sandbox_id: "sandbox_ghost".to_string(),
1275 job_id: Some("cloud_0000000000000bad".to_string()),
1276 }];
1277
1278 let receipt = startup_reconcile(&store, &launcher);
1279 assert!(
1280 receipt.contains(&job.id),
1281 "receipt names the swept job: {receipt}"
1282 );
1283 assert!(
1284 receipt.contains("sandbox_ghost"),
1285 "receipt names deletions: {receipt}"
1286 );
1287 assert_eq!(store.load(&job.id).unwrap().status, CloudJobStatus::Failed);
1288 assert!(launcher.calls().contains(&"teardown".to_string()));
1289 assert!(launcher.calls().contains(&"list".to_string()));
1290 }
1291
1292 #[test]
1293 fn only_confirmed_jobs_can_run() {
1294 let temp = tempfile::tempdir().unwrap();
1295 let store = CloudJobStore::from_path(temp.path().join("jobs"));
1296 let plan = plan_dispatch(
1297 &[GitRemote {
1298 name: "github".to_string(),
1299 url: "https://github.com/org/repo.git".to_string(),
1300 }],
1301 "unconfirmed work",
1302 Some(Forge::Github),
1303 Some("codewhale/cloud-unconfirmed"),
1304 )
1305 .unwrap();
1306 match execute_dispatch(
1307 &store,
1308 plan,
1309 false,
1310 &CredentialState::Present {
1311 source: CredentialSource::Env,
1312 },
1313 &MachineTokenState::Present,
1314 )
1315 .unwrap()
1316 {
1317 DispatchOutcome::Proposal(job) => {
1318 let launcher = RecordingLauncher::new("never", fixture_patch());
1319 let forge = RecordingForgePr::new("https://github.com/org/repo/pull/1");
1320 let error = run_confirmed_job(&store, &job.id, &launcher, &forge)
1321 .unwrap_err()
1322 .to_string();
1323 assert!(error.contains("confirm it first"), "{error}");
1324 assert!(launcher.calls().is_empty());
1325 }
1326 other => panic!("expected proposal, got {other:?}"),
1327 }
1328 }
1329
1330 #[test]
1331 fn launch_failure_fails_closed_with_sanitized_note() {
1332 let temp = tempfile::tempdir().unwrap();
1333 let store = CloudJobStore::from_path(temp.path().join("jobs"));
1334 let job = confirmed_job(&store);
1335 struct FailingLauncher;
1336 impl DaytonaLauncher for FailingLauncher {
1337 fn create_sandbox(&self, _job: &CloudJob) -> Result<SandboxReceipt> {
1338 bail!("create exploded\u{1}")
1339 }
1340 }
1341 let forge = RecordingForgePr::new("https://github.com/org/repo/pull/2");
1342 let error = run_confirmed_job(&store, &job.id, &FailingLauncher, &forge).unwrap_err();
1343 assert!(error.to_string().contains("create exploded"));
1344 let failed = store.load(&job.id).unwrap();
1345 assert_eq!(failed.status, CloudJobStatus::Failed);
1346 assert!(failed.pr_url.is_none());
1347 assert!(failed.note.contains("failed closed"));
1348 assert!(!failed.note.contains('\u{1}'));
1349 assert!(forge.opened().is_empty());
1350 }
1351
1352 #[test]
1353 fn pr_title_and_body_are_truthful_unbranded_and_carry_receipts() {
1354 let temp = tempfile::tempdir().unwrap();
1355 let store = CloudJobStore::from_path(temp.path().join("jobs"));
1356 let job = confirmed_job(&store);
1357 let patch = fixture_patch();
1358 let title = compose_pr_title(&job, &patch);
1359 assert_eq!(title, "codewhale cloud: Fix the flaky dispatch test");
1360 let body = compose_pr_body(&job, &patch);
1361 assert!(body.contains("Codewhale cloud agent"));
1362 assert!(body.contains("What the agent did"));
1363 assert!(body.contains("Fix the flaky dispatch test"));
1364 assert!(body.contains("open a PR that fixes the flake"));
1365 assert!(body.contains(&job.id));
1366 assert!(body.contains("(pending)"));
1367 assert!(body.contains("codewhale/cloud-runner-test"));
1368 assert!(body.contains("`main`"));
1369 assert!(body.contains("abc123def4567"));
1370 assert!(body.contains("No-Issue: cloud dispatch"));
1371 for banned in ["Daytona", "daytona"] {
1372 assert!(
1373 !body.contains(banned),
1374 "body must not brand the sandbox: {banned}"
1375 );
1376 }
1377 }
1378
1379 #[test]
1380 fn harness_command_is_the_standard_one_shot_entry() {
1381 let temp = tempfile::tempdir().unwrap();
1382 let store = CloudJobStore::from_path(temp.path().join("jobs"));
1383 let job = confirmed_job(&store);
1384 let command = harness_command(&job);
1385 assert_eq!(
1386 command.argv,
1387 vec![
1388 "codewhale".to_string(),
1389 "exec".to_string(),
1390 "--auto".to_string(),
1391 job.prompt.clone(),
1392 ]
1393 );
1394 assert_eq!(command.cwd, SANDBOX_WORKSPACE);
1395 }
1396
1397 #[test]
1398 fn gh_argv_and_forge_endpoints_pin_the_pr_shapes() {
1399 let argv = gh_pr_create_argv(
1400 "org/repo",
1401 "main",
1402 "codewhale/cloud-1",
1403 "title",
1404 "/tmp/body.md",
1405 );
1406 assert_eq!(
1407 argv,
1408 vec![
1409 "pr",
1410 "create",
1411 "--repo",
1412 "org/repo",
1413 "--base",
1414 "main",
1415 "--head",
1416 "codewhale/cloud-1",
1417 "--title",
1418 "title",
1419 "--body-file",
1420 "/tmp/body.md",
1421 ]
1422 );
1423 assert_eq!(
1424 gitee_pr_url("org/repo"),
1425 "https://gitee.com/api/v5/repos/org/repo/pulls"
1426 );
1427 assert_eq!(
1428 cnb_pr_url("org/repo"),
1429 "https://api.cnb.cool/org/repo/-/pulls"
1430 );
1431 }
1432
1433 #[test]
1434 fn forge_slug_parses_https_and_ssh_and_rejects_foreign_hosts() {
1435 assert_eq!(
1436 forge_slug(Forge::Github, "https://github.com/Hmbown/CodeWhale.git"),
1437 Some("Hmbown/CodeWhale".to_string())
1438 );
1439 assert_eq!(
1440 forge_slug(Forge::Github, "git@github.com:Hmbown/CodeWhale.git"),
1441 Some("Hmbown/CodeWhale".to_string())
1442 );
1443 assert_eq!(
1444 forge_slug(Forge::Cnb, "https://cnb.cool/codewhale.net/codewhale.git"),
1445 Some("codewhale.net/codewhale".to_string())
1446 );
1447 assert_eq!(
1448 forge_slug(Forge::Gitee, "https://gitee.com/org/repo.git"),
1449 Some("org/repo".to_string())
1450 );
1451 assert_eq!(
1452 forge_slug(Forge::Github, "https://gitee.com/org/repo.git"),
1453 None
1454 );
1455 assert_eq!(
1456 forge_slug(Forge::Cnb, "https://example.test/org/repo.git"),
1457 None
1458 );
1459 assert_eq!(
1460 forge_slug(Forge::Github, "https://github.com/only-repo"),
1461 None
1462 );
1463 }
1464
1465 #[test]
1466 fn push_branch_refuses_non_forge_remotes() {
1467 let error =
1468 push_branch(Path::new("."), "https://example.test/org/repo.git", "b").unwrap_err();
1469 let text = error.to_string();
1470 assert!(
1471 text.contains("non-forge") || text.contains("not a supported forge"),
1472 "{text}"
1473 );
1474 }
1475
1476 #[test]
1477 fn push_branch_refuses_forge_default_branch_names() {
1478 let error =
1479 push_branch(Path::new("."), "https://github.com/org/repo.git", "main").unwrap_err();
1480 assert!(error.to_string().contains("default branch"), "{}", error);
1481 }
1482
1483 #[test]
1484 fn prepare_branch_rejects_leading_dash_remote_before_clone() {
1485 let temp = tempfile::tempdir().unwrap();
1486 let mut job = confirmed_job(&CloudJobStore::from_path(temp.path().join("jobs")));
1487 job.remote_url = "--upload-pack=evil".to_string();
1488 let error = prepare_branch(&job, &fixture_patch(), temp.path()).unwrap_err();
1489 assert!(
1490 error.to_string().contains("must not start with '-'"),
1491 "{error}"
1492 );
1493 }
1494
1495 #[test]
1496 fn compose_pr_body_head_is_the_pushed_sha_not_the_sandbox_sha() {
1497 let job = CloudJob {
1498 id: "cloud_00000000000000cc".to_string(),
1499 kind: "cloud".to_string(),
1500 status: CloudJobStatus::OpeningPr,
1501 prompt: "fix".to_string(),
1502 forge: Forge::Github,
1503 remote_name: "github".to_string(),
1504 remote_url: "https://github.com/org/repo.git".to_string(),
1505 branch: "codewhale/cloud-b".to_string(),
1506 confirmed: true,
1507 sandbox_id: Some("sandbox".to_string()),
1508 pr_url: None,
1509 refusal: None,
1510 note: "n".to_string(),
1511 created_unix: 1,
1512 base_branch: None,
1513 head_sha: None,
1514 agent_summary: None,
1515 finished_unix: None,
1516 sandbox_pending: false,
1517 };
1518 let patch = fixture_patch();
1519 let body = compose_pr_body_for_head(&job, &patch, "pushedsha0000000000000000000000000001");
1520 assert!(body.contains("Head: `pushedsha0000000000000000000000000001`"));
1521 assert!(
1522 !body.contains(&format!("Head: `{}`", patch.head_sha)),
1523 "sandbox sha must not be the receipt Head"
1524 );
1525 }
1526
1527 /// Local-fixture integration: real git, no network — branch preparation
1528 /// applies a real format-patch, and a diverged push fails without force.
1529 #[test]
1530 fn prepare_branch_applies_a_real_patch_locally() {
1531 let temp = tempfile::tempdir().unwrap();
1532 let origin = temp.path().join("origin.git");
1533 git(
1534 None,
1535 &[
1536 "init",
1537 "--bare",
1538 "--quiet",
1539 "--initial-branch=main",
1540 &origin.to_string_lossy(),
1541 ],
1542 )
1543 .unwrap();
1544 let seed = temp.path().join("seed");
1545 git(
1546 None,
1547 &[
1548 "clone",
1549 "--quiet",
1550 &origin.to_string_lossy(),
1551 &seed.to_string_lossy(),
1552 ],
1553 )
1554 .unwrap();
1555 git(Some(&seed), &["config", "user.name", "Seeder"]).unwrap();
1556 git(Some(&seed), &["config", "user.email", "seed@example.test"]).unwrap();
1557 std::fs::write(seed.join("file.txt"), "base\n").unwrap();
1558 git(Some(&seed), &["add", "."]).unwrap();
1559 git(Some(&seed), &["commit", "--quiet", "-m", "base"]).unwrap();
1560 git(
1561 Some(&seed),
1562 &[
1563 "push",
1564 "--quiet",
1565 &origin.to_string_lossy(),
1566 "HEAD:refs/heads/main",
1567 ],
1568 )
1569 .unwrap();
1570
1571 // The agent's work product: a real patch.
1572 std::fs::write(seed.join("file.txt"), "base\nagent change\n").unwrap();
1573 git(Some(&seed), &["add", "."]).unwrap();
1574 git(Some(&seed), &["commit", "--quiet", "-m", "agent work"]).unwrap();
1575 let patch_text = git(Some(&seed), &["format-patch", "HEAD~1", "--stdout"]).unwrap();
1576
1577 let store = CloudJobStore::from_path(temp.path().join("jobs"));
1578 let mut job = confirmed_job(&store);
1579 // Point at the local fixture so preparation is offline.
1580 job.remote_url = origin.to_string_lossy().to_string();
1581 let patch = PatchReceipt {
1582 base_branch: "main".to_string(),
1583 head_sha: "fixture".to_string(),
1584 summary: "agent work".to_string(),
1585 patch: patch_text,
1586 };
1587 let dir = temp.path().join("agent");
1588 std::fs::create_dir_all(&dir).unwrap();
1589 let head = prepare_branch(&job, &patch, &dir).unwrap();
1590 assert_eq!(
1591 head.len(),
1592 40,
1593 "prepare_branch returns the applied head sha"
1594 );
1595
1596 // A diverged push onto the same branch must fail: no force, ever.
1597 let repo = dir.join("repo");
1598 std::fs::write(repo.join("other.txt"), "y\n").unwrap();
1599 git(Some(&repo), &["add", "."]).unwrap();
1600 git(Some(&repo), &["commit", "--quiet", "-m", "diverges"]).unwrap();
1601 // Seed the remote branch at the "diverges" commit.
1602 git(
1603 Some(&repo),
1604 &[
1605 "push",
1606 "--quiet",
1607 &origin.to_string_lossy(),
1608 "HEAD:refs/heads/codewhale/cloud-runner-test",
1609 ],
1610 )
1611 .unwrap();
1612 // Rewind and build a sibling commit: same parent, different content.
1613 git(Some(&repo), &["reset", "--quiet", "--hard", "HEAD~1"]).unwrap();
1614 std::fs::write(repo.join("other2.txt"), "z\n").unwrap();
1615 git(Some(&repo), &["add", "."]).unwrap();
1616 git(
1617 Some(&repo),
1618 &["commit", "--quiet", "-m", "diverges differently"],
1619 )
1620 .unwrap();
1621 let diverged = git(
1622 Some(&repo),
1623 &[
1624 "push",
1625 "--quiet",
1626 &origin.to_string_lossy(),
1627 "HEAD:refs/heads/codewhale/cloud-runner-test",
1628 ],
1629 );
1630 assert!(diverged.is_err(), "a diverged push must fail without force");
1631 }
1632
1633 #[test]
1634 fn compose_pr_body_bounds_oversized_prompts() {
1635 let job = CloudJob {
1636 id: "cloud_00000000000000bb".to_string(),
1637 kind: "cloud".to_string(),
1638 status: CloudJobStatus::OpeningPr,
1639 prompt: "p".repeat(9_000),
1640 forge: Forge::Github,
1641 remote_name: "github".to_string(),
1642 remote_url: "https://github.com/org/repo.git".to_string(),
1643 branch: "codewhale/cloud-b".to_string(),
1644 confirmed: true,
1645 sandbox_id: Some("sandbox".to_string()),
1646 pr_url: None,
1647 refusal: None,
1648 note: "n".to_string(),
1649 created_unix: 1,
1650 base_branch: None,
1651 head_sha: None,
1652 agent_summary: None,
1653 finished_unix: None,
1654 sandbox_pending: false,
1655 };
1656 let body = compose_pr_body(&job, &fixture_patch());
1657 assert!(body.chars().count() <= MAX_BODY_CHARS);
1658 }
1659
1660 #[test]
1661 fn one_line_flattens_control_characters() {
1662 assert_eq!(one_line("a\nb", 10), "a\nb");
1663 assert_eq!(one_line("a\u{1}b", 10), "a b");
1664 assert_eq!(one_line("abcdef", 3), "ab…");
1665 }
1666 }
1667
1667 lines RUST