fix: handle non-git local sandbox runs

Skip worktree checkpoint setup when a local sandbox is not backed by a git repository, and keep the API contract aligned with RunSpec serialization for omitted labels.
This commit is contained in:
Bryan Helmkamp 2026-04-28 08:22:58 -07:00
parent ab9b28875b
commit b24c67acef
No known key found for this signature in database
8 changed files with 48 additions and 62 deletions

View file

@ -4523,7 +4523,6 @@ components:
- run_id
- settings
- graph
- labels
- checkpoints_disabled
properties:
run_id:

View file

@ -159,7 +159,6 @@ fn attach_replays_completed_detached_run() {
exit_code: 0
----- stdout -----
----- stderr -----
Warning: Uncommitted changes will not be included in the worktree. [dirty_worktree]
Sandbox: local (ready in [TIME])
✓ Start [TIME]
✓ Run Tests [TIME]
@ -266,7 +265,6 @@ fn attach_before_completion_streams_to_finished_state() {
exit_code: 0
----- stdout -----
----- stderr -----
Warning: Uncommitted changes will not be included in the worktree. [dirty_worktree]
Sandbox: local (ready in [TIME])
✓ start [DURATION]
✓ wait [DURATION]
@ -690,22 +688,6 @@ fn attach_json_errors_without_prompting_for_human_input() {
"run_id": "[ULID]",
"ts": "[TIMESTAMP]"
},
{
"actor": {
"display": "system:worker",
"id": "worker",
"kind": "system"
},
"event": "run.notice",
"id": "[EVENT_ID]",
"properties": {
"code": "dirty_worktree",
"level": "warn",
"message": "Uncommitted changes will not be included in the worktree."
},
"run_id": "[ULID]",
"ts": "[TIMESTAMP]"
},
{
"actor": {
"display": "system:worker",

View file

@ -270,9 +270,9 @@ fn dump_exports_completed_run_snapshot() {
");
assert_snapshot!(dump_file_summary(&output_dir), @"
checkpoints/0014.json
checkpoints/0018.json
checkpoints/0022.json
checkpoints/0013.json
checkpoints/0017.json
checkpoints/0021.json
events.jsonl
graph.fabro
run.json

View file

@ -188,7 +188,6 @@ fn logs_pretty_formats_small_run() {
success: true
exit_code: 0
----- stdout -----
[CLOCK] Warning: Uncommitted changes will not be included in the worktree. [dirty_worktree]
[CLOCK] Sandbox: local [DURATION]
[CLOCK] ▶ Simple [ULID]
Run tests and report results

View file

@ -674,7 +674,6 @@ fn dry_run_simple() {
Goal: Run tests and report results
Run: [ULID]
Warning: Uncommitted changes will not be included in the worktree. [dirty_worktree]
Sandbox: local (ready in [TIME])
✓ Start [TIME]
✓ Run Tests [TIME]

View file

@ -20,7 +20,6 @@ fn dry_run_branching() {
warning [node: implement]: Node 'implement' has goal_gate=true but no retry_target or fallback_retry_target (goal_gate_has_retry)
Run: [ULID]
Warning: Uncommitted changes will not be included in the worktree. [dirty_worktree]
Sandbox: local (ready in [TIME])
✓ Start [TIME]
✓ Plan [TIME]
@ -56,7 +55,6 @@ fn dry_run_conditions() {
Goal: Test condition evaluation with OR and parentheses
Run: [ULID]
Warning: Uncommitted changes will not be included in the worktree. [dirty_worktree]
Sandbox: local (ready in [TIME])
✓ start [TIME]
✓ Decide [TIME]
@ -90,7 +88,6 @@ fn dry_run_parallel() {
Goal: Test parallel and fan-in execution
Run: [ULID]
Warning: Uncommitted changes will not be included in the worktree. [dirty_worktree]
Sandbox: local (ready in [TIME])
✓ start [TIME]
✓ Fork Work [TIME]
@ -125,7 +122,6 @@ fn dry_run_styled() {
Goal: Build a styled pipeline
Run: [ULID]
Warning: Uncommitted changes will not be included in the worktree. [dirty_worktree]
Sandbox: local (ready in [TIME])
✓ start [TIME]
✓ Plan [TIME]
@ -160,7 +156,6 @@ fn dry_run_legacy_tool() {
Goal: Verify backwards compatibility with old tool naming
Run: [ULID]
Warning: Uncommitted changes will not be included in the worktree. [dirty_worktree]
Sandbox: local (ready in [TIME])
✓ Start [TIME]
✓ Echo [TIME]

View file

@ -45,9 +45,9 @@ struct WorktreePlan {
async fn resolve_worktree_base_sha(
sandbox: &dyn Sandbox,
plan: &WorktreePlan,
) -> Result<String, Error> {
) -> Result<Option<String>, Error> {
if let Some(base_sha) = plan.base_sha.as_ref() {
return Ok(base_sha.clone());
return Ok(Some(base_sha.clone()));
}
let result = sandbox
@ -67,6 +67,9 @@ async fn resolve_worktree_base_sha(
} else {
output
};
if is_not_git_repository(output) {
return Ok(None);
}
return Err(Error::engine(format!(
"git rev-parse HEAD failed (exit {}): {}",
result.exit_code, output
@ -77,7 +80,11 @@ async fn resolve_worktree_base_sha(
if base_sha.is_empty() {
return Err(Error::engine("git rev-parse HEAD returned no commit sha"));
}
Ok(base_sha.to_string())
Ok(Some(base_sha.to_string()))
}
fn is_not_git_repository(output: &str) -> bool {
output.contains("not a git repository") || output.contains("ambiguous argument 'HEAD'")
}
async fn run_hooks(
@ -131,17 +138,19 @@ fn resolve_worktree_plan(options: &mut InitOptions) -> Option<WorktreePlan> {
}
}
let git_is_clean = options
let local_dirty = options
.run_options
.pre_run_git
.as_ref()
.is_some_and(|git| matches!(git.local_dirty, fabro_types::DirtyStatus::Clean));
.map(|git| git.local_dirty);
let git_is_clean =
local_dirty.is_some_and(|status| matches!(status, fabro_types::DirtyStatus::Clean));
let strategy =
options
.sandbox
.workdir_strategy(worktree_mode, git_is_clean, options.checkpoint.is_some());
if !git_is_clean {
if matches!(local_dirty, Some(fabro_types::DirtyStatus::Dirty)) {
let env_name = match strategy {
WorkdirStrategy::LocalWorktree => Some("worktree"),
WorkdirStrategy::Cloud => Some("remote sandbox"),
@ -491,33 +500,36 @@ pub async fn initialize(
.build(Some(Arc::clone(&sandbox_event_callback)))
.await
.map_err(|e| Error::engine(e.to_string()))?;
metadata_runtime
.ensure_git_available(&*inner)
.await
.map_err(|err| Error::engine(format!("sandbox git unavailable: {err}")))?;
let base_sha = resolve_worktree_base_sha(&*inner, plan).await?;
options.run_options.display_base_sha = Some(base_sha.clone());
options.run_options.git = Some(GitCheckpointOptions {
base_sha: Some(base_sha.clone()),
run_branch: Some(plan.branch_name.clone()),
meta_branch: Some(metadata_branch_name(&options.run_id.to_string())),
});
let mut worktree = WorktreeSandbox::new(inner, WorktreeOptions {
branch_name: plan.branch_name.clone(),
base_sha,
worktree_path: plan.worktree_path.to_string_lossy().into_owned(),
skip_branch_creation: plan.skip_branch_creation,
setup_intent: Some(git_setup_intent(&options.run_options)),
});
worktree.set_event_callback(Arc::clone(&options.emitter).worktree_callback());
match worktree.initialize().await {
Ok(()) => {
worktree_created = true;
Arc::new(ReadBeforeWriteSandbox::new(Arc::new(worktree)))
}
Err(e) => {
return Err(Error::engine(format!("Git worktree setup failed: {e}")));
if let Some(base_sha) = resolve_worktree_base_sha(&*inner, plan).await? {
metadata_runtime
.ensure_git_available(&*inner)
.await
.map_err(|err| Error::engine(format!("sandbox git unavailable: {err}")))?;
options.run_options.display_base_sha = Some(base_sha.clone());
options.run_options.git = Some(GitCheckpointOptions {
base_sha: Some(base_sha.clone()),
run_branch: Some(plan.branch_name.clone()),
meta_branch: Some(metadata_branch_name(&options.run_id.to_string())),
});
let mut worktree = WorktreeSandbox::new(inner, WorktreeOptions {
branch_name: plan.branch_name.clone(),
base_sha,
worktree_path: plan.worktree_path.to_string_lossy().into_owned(),
skip_branch_creation: plan.skip_branch_creation,
setup_intent: Some(git_setup_intent(&options.run_options)),
});
worktree.set_event_callback(Arc::clone(&options.emitter).worktree_callback());
match worktree.initialize().await {
Ok(()) => {
worktree_created = true;
Arc::new(ReadBeforeWriteSandbox::new(Arc::new(worktree)))
}
Err(e) => {
return Err(Error::engine(format!("Git worktree setup failed: {e}")));
}
}
} else {
Arc::new(ReadBeforeWriteSandbox::new(inner))
}
} else {
Arc::new(ReadBeforeWriteSandbox::new(

View file

@ -34,7 +34,7 @@ export interface RunSpec {
'source_directory'?: string | null;
'repo_origin_url'?: string | null;
'base_branch'?: string | null;
'labels': { [key: string]: string; };
'labels'?: { [key: string]: string; };
'provenance'?: { [key: string]: any; } | null;
'manifest_blob'?: string | null;
'definition_blob'?: string | null;