Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions docs/2026-09-26-defect-closure/handoffs/ACCEPTANCE-RUN-4.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Acceptance Run 4 — agents' work is now actually delivered

**Date:** 2026-09-29 · branch `hardening/production`

## The gap this closes

Before this change the pipeline ran agents in worktrees and then verified the
**base project**, which contained none of their work. The run could report
`succeeded` while every agent's output stayed stranded in a throwaway worktree —
the acceptance test had to assert `agent_wrote_greeting_in_worktree=true` because
`greeting_in_base_project` was false.

Now `wave_executor::merge_completed_agents` runs between finalize and verify:

1. commits each completed agent's worktree (excluding `.acc/` bookkeeping),
2. merges its branch into the base branch (`--no-ff`),
3. removes the worktree — only after its work is merged,
4. on conflict: aborts the merge, records it, and the run pauses as
`awaiting_user` with the conflicting agents named (deploy is skipped too).

A production-mode flag (`PipelineAdapters::deliver`) enables it, alongside
`install_deps`, so hermetic tests stay offline and deterministic.

## Result (real agents, 2 concurrent)

```
status : succeeded
finalize_and_verify done verification passed=true
deploy done deployed via mock → … (+ Dockerfile artifact)
delivery: greeting_in_base_project=true farewell_in_base_project=true
merges : {"conflicts":[],"merged":[{"agent_ref":"1-1.1",…},{"agent_ref":"1-1.2",…}]}
192 lib + 42 integration, 341 web, tsc/lint clean — 69.75s
```

Both agents' files are in the base project; every merge names its commit; no
worktrees are left behind.

## Defect found by this step (and fixed)

The first delivery attempt failed with
`CONFLICT (add/add): Merge conflict in .acc/GUIDELINE.md` — each agent's worktree
contained its **own** copy of the generated guideline, and every merge after the
first collided on it. Per-agent bookkeeping is not deliverable, so the commit now
excludes `.acc/` via a pathspec (`:(exclude).acc`), which needs no change to the
user's git config. The fail-closed behaviour worked: the run reported
`awaiting_user` and skipped deploy instead of shipping a half-merged project.
Regression test: the merge tests give every agent a distinct
`.acc/GUIDELINE.md` and still expect a clean 2/2 merge.

## Still not proven

- **Real build/test gate end-to-end.** `install_deps` + `VerifyMode::RequireBuild`
are implemented and unit-tested (`test_require_build_turns_tooling_skips_into_failures`),
but the acceptance run keeps `install_deps=false` because the synthetic fixture
has no real toolchain (its `build` script references `vite`, which is not
installed). The next milestone is a **from-empty** run: `scaffold_project`
produces a real app → `npm install` → real `build`/`typecheck`/runtime checks
execute and can fail — the point where "verification passed" finally means
"the app was built and tested".
- **GitHub delivery** (private repo, issues, PRs) is still not implemented; it
remains documented in `EXTERNAL-VERIFICATION.md`.
187 changes: 185 additions & 2 deletions src-tauri/src/pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,13 @@ pub struct PipelineAdapters<'a> {
/// None = detect CLIs on this machine.
pub cli_status: Option<HashMap<String, CliStatus>>,
pub run_compounder: bool,
/// Install project dependencies before verification and treat an
/// un-runnable build/test as a verification failure. Production `true`;
/// hermetic tests `false` so they stay offline and deterministic.
pub install_deps: bool,
/// Commit each agent's work and merge it into the base branch (delivery).
/// Production `true`; hermetic tests use non-git stand-in worktrees.
pub deliver: bool,
}

enum StageResult {
Expand All @@ -296,6 +303,7 @@ struct PipelineState {
verification: Option<crate::verification::VerificationReport>,
deploy: Option<DeployOutcome>,
compounder_items: usize,
merges: Option<crate::wave_executor::MergeReport>,
}

fn lock<'a>(db: &'a Mutex<Connection>) -> Result<std::sync::MutexGuard<'a, Connection>, String> {
Expand Down Expand Up @@ -439,6 +447,38 @@ pub fn run_pipeline(
}
}

// Work that could not be merged was not delivered: pause for the operator
// instead of verifying/deploying a project that is missing agent output.
if let Some(merges) = &state.merges {
if !merges.conflicts.is_empty() {
let detail = merges
.conflicts
.iter()
.map(|record| format!("{}: {}", record.agent_ref, record.detail))
.collect::<Vec<_>>()
.join("; ");
let message = format!(
"{} agent branch(es) could not be merged: {detail}",
merges.conflicts.len()
);
report.status = pipeline_store::STATUS_AWAITING_USER.to_string();
report.error = Some(message.clone());
finalize_report(db, opts, &mut report, &state)?;
{
let conn = lock(db)?;
pipeline_store::set_error(&conn, &opts.run_id, &message)?;
pipeline_store::update_status(
&conn,
&opts.run_id,
pipeline_store::STATUS_AWAITING_USER,
Some("report"),
)?;
}
adapters.event_sink.emit("report", "merge_conflict", &message);
return Ok(report);
}
}

// A run whose agents never produced a valid handoff is NOT a success, even
// when the project happens to satisfy verification (e.g. it already passed
// before the agents ran). Pause instead of overclaiming: the operator can
Expand Down Expand Up @@ -533,6 +573,11 @@ fn finalize_report(
"generate_dockerfile": opts.generate_dockerfile,
"agent_timeout_secs": opts.agent_timeout_secs,
});
// Delivery evidence: which agent branches were merged into the base branch.
if let Some(merges) = &state.merges {
report.artifacts["merges"] =
serde_json::to_value(merges).unwrap_or(serde_json::Value::Null);
}

let conn = lock(db)?;
pipeline_store::set_report(&conn, &opts.run_id, &serde_json::to_value(report).unwrap_or_default())
Expand All @@ -552,7 +597,7 @@ fn run_stage(
BuildStage::Scaffold => stage_scaffold(opts, state),
BuildStage::SeedPlan => stage_seed_plan(db, opts, state),
BuildStage::ExecuteWaves => stage_execute_waves(db, opts, adapters, state),
BuildStage::FinalizeAndVerify => stage_finalize_and_verify(db, adapters, state),
BuildStage::FinalizeAndVerify => stage_finalize_and_verify(db, opts, adapters, state),
BuildStage::Deploy => stage_deploy(opts, adapters, state),
BuildStage::Report => Ok(StageResult::Done("report assembled".to_string())),
}
Expand Down Expand Up @@ -825,6 +870,7 @@ fn stage_execute_waves(

fn stage_finalize_and_verify(
db: &Mutex<Connection>,
opts: &PipelineOptions,
adapters: &PipelineAdapters,
state: &mut PipelineState,
) -> Result<StageResult, String> {
Expand All @@ -847,9 +893,47 @@ fn stage_finalize_and_verify(

// Feed the compounder: the adapter execution path writes no session events,
// so derive them from the handoffs (otherwise compounder_items is always 0).
// Must run before the merge removes the worktrees.
record_handoff_events(db, &finalized);

let verification = crate::verification::verify_project(Path::new(&project_path));
// Deliver: commit each completed agent's work and merge it into the base
// branch. Without this the agents' work stays stranded in worktrees and the
// project being verified/deployed contains none of it.
if adapters.deliver {
let merges = crate::wave_executor::merge_completed_agents(
&finalized,
&project_path,
&opts.base_branch,
);
adapters.event_sink.emit(
"finalize_and_verify",
"progress",
&format!(
"delivered {} agent branch(es), {} conflict(s)",
merges.merged.len(),
merges.conflicts.len()
),
);
state.merges = Some(merges);
}

// Real build/test gate: install the project's dependencies so the build,
// typecheck and runtime checks actually execute instead of being Skipped.
if adapters.install_deps {
let detail = install_project_dependencies(&project_path)?;
adapters
.event_sink
.emit("finalize_and_verify", "progress", &detail);
}

let verification = if adapters.install_deps {
crate::verification::verify_project_with(
Path::new(&project_path),
crate::verification::VerifyMode::RequireBuild,
)
} else {
crate::verification::verify_project(Path::new(&project_path))
};
let passed = verification.passed;
state.verification = Some(verification);

Expand Down Expand Up @@ -935,6 +1019,85 @@ fn compound_session(
Ok(items.len())
}

/// Hard cap on the dependency install so a hung registry cannot wedge a run.
const DEPENDENCY_INSTALL_TIMEOUT_SECS: u64 = 900;

/// Install the project's dependencies (`npm ci` when a lock file exists).
///
/// Without this the build/typecheck/runtime checks only had the option to Skip,
/// so "verification passed" could mean "nothing was ever built".
fn install_project_dependencies(project_path: &str) -> Result<String, String> {
let base = Path::new(project_path);
if !base.join("package.json").exists() {
return Ok("no package.json — no dependencies to install".to_string());
}
let args: Vec<&str> = if base.join("package-lock.json").exists() {
vec!["ci", "--no-audit", "--no-fund"]
} else {
vec!["install", "--no-audit", "--no-fund"]
};

let mut cmd = if cfg!(windows) {
let mut c = std::process::Command::new("cmd");
c.arg("/C").arg("npm");
c
} else {
std::process::Command::new("npm")
};
cmd.args(&args).current_dir(base);

run_with_timeout(cmd, DEPENDENCY_INSTALL_TIMEOUT_SECS)
.map(|_| format!("dependencies installed (npm {})", args.join(" ")))
}

/// Run a command with a wall-clock timeout, sending its output to a temp log so
/// a chatty child cannot deadlock on a full pipe. Failures carry the log tail.
fn run_with_timeout(mut cmd: std::process::Command, secs: u64) -> Result<(), String> {
let log_path =
std::env::temp_dir().join(format!("sourceforge-cmd-{}.log", std::process::id()));
let log = std::fs::File::create(&log_path).map_err(|e| e.to_string())?;
let log_err = log.try_clone().map_err(|e| e.to_string())?;
let mut child = cmd
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::from(log))
.stderr(std::process::Stdio::from(log_err))
.spawn()
.map_err(|e| format!("cannot start command: {e}"))?;

let deadline = std::time::Instant::now() + std::time::Duration::from_secs(secs);
loop {
match child.try_wait() {
Ok(Some(status)) if status.success() => return Ok(()),
Ok(Some(status)) => {
return Err(format!(
"command failed ({status}); output tail: {}",
tail_of(&log_path)
));
}
Ok(None) => {
if std::time::Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
return Err(format!(
"timed out after {secs}s; output tail: {}",
tail_of(&log_path)
));
}
std::thread::sleep(std::time::Duration::from_millis(200));
}
Err(error) => return Err(error.to_string()),
}
}
}

fn tail_of(path: &Path) -> String {
let Ok(text) = std::fs::read_to_string(path) else {
return String::new();
};
let tail: Vec<&str> = text.lines().rev().take(8).collect();
tail.into_iter().rev().collect::<Vec<_>>().join(" | ")
}

/// Agents that did not finish with a valid handoff, as `(agent_ref, status)`.
/// A non-empty result means the promised work was not done.
fn incomplete_agents(report: &crate::wave_executor::WaveExecutionReport) -> Vec<(String, String)> {
Expand Down Expand Up @@ -975,6 +1138,14 @@ fn stage_deploy(
// happened to be in the worktree), so gate it like a failed verification
// instead of pushing a half-done project to production.
if !opts.allow_deploy_on_failed_verification {
if let Some(merges) = &state.merges {
if !merges.conflicts.is_empty() {
return Ok(StageResult::Skipped(format!(
"{} agent branch(es) could not be merged — deploy skipped",
merges.conflicts.len()
)));
}
}
if let Some(wave) = &state.wave_report {
let incomplete = incomplete_agents(wave);
if !incomplete.is_empty() {
Expand Down Expand Up @@ -1260,6 +1431,8 @@ mod tests {
event_sink: &NoopEventSink,
llm: &llm,
cli_status: Some(all_clis_installed()),
install_deps: false,
deliver: false,
run_compounder: true,
};

Expand Down Expand Up @@ -1388,6 +1561,8 @@ mod tests {
event_sink: &NoopEventSink,
llm: &llm,
cli_status: Some(status),
install_deps: false,
deliver: false,
run_compounder: false,
};

Expand All @@ -1405,6 +1580,8 @@ mod tests {
event_sink: &NoopEventSink,
llm: &llm,
cli_status: Some(all_clis_installed()),
install_deps: false,
deliver: false,
run_compounder: false,
};

Expand Down Expand Up @@ -1441,6 +1618,8 @@ mod tests {
event_sink: &NoopEventSink,
llm: &llm,
cli_status: Some(all_clis_installed()),
install_deps: false,
deliver: false,
run_compounder: false,
};

Expand Down Expand Up @@ -1472,6 +1651,8 @@ mod tests {
event_sink: &NoopEventSink,
llm: &llm,
cli_status: Some(all_clis_installed()),
install_deps: false,
deliver: false,
run_compounder: false,
};

Expand Down Expand Up @@ -1542,6 +1723,8 @@ mod tests {
event_sink: &NoopEventSink,
llm: &llm,
cli_status: Some(all_clis_installed()),
install_deps: false,
deliver: false,
run_compounder: false,
};

Expand Down
2 changes: 2 additions & 0 deletions src-tauri/src/pipeline_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ async fn run_on_blocking_thread(
event_sink: &sink,
llm: &llm,
cli_status: None,
install_deps: true,
deliver: true,
run_compounder: true,
};

Expand Down
Loading
Loading