haz-exec 0.1.0

Async task execution engine for haz.
Documentation
//! Chunk 10 scenario (e): cross-project runtime cycle.
//!
//! Two projects `lib` and `app`. Their tasks declare crossing
//! input / output pairs:
//!
//! - `lib:produce` -- inputs `/app/b_out`, outputs `/lib/a_out`
//! - `app:consume` -- inputs `/lib/a_out`, outputs `/app/b_out`
//!
//! Producer-matching (`DAG-013`) at runtime adds an edge
//! `lib:produce -> app:consume` (lib's outputs match app's
//! inputs) and the symmetric `app:consume -> lib:produce`,
//! forming a length-2 cycle whose edges cross project
//! boundaries. The static check (`DAG-014`) does not reject
//! the workspace because both sides are literal paths; the
//! runtime check (`EXEC-019`) does, when the second task
//! completes and its new producer-matching edge would close
//! the cycle.
//!
//! The per-task outcomes remain `Succeeded` (the cycle is a
//! run-level diagnostic, not a per-task classification); the
//! diagnostic surfaces on `RunGraphOutcome.invariant_violations`
//! as a single `RuntimeCycle` whose node set spans both
//! projects.

mod common;

use std::collections::BTreeSet;

use common::{
    Fixture, Recorder, TaskBuilder, completed_for, host_path, make_graph, make_project,
    make_workspace_at, nested_project_root, push_n_default_specs, tid,
    workspace_settings_with_fixed_cap,
};
use haz_domain::task::Task;
use haz_domain::task_id::TaskId;
use haz_exec::mock_impl::MockProcessSpawner;
use haz_exec::run_graph::{RuntimeInvariantViolation, run_graph};
use haz_exec::run_task::RunState;
use haz_vfs::WritableFilesystem;

const WORKSPACE_HOST: &str = "/ws";
const A_OUT: &str = "/lib/a_out";
const B_OUT: &str = "/app/b_out";

#[tokio::test]
async fn cross_project_runtime_cycle_yields_one_violation_spanning_both_projects() {
    // Distinct argvs so each task carries a distinct cache key
    // (`CACHE-001`'s content addressing would otherwise dedupe).
    let produce: Task = TaskBuilder::new("produce")
        .command(&["echo", "lib:produce"])
        .input(B_OUT)
        .output(A_OUT)
        .build();
    let consume: Task = TaskBuilder::new("consume")
        .command(&["echo", "app:consume"])
        .input(A_OUT)
        .output(B_OUT)
        .build();

    let lib_project = make_project("lib", nested_project_root("/lib"), vec![produce]);
    let app_project = make_project("app", nested_project_root("/app"), vec![consume]);

    let workspace_host = std::path::PathBuf::from(WORKSPACE_HOST);
    let workspace = make_workspace_at(
        &workspace_host,
        vec![lib_project, app_project],
        workspace_settings_with_fixed_cap(2),
    );
    let graph = make_graph(
        vec![tid("lib", "produce"), tid("app", "consume")],
        Vec::new(),
    );
    let fixture = Fixture::new_mem(&workspace_host, workspace, graph);

    // Pre-write both files so each task's cache-key derivation
    // reads its input and the `resolve_output_files` pass finds
    // its output.
    fixture
        .cache
        .fs()
        .write_file(&host_path(&workspace_host, A_OUT), b"a")
        .unwrap();
    fixture
        .cache
        .fs()
        .write_file(&host_path(&workspace_host, B_OUT), b"b")
        .unwrap();

    let spawner = MockProcessSpawner::new();
    push_n_default_specs(&spawner, 2);
    let observer = Recorder::default();
    let ctx = common::make_ctx(&fixture, &spawner, &observer);

    let result = run_graph(&ctx, 1_700_000_000).await.unwrap();

    // Per-task outcomes stay honest: both ran fresh and
    // succeeded. The cycle is a run-level diagnostic, not a
    // per-task classification.
    for task in [tid("lib", "produce"), tid("app", "consume")] {
        let rec = completed_for(&result.outcomes, &task);
        assert_eq!(rec.state, RunState::Succeeded);
    }
    assert_eq!(spawner.spawns().len(), 2);

    // Exactly one RuntimeCycle violation; the cycle's node set
    // is the two cross-project tasks; the offending edge's
    // endpoints are both cycle members and distinct
    // (`EXEC-019`'s length-≥2 carve-out per `DAG-014`).
    assert_eq!(result.invariant_violations.len(), 1);
    let expected: BTreeSet<TaskId> = BTreeSet::from([tid("lib", "produce"), tid("app", "consume")]);
    match &result.invariant_violations[0] {
        RuntimeInvariantViolation::RuntimeCycle {
            nodes,
            offending_edge,
        } => {
            assert_eq!(nodes, &expected);
            assert!(expected.contains(&offending_edge.0));
            assert!(expected.contains(&offending_edge.1));
            assert_ne!(offending_edge.0, offending_edge.1);
            // Cross-project structural assertion: the two
            // endpoints belong to distinct projects, so the
            // offending edge crosses a project boundary
            // regardless of which task completed last.
            assert_ne!(
                offending_edge.0.project, offending_edge.1.project,
                "the cycle's offending edge must cross project boundaries",
            );
        }
        other @ RuntimeInvariantViolation::OutputOverlap { .. } => {
            panic!("expected RuntimeCycle, got {other:?}")
        }
    }
}