From fa542579f34b568774681d07bc968b629912d227 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Mon, 7 Sep 2026 13:46:10 +0200 Subject: [PATCH 1/6] test: capture placement and shared workspace regression for #225 --- kernel/evidence/225/red.txt | 155 ++++++++++++++++++ kernel/relayflowd/tests/crash_resume.rs | 2 + .../tests/crash_resume/placement.rs | 67 ++++++++ 3 files changed, 224 insertions(+) create mode 100644 kernel/evidence/225/red.txt create mode 100644 kernel/relayflowd/tests/crash_resume/placement.rs diff --git a/kernel/evidence/225/red.txt b/kernel/evidence/225/red.txt new file mode 100644 index 000000000..f90f323d7 --- /dev/null +++ b/kernel/evidence/225/red.txt @@ -0,0 +1,155 @@ +$ cd kernel && cargo test --workspace --test crash_resume placement:: -- --nocapture + Compiling proc-macro2 v1.0.107 + Compiling quote v1.0.47 + Compiling unicode-ident v1.0.24 + Compiling libc v0.2.189 + Compiling cfg-if v1.0.4 + Compiling stable_deref_trait v1.2.1 + Compiling version_check v0.9.5 + Compiling autocfg v1.5.1 + Compiling serde_core v1.0.229 + Compiling getrandom v0.3.4 + Compiling zerocopy v0.8.56 + Compiling serde v1.0.229 + Compiling smallvec v1.15.2 + Compiling litemap v0.8.3 + Compiling writeable v0.6.4 + Compiling num-traits v0.2.19 + Compiling memchr v2.8.3 + Compiling generic-array v0.14.7 + Compiling icu_properties_data v2.3.0 + Compiling utf8_iter v1.0.4 + Compiling icu_normalizer_data v2.3.0 + Compiling parking_lot_core v0.9.12 + Compiling typenum v1.20.1 + Compiling zmij v1.0.23 + Compiling ref-cast v1.0.27 + Compiling syn v3.0.4 + Compiling syn v2.0.119 + Compiling synstructure v0.13.2 + Compiling num-integer v0.1.47 + Compiling num-bigint v0.4.8 + Compiling aho-corasick v1.1.5 + Compiling ahash v0.8.12 + Compiling zerofrom-derive v0.1.7 + Compiling yoke-derive v0.8.2 + Compiling shlex v2.0.1 + Compiling zerofrom v0.1.8 + Compiling serde_json v1.0.151 + Compiling once_cell v1.21.4 + Compiling regex-syntax v0.8.11 + Compiling scopeguard v1.2.0 + Compiling find-msvc-tools v0.1.11 + Compiling lock_api v0.4.14 + Compiling cc v1.4.4 + Compiling num-rational v0.4.2 + Compiling zerovec-derive v0.11.6 + Compiling displaydoc v0.2.7 + Compiling serde_derive v1.0.229 + Compiling ref-cast-impl v1.0.27 + Compiling yoke v0.8.3 + Compiling num-iter v0.1.46 + Compiling num-complex v0.4.6 + Compiling rand_core v0.9.5 + Compiling bit-vec v0.8.0 + Compiling pkg-config v0.3.34 + Compiling itoa v1.0.18 + Compiling borrow-or-share v0.2.4 + Compiling regex-automata v0.4.18 + Compiling vcpkg v0.2.15 + Compiling ppv-lite86 v0.2.21 + Compiling bit-set v0.8.0 + Compiling num v0.4.3 + Compiling parking_lot v0.12.5 + Compiling block-buffer v0.10.4 + Compiling rand_chacha v0.9.0 + Compiling crypto-common v0.1.7 + Compiling uuid v1.26.0 + Compiling libsqlite3-sys v0.35.0 + Compiling vsimd v0.8.0 + Compiling utf8parse v0.2.2 + Compiling zerovec v0.11.8 + Compiling zerotrie v0.2.5 + Compiling thiserror v2.0.20 + Compiling lazy_static v1.5.0 + Compiling foldhash v0.1.5 + Compiling percent-encoding v2.3.2 + Compiling outref v0.5.2 + Compiling bitflags v2.13.1 + Compiling uuid-simd v0.8.0 + Compiling hashbrown v0.15.5 + Compiling fraction v0.15.4 + Compiling anstyle-parse v1.0.0 + Compiling rand v0.9.5 + Compiling digest v0.10.7 + Compiling thiserror-impl v2.0.20 + Compiling cpufeatures v0.2.17 + Compiling anstyle v1.0.14 + Compiling num-cmp v0.1.0 + Compiling bytecount v0.6.9 + Compiling colorchoice v1.0.5 + Compiling is_terminal_polyfill v1.70.2 + Compiling anstyle-query v1.1.5 + Compiling base64 v0.22.1 + Compiling sha2 v0.10.9 + Compiling anstream v1.0.0 + Compiling hashlink v0.10.0 + Compiling clap_lex v1.1.0 + Compiling fallible-iterator v0.3.0 + Compiling strsim v0.11.1 + Compiling heck v0.5.0 + Compiling anyhow v1.0.104 + Compiling fallible-streaming-iterator v0.1.9 + Compiling clap_builder v4.6.6 + Compiling tinystr v0.8.4 + Compiling potential_utf v0.1.6 + Compiling clap_derive v4.6.4 + Compiling getrandom v0.4.3 + Compiling icu_locale_core v2.3.0 + Compiling icu_collections v2.3.0 + Compiling rustix v1.1.4 + Compiling fancy-regex v0.16.2 + Compiling icu_provider v2.3.1 + Compiling fluent-uri v0.3.2 + Compiling icu_normalizer v2.3.0 + Compiling icu_properties v2.3.0 + Compiling email_address v0.2.9 + Compiling regex v1.13.1 + Compiling ulid v1.2.1 + Compiling clap v4.6.6 + Compiling referencing v0.33.0 + Compiling wait-timeout v0.2.1 + Compiling errno v0.3.14 + Compiling fastrand v2.5.0 + Compiling idna_adapter v1.2.2 + Compiling idna v1.1.0 + Compiling jsonschema v0.33.0 + Compiling tempfile v3.27.0 + Compiling rusqlite v0.37.0 + Compiling relayflowd-core v0.1.0 (/Users/khaliqgant/AgentWorkforce/flows-225-placement-wt/kernel/relayflowd-core) + Compiling relayflowd-journal v0.1.0 (/Users/khaliqgant/AgentWorkforce/flows-225-placement-wt/kernel/relayflowd-journal) + Compiling relayflowd v0.1.0 (/Users/khaliqgant/AgentWorkforce/flows-225-placement-wt/kernel/relayflowd) + Finished `test` profile [unoptimized + debuginfo] target(s) in 16.12s + Running tests/crash_resume.rs (target/debug/deps/crash_resume-e6635a3f0d48512c) + +running 1 test + +thread 'placement::declared_placement_keeps_one_source_tree_across_resume' (84534924) panicked at relayflowd/tests/crash_resume/placement.rs:40:5: +run failed: Error: parse run spec /var/folders/yv/nbp9l2c55wlbj1x0gml37s7c0000gn/T/.tmpsHRxJK/flow.json + +Caused by: + unknown field "requirements" at steps[0] — refusing to guess (fail closed) + +note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace +test placement::declared_placement_keeps_one_source_tree_across_resume ... FAILED + +failures: + +failures: + placement::declared_placement_keeps_one_source_tree_across_resume + +test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 37 filtered out; finished in 0.65s + +error: test failed, to rerun pass `-p relayflowd --test crash_resume` + +exit_code=101 diff --git a/kernel/relayflowd/tests/crash_resume.rs b/kernel/relayflowd/tests/crash_resume.rs index 51d36f039..c77613d83 100644 --- a/kernel/relayflowd/tests/crash_resume.rs +++ b/kernel/relayflowd/tests/crash_resume.rs @@ -29,6 +29,8 @@ mod surface_identity; mod worker_capacity; #[path = "crash_resume/workspace_identity.rs"] mod workspace_identity; +#[path = "crash_resume/placement.rs"] +mod placement; use std::{ fs, io::Write, os::unix::net::UnixStream, os::unix::process::CommandExt, process::Command, diff --git a/kernel/relayflowd/tests/crash_resume/placement.rs b/kernel/relayflowd/tests/crash_resume/placement.rs new file mode 100644 index 000000000..f87f56dfa --- /dev/null +++ b/kernel/relayflowd/tests/crash_resume/placement.rs @@ -0,0 +1,67 @@ +use std::{fs, process::Command}; + +use relayflowd::{RunOutcome, RunStatus}; +use relayflowd_core::EntryType; +use serde_json::json; +use tempfile::tempdir; + +use super::support::{journal_entries, only_run_id}; + +#[test] +fn declared_placement_keeps_one_source_tree_across_resume() { + let directory = tempdir().unwrap(); + let tree = directory.path().join("source"); + let elsewhere = directory.path().join("elsewhere"); + let data = directory.path().join("data"); + fs::create_dir(&tree).unwrap(); + fs::create_dir(&elsewhere).unwrap(); + fs::write(tree.join("source.txt"), "source-present\n").unwrap(); + for args in [ + vec!["init", "-q"], + vec!["add", "source.txt"], + vec!["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-qm", "source"], + ] { + assert!(Command::new("git").args(args).current_dir(&tree).status().unwrap().success()); + } + let revision = Command::new("git").args(["rev-parse", "HEAD"]) + .current_dir(&tree).output().unwrap(); + let revision = String::from_utf8(revision.stdout).unwrap().trim().to_owned(); + let spec = directory.path().join("flow.json"); + fs::write(&spec, json!({"steps":[ + {"id":"write","type":"deterministic","requirements":{"workspace":true,"execution":"batch"}, + "command":"cat source.txt > shared.txt"}, + {"id":"read","type":"deterministic","depends_on":["write"], + "requirements":{"workspace":true,"execution":"batch"}, + "command":"cat shared.txt","verification":{"output_contains":"source-present"}} + ]}).to_string()).unwrap(); + let first = Command::new(env!("CARGO_BIN_EXE_relayflowd")) + .arg("--data-dir").arg(&data).arg("run").arg(&spec) + .args(["--stop-after", "1"]).current_dir(&tree).output().unwrap(); + assert!(first.status.success(), "run failed: {}", String::from_utf8_lossy(&first.stderr)); + let run = only_run_id(&data); + let resumed = Command::new(env!("CARGO_BIN_EXE_relayflowd")) + .arg("--data-dir").arg(&data).args(["resume", &run]) + .current_dir(&elsewhere).output().unwrap(); + assert!(resumed.status.success(), "resume failed: {resumed:?}"); + let outcome: RunOutcome = serde_json::from_slice(&resumed.stdout).unwrap(); + assert_eq!(outcome.status, RunStatus::Completed); + assert_eq!(fs::read_to_string(tree.join("shared.txt")).unwrap(), "source-present\n"); + assert!(!elsewhere.join("shared.txt").exists()); + let entries = journal_entries(&data).unwrap(); + let routes: Vec<_> = entries.iter().filter(|e| e.entry_type.as_str() == "step.routed").collect(); + assert_eq!(routes.len(), 2, "each routing decision must be durable"); + for route in routes { + assert_eq!(route.payload["profile"], "batch"); + assert_eq!(route.payload["provider"], "local"); + assert_eq!(route.payload["fallbacks_attempted"], json!([])); + } + for start in entries.iter().filter(|e| e.entry_type == EntryType::StepAttemptStarted) { + assert_eq!(start.payload["pins"]["workspace"][0]["revision_id"], revision); + assert_eq!(start.payload["pins"]["workspace"][0]["surface"], fs::canonicalize(&tree).unwrap().to_str().unwrap()); + } + let replay = Command::new(env!("CARGO_BIN_EXE_relayflowd")) + .arg("--data-dir").arg(&data).args(["resume", &run]) + .current_dir(&elsewhere).output().unwrap(); + assert!(replay.status.success()); + assert_eq!(entries, journal_entries(&data).unwrap(), "completed replay must not decide again"); +} From 541d90078d23f3037cbc373dc550ba6e91fea66e Mon Sep 17 00:00:00 2001 From: kjgbot Date: Mon, 7 Sep 2026 14:02:43 +0200 Subject: [PATCH 2/6] feat: journal fixed step placement and pin declared workspaces --- kernel/Cargo.lock | 1 + kernel/evidence/225/README.md | 71 ++++ kernel/evidence/225/green-kernel.txt | 313 ++++++++++++++++++ kernel/evidence/225/green-sdk.txt | 20 ++ kernel/relayflowd-core/src/entry.rs | 6 + kernel/relayflowd-core/src/journal.rs | 1 + kernel/relayflowd-core/src/lib.rs | 2 + kernel/relayflowd-core/src/placement.rs | 83 +++++ kernel/relayflowd-core/src/spec.rs | 9 + kernel/relayflowd-core/src/state.rs | 32 +- kernel/relayflowd-core/tests/spec_parity.rs | 34 ++ kernel/relayflowd-journal/src/append.rs | 1 + kernel/relayflowd-journal/src/lib.rs | 2 + kernel/relayflowd-journal/src/placement.rs | 62 ++++ kernel/relayflowd-journal/src/segment.rs | 1 + kernel/relayflowd/Cargo.toml | 1 + kernel/relayflowd/src/engine.rs | 3 + kernel/relayflowd/src/engine/drive.rs | 33 +- kernel/relayflowd/src/engine/placement.rs | 168 ++++++++++ kernel/relayflowd/src/exec_det.rs | 11 + kernel/relayflowd/src/lib.rs | 2 + kernel/relayflowd/src/worker.rs | 29 +- kernel/relayflowd/src/workspace.rs | 50 +++ .../tests/crash_resume/placement.rs | 171 ++++++++-- kernel/relayflowd/tests/memory_epoch.rs | 1 + kernel/relayflowd/tests/placement_pins.rs | 86 +++++ kernel/relayflowd/tests/placement_routing.rs | 217 ++++++++++++ packages/sdk/src/compile.ts | 18 +- packages/sdk/src/index.ts | 3 + packages/sdk/src/protocol.ts | 9 + packages/sdk/src/spec.ts | 21 +- packages/sdk/src/step-fields.ts | 1 + packages/sdk/src/validate.ts | 19 ++ packages/sdk/tests/placement.test.ts | 27 ++ packages/sdk/tests/spec-parity.test.ts | 2 +- testdata/placement-spec-cases.json | 127 +++++++ testdata/step-placement.flow.yaml | 11 + testdata/step-placement.spec.canonical.json | 1 + testdata/step-placement.spec.sha256 | 1 + 39 files changed, 1611 insertions(+), 39 deletions(-) create mode 100644 kernel/evidence/225/README.md create mode 100644 kernel/evidence/225/green-kernel.txt create mode 100644 kernel/evidence/225/green-sdk.txt create mode 100644 kernel/relayflowd-core/src/placement.rs create mode 100644 kernel/relayflowd-journal/src/placement.rs create mode 100644 kernel/relayflowd/src/engine/placement.rs create mode 100644 kernel/relayflowd/src/workspace.rs create mode 100644 kernel/relayflowd/tests/placement_pins.rs create mode 100644 kernel/relayflowd/tests/placement_routing.rs create mode 100644 packages/sdk/tests/placement.test.ts create mode 100644 testdata/placement-spec-cases.json create mode 100644 testdata/step-placement.flow.yaml create mode 100644 testdata/step-placement.spec.canonical.json create mode 100644 testdata/step-placement.spec.sha256 diff --git a/kernel/Cargo.lock b/kernel/Cargo.lock index c7d7cda40..bee676327 100644 --- a/kernel/Cargo.lock +++ b/kernel/Cargo.lock @@ -903,6 +903,7 @@ dependencies = [ "libc", "relayflowd-core", "relayflowd-journal", + "rusqlite", "serde", "serde_json", "sha2", diff --git a/kernel/evidence/225/README.md b/kernel/evidence/225/README.md new file mode 100644 index 000000000..7a1a239d3 --- /dev/null +++ b/kernel/evidence/225/README.md @@ -0,0 +1,71 @@ +# Issue 225 — gate 7 slice 1 + +The red test was committed before implementation in `fa54257`. It submits two +deterministic steps with placement requirements: the first copies a source file +into a relative output file, the second reads that output. Resume runs from a +different working directory. The initial failure was the kernel rejecting +`requirements`, captured verbatim in [red.txt](red.txt). + +The implementation adds: + +- Authoring and kernel `requirements`: execution (`batch` or `interactive`), + workspace, network, expected duration, and cost/latency/reliability/balanced + preference. Duration lowers from `expectedDurationMs` to `expected_duration_ms`. + Provider names and revisions are not authoring fields. Shared canonical JSON, + hash, and an acceptance/rejection corpus pin both dialects. +- `step.routed`: profile, provider, fallbacks attempted, and optional provider + workspace identity. A step has one routing fact, written before execution and + retained on retries and epoch rollover. `StepDispatch.routing` carries that + fact to the adapter. Duplicate routing writes fail inside the SQLite append + transaction. A failed append prevents attempt start and dispatch. +- Fixed local batch placement for deterministic steps. Declaring requirements + opts into a shared worktree unless `workspace: false`; submission records its + canonical directory. Attempts and completions record its Git base commit. + Each command executes in the recorded directory, including after resume from + elsewhere. Local interactive execution and unavailable source commits are + refused before submission. Network is a capability need (`false` means no + need), not a network isolation policy; duration and preference are hints for + later ranking. +- The default in-process worker pin source reads declared worktree base commits + instead of returning empty workspace pins. Remote workers continue to report + their own revision facts through the existing override/protocol. + +## Scope and integration boundary + +This is a declaration and journal slice, not all of gate 7. No sandbox, source +uploader, root lease manager, provider SDK, or ranking engine is introduced. +`StepDispatcher::routing_decision` is the adapter hook for an existing provider +orchestrator; it is invoked only until its decision is durable. Dispatch must +consume the recorded decision without selecting another provider on retry. +The default attached-worker dispatcher rejects unsupported placement declarations. + +The remote-shaped test uses `test-cloud-adapter`, an in-process test dispatcher. +It is **not a live cloud run**. No claim is made that Daytona source sync, +remote deterministic execution, or actual sandbox destruction was verified. +Connecting the flows transport to cloud's existing code-sync and sandbox +orchestration remains integration work. Git pins here are worktree base commits, +as allowed by Appendix A; they are not snapshots of uncommitted files. Agent +reset/inspect/manual recovery retains its existing revision protocol. + +Existing specs without requirements keep their execution behavior; local +worktree pinning is enabled by the new declaration. Their routing choices are +still recorded when execution is admitted. + +## Captured commands and output + +Each transcript contains the literal command, stdout/stderr, and exit code. + +- [Red regression](red.txt): + `cd kernel && cargo test --workspace --test crash_resume placement:: -- --nocapture` +- [Complete kernel gate](green-kernel.txt): + `cd kernel && cargo test --workspace` +- [SDK parity and type checks](green-sdk.txt): + `cd packages/sdk && node node_modules/vitest/vitest.mjs run tests/placement.test.ts tests/spec-parity.test.ts` + plus `node node_modules/typescript/bin/tsc --noEmit` and + `node node_modules/typescript/bin/tsc -p tsconfig.type-tests.json` in that directory. + +The SDK dependencies were copied into this worktree from the existing +`flows-212-channels-wt/packages/sdk/node_modules` after the local `npm` process +stalled before installing dependencies. No dependency manifest or lockfile was +changed for that copy. The Rust dev dependency on the already-used `rusqlite` +crate supports an actual SQLite failure-injection test. diff --git a/kernel/evidence/225/green-kernel.txt b/kernel/evidence/225/green-kernel.txt new file mode 100644 index 000000000..e6e5065ff --- /dev/null +++ b/kernel/evidence/225/green-kernel.txt @@ -0,0 +1,313 @@ +$ cd kernel && cargo test --workspace + Compiling relayflowd-core v0.1.0 (/Users/khaliqgant/AgentWorkforce/flows-225-placement-wt/kernel/relayflowd-core) + Compiling relayflowd-journal v0.1.0 (/Users/khaliqgant/AgentWorkforce/flows-225-placement-wt/kernel/relayflowd-journal) + Compiling relayflowd v0.1.0 (/Users/khaliqgant/AgentWorkforce/flows-225-placement-wt/kernel/relayflowd) + Finished `test` profile [unoptimized + debuginfo] target(s) in 5.66s + Running unittests src/lib.rs (target/debug/deps/relayflowd-3287862779f564ca) + +running 36 tests +test engine::remote::worker_failure_detail_tests::a_null_or_blank_output_yields_no_detail ... ok +test engine::remote::worker_failure_detail_tests::a_string_output_is_carried_verbatim_and_trimmed ... ok +test engine::remote::worker_failure_detail_tests::a_non_string_output_is_rendered_rather_than_dropped ... ok +test engine::remote::worker_failure_detail_tests::an_output_at_the_boundary_is_not_truncated ... ok +test engine::remote::worker_failure_detail_tests::truncation_does_not_split_a_multi_byte_char ... ok +test server::client::tests::resume_waits_while_the_heartbeat_renewed_lease_is_live ... ok +test engine::boot_identity_tests::every_engine_in_this_process_shares_one_boot_id ... ok +test server::liveness::tests::sweep_id_buckets_by_the_interval ... ok +test server::channels::tests::unknown_verb_never_falls_through_to_receive ... ok +test exec_det::tests::timeout_has_an_explicit_completion_reason ... ok +test exec_det::tests::captures_deterministic_output ... ok +test engine::wake::claim_guard_tests::a_disarmed_guard_leaves_the_claim_alone ... ok +test server::tests::agent::contract::an_agent_worker_attaching_without_pins_is_refused_at_attach ... ok +test server::liveness::tests::sweep_pass_healthy_subscription_is_a_noop ... ok +test server::liveness::tests::sweep_pass_latches_after_journaling_and_next_bucket_is_empty ... ok +test engine::wake::claim_guard_tests::a_guard_only_releases_its_own_run ... ok +test server::tests::agent::contract::an_oversized_trajectory_tail_is_refused_at_step_complete ... ok +test engine::wake::claim_guard_tests::an_armed_guard_releases_the_claim_when_dropped ... ok +test engine::wake::claim_guard_tests::a_panic_between_claim_and_register_still_releases ... ok +test server::tests::agent::contract::an_agent_worker_missing_a_declared_surface_parks_the_run_instead_of_erroring ... ok +test server::tests::hello_enforces_protocol_version ... ok +test server::tests::agent::contract::agent_without_a_compatible_worker_parks_without_starting ... ok +test server::tests::run_resume_asks_the_registry_instead_of_treating_an_orphan_file_as_a_run ... ok +test server::tests::agent::contract::an_llm_completion_claiming_an_effect_fails_closed_with_the_reason_journaled ... ok +test server::tests::a_failed_disconnect_journal_append_is_retained_and_retried_not_dropped ... ok +test server::tests::run_start_fails_closed_on_an_unknown_verification_key ... ok +test server::tests::agent::contract::a_replacement_worker_that_never_reported_the_pinned_surface_is_not_dispatched_to ... ok +test server::tests::run_resume_refuses_a_journal_that_never_recorded_its_run ... ok +test server::tests::agent::pins::reset_worker_reporting_a_revision_other_than_its_pin_fails_closed_as_worker_error ... ok +test server::tests::run_resume_adopts_a_real_journal_whose_registry_row_is_missing ... ok +test server::tests::agent::pins::consecutive_agent_steps_on_different_surfaces_each_start_from_their_own_pins ... ok +test server::tests::run_resume_refuses_a_valid_journal_that_belongs_to_another_run ... ok +test server::tests::stopped_heartbeats_past_the_deadline_journal_lease_expired_and_release_the_step ... ok +test exec_det::tests::timeout_kills_the_whole_process_group ... ok +test server::tests::agent::pins::a_replacement_worker_at_a_different_revision_is_not_dispatched_the_stale_pins ... ok +test server::tests::an_entry_appended_during_watch_registration_is_delivered_exactly_once ... ok + +test result: ok. 36 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.57s + + Running unittests src/main.rs (target/debug/deps/relayflowd-ae3ceb16e5d803ec) + +running 0 tests + +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + Running tests/crash_resume.rs (target/debug/deps/crash_resume-4619905d2726c36c) + +running 40 tests +test agent::resume_without_a_worker_parks_immediately_instead_of_timing_out ... ok +test agent::rung_c_sigkill_after_final_effect_replays_results_without_redispatch ... ok +test channels::channels_reject_foreign_workers_stale_attempts_and_invalid_acknowledgements ... ok +test concurrency::cancel_and_completion_race_has_one_terminal_fact ... ok +test agent::rung_c_sigkill_between_agent_completion_and_final_effect_memoizes_the_agent ... ok +test concurrency::run_start_dispatches_every_independent_lane_before_any_completion ... ok +test concurrency::cancel_closes_the_lease_and_rejects_a_late_completion ... ok +test agent::rung_c_crash_between_effect_election_and_the_provider_call_performs_it_exactly_once ... ok +test agent::rung_c_reset_sigkill_mid_edit_restores_pins_dedupes_effect_and_explains_attempts ... ok +test concurrency::concurrent_resumes_lease_exactly_one_attempt ... ok +test llm::serve_plumbs_watch_events_and_replayable_stream_verbs ... ok +test concurrency::live_resume_leaves_an_active_lease_running ... ok +test llm::completed_llm_output_is_memoized_when_serve_dies_during_the_next_step ... ok +test concurrency::server_restart_recovers_every_parallel_lease_without_duplicate_success ... ok +test llm::llm_verification_exhaustion_is_a_declared_failure_kind ... ok +test llm::failing_llm_verification_schedules_a_durable_retry_and_succeeds ... ok +test llm::sigkill_after_the_final_rung_b_effect_resumes_without_redispatching_llm ... ok +test memory::memory_sigkill_after_injection_replays_pack_and_charges_it_once ... ok +test llm::sigkill_under_serve_mid_llm_releases_the_lease_and_finishes_via_cli_resume ... ok +test llm::worker_killed_while_holding_a_lease_is_explained_and_released_on_cli_resume ... ok +test pin_projection::rejected_completion_cannot_forge_inspect_retry_pins_over_the_real_socket ... ok +test agent::rung_c_sigkill_boundaries_resume_only_unfinished_steps_via_real_cli ... ok +test parallel_lifecycle::overlapping_agent_conflict_survives_server_crash_and_resume ... ok +test placement::declared_placement_keeps_one_source_tree_across_resume ... ok +test parallel_lifecycle::terminal_failure_drains_or_explains_every_live_sibling ... ok +test sigkill_mid_step_replaces_and_explains_the_dead_attempt ... ok +test protocol_admission::every_mutating_run_verb_refuses_terminal_before_changing_state ... ok +test sigkill_after_cancel_request_resumes_to_one_canceled_fact ... ok +test placement::sigkill_mid_step_keeps_the_route_and_source_tree ... ok +test parallel_lifecycle::overlapping_agent_lanes_serialize_while_disjoint_lanes_merge_in_either_order ... ok +test surface_identity::aliases_are_rejected_and_external_ancestors_serialize_over_real_sockets ... ok +test placement::sigkill_before_first_step_preserves_the_submitted_workspace ... ok +test llm::sigkill_sweep_covers_before_and_between_the_rung_b_steps ... ok +test workspace_identity::workspace_aliases_are_refused_and_canonical_subtrees_serialize_over_real_sockets ... ok +test sigkill_under_serve_resumes_the_socket_started_run ... ok +test worker_capacity::two_workers_receive_a_deterministic_fair_capacity_bounded_batch ... ok +test sigkill_sweep_covers_every_hello_step_boundary ... ok +test worker_capacity::default_capacity_one_reopens_only_after_durable_completion_or_crash ... ok +test channels::channels_sigkill_resume_redelivers_unacked_messages_with_exactly_once_effects ... ok +test parallel_lifecycle::renewed_parallel_leases_survive_the_original_grant_and_remain_distinct ... ok + +test result: ok. 40 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 37.79s + + Running tests/event_wake.rs (target/debug/deps/event_wake-bc602276d1cebcd0) + +running 3 tests +test matching_event_wakes_once_with_fresh_context ... ok +test two_racing_deliveries_of_one_event_produce_exactly_one_run ... ok +test a_resumed_run_dispatches_the_original_wake_context ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s + + Running tests/hn_monitor_integration.rs (target/debug/deps/hn_monitor_integration-6b22693ad979dd80) + +running 1 test +test hn_story_event_wakes_monitor_once_with_story_context ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s + + Running tests/invalid_schema_preflight.rs (target/debug/deps/invalid_schema_preflight-2c352b9351d5acf8) + +running 3 tests +test invalid_json_schema_is_refused_before_journal_or_command ... ok +test unbounded_json_schema_is_refused_before_journal_or_command ... ok +test legitimately_recursive_json_schema_still_starts ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 7.30s + + Running tests/memory.rs (target/debug/deps/memory-a74848a8274a290e) + +running 5 tests +test rejected_journal_fact_releases_reservation_and_never_dispatches ... ok +test llm_dispatch_receives_same_pack_after_resume_without_provider ... ok +test replay_and_resume_need_no_provider_and_script_receives_recorded_pack ... ok +test semantic_retry_reuses_memory_without_a_second_charge ... ok +test over_budget_and_provider_errors_fail_without_dispatch_or_charge ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s + + Running tests/memory_epoch.rs (target/debug/deps/memory_epoch-edee30a47cad81b5) + +running 1 test +test epoch_carries_pack_and_exact_charge_and_refuses_duplicate_injection ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s + + Running tests/parallel_driver.rs (target/debug/deps/parallel_driver-74d9d10149c534bd) + +running 4 tests +test stop_after_one_holds_for_an_independent_deterministic_batch ... ok +test backpressured_or_mismatched_lane_does_not_drop_a_later_dispatch ... ok +test crash_boundaries_resume_the_real_driver_with_one_effect_per_lane ... ok +test pause_before_second_independent_step_holds_the_driver_boundary ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.14s + + Running tests/placement_pins.rs (target/debug/deps/placement_pins-893a8773fbe94aee) + +running 2 tests +test unsupported_local_pty_is_refused_before_an_earlier_step_can_run ... ok +test default_worker_pins_the_declared_worktree_base_commit_and_refuses_missing_source ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.05s + + Running tests/placement_routing.rs (target/debug/deps/placement_routing-aabf5947f3ca76b4) + +running 3 tests +test a_failed_routing_append_never_starts_or_dispatches_work ... ok +test crash_between_routing_and_start_does_not_redecide ... ok +test worker_retry_consumes_the_original_routing_fact ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s + + Running tests/subscription_liveness.rs (target/debug/deps/subscription_liveness-9506d11f678a9586) + +running 3 tests +test submit_event_upserts_subscription_row_and_sweep_flags_it_stale_after_budget ... ok +test stale_transition_is_journaled_as_subscription_stale_entry_in_the_last_known_run ... ok +test a_fresh_arrival_re_arms_the_latch_and_the_next_silence_can_stale_again ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s + + Running unittests src/lib.rs (target/debug/deps/relayflowd_core-b1fe3b3250e9e7a2) + +running 60 tests +test clock::tests::simulated_clock_is_explicitly_advanced ... ok +test journal::tests::memory_journal_assigns_sequences_and_rolls_epochs ... ok +test channel::tests::malformed_payloads_and_invalid_new_channel_appends_leave_state_unchanged ... ok +test channel::tests::send_retry_is_stable_and_conflicting_content_is_rejected ... ok +test channel::tests::delivery_replay_and_independent_acknowledged_offsets ... ok +test channel::tests::forged_deliveries_and_acknowledgements_fail_closed ... ok +test machine::tests::all_backing_off_steps_return_timers ... ok +test machine::parallel_tests::machine_starts_every_runnable_step_in_authored_order ... ok +test machine::parallel_tests::failed_run_drains_open_siblings_before_terminal_entry ... ok +test machine::parallel_tests::parallel_lanes_do_not_cross_the_dependency_barrier_early ... ok +test machine::parallel_tests::crash_resume_preserves_each_parallel_lease_exactly_once ... ok +test machine::parallel_tests::every_declared_mutable_surface_participates_in_conflict_selection ... ok +test machine::tests::every_reason_label_matches_its_serialized_form ... ok +test machine::parallel_tests::overlapping_agent_surfaces_are_serialized_in_authored_order ... ok +test machine::tests::cancel_request_closes_the_active_lease_before_the_terminal_fact ... ok +test machine::parallel_tests::external_ancestor_and_descendant_paths_conflict_but_siblings_do_not ... ok +test machine::tests::durable_cancel_request_outranks_crash_recovery ... ok +test machine::tests::crashed_attempt_does_not_consume_an_iteration ... ok +test machine::parallel_tests::workspace_ancestor_and_descendant_paths_conflict_but_siblings_do_not ... ok +test machine::tests::repeated_cancel_request_is_idempotent ... ok +test machine::tests::machine_starts_runnable_step_with_stable_effect_key ... ok +test machine::tests::inspect_recovery_injects_the_dirty_pin_completion_reason_and_tail ... ok +test machine::tests::successful_memo_is_never_scheduled_again ... ok +test machine::tests::manual_recovery_parks_needs_human_and_never_redispatches ... ok +test machine::tests::verification_failure_schedules_a_durable_retry ... ok +test machine::tests::worker_reported_failure_without_detail_still_records_a_verification ... ok +test retry::tests::jitter_is_repeatable_and_bounded ... ok +test memory::tests::caps_compare_exact_decimals_and_each_token_dimension ... ok +test machine::tests::every_failed_run_terminates_with_declared_completion_reasons ... ok +test machine::parallel_tests::disjoint_agent_lanes_merge_pins_in_either_completion_order ... ok +test machine::tests::reset_recovery_dispatches_the_original_pinned_revision ... ok +test schema::tests::in_document_uri_references_resolve_to_the_node_they_name ... ok +test schema::tests::refusal_names_the_cycle_it_found ... ok +test spec::tests::a_misspelled_step_level_key_is_a_parse_error ... ok +test spec::tests::a_misspelled_verification_gate_key_is_a_parse_error_not_a_dropped_gate ... ok +test spec::tests::cycles_are_rejected ... ok +test spec::tests::preflight_data_is_fail_closed ... ok +test spec::tests::spec_version_is_semver_and_gated ... ok +test spec::tests::external_surface_paths_must_have_one_canonical_spelling ... ok +test schema::tests::references_the_bound_leaves_opaque_are_refused_by_the_engine ... ok +test spec::tests::unknown_root_and_nested_fields_are_rejected ... ok +test spec::tests::zero_agent_flow_is_valid ... ok +test state::budget::tests::adds_costs_exactly_beyond_machine_decimal_precision ... ok +test spec::tests::workspace_mounts_and_worktrees_must_have_one_canonical_spelling ... ok +test state::budget::tests::overflow_and_malformed_cost_leave_total_unchanged ... ok +test state::tests::budget_decimal_strings_add_without_floats ... ok +test state::tests::a_completion_that_omits_a_surface_does_not_drop_it_from_the_pin_chain ... ok +test state::tests::journal_replays_data_gate_verdict_without_rerunning_completed_code ... ok +test verify::tests::an_unbounded_schema_in_a_journal_fails_its_gate_instead_of_aborting ... ok +test verify::tests::deterministic_output_requires_successful_exit_and_content ... ok +test state::tests::end_pin_chain_is_enforced_and_a_broken_chain_is_a_hard_error ... ok +test verify::tests::json_schema_is_a_control_gate ... ok +test spec::tests::the_full_ladder_parses_in_the_one_dialect ... ok +test schema::tests::a_property_named_ref_is_not_a_reference ... ok +test schema::tests::shared_declarations_and_boolean_schemas_are_validated ... ok +test schema::tests::every_accepted_corpus_schema_is_accepted ... ok +test schema::tests::every_refused_corpus_schema_compiles_but_is_refused_by_the_bound ... ok +test spec::tests::sdk_boundary_rejects_a_10_000_step_cycle_with_a_typed_error ... ok +test spec::tests::sdk_boundary_accepts_a_valid_10_000_step_reverse_chain ... ok +test schema::tests::deeply_nested_schemas_do_not_overflow_the_checker ... ok + +test result: ok. 60 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.64s + + Running tests/spec_parity.rs (target/debug/deps/spec_parity-bbda6cf1e1cf1c19) + +running 9 tests +test the_kernel_parses_the_event_triggered_spec_and_stamps_the_same_hash ... ok +test placement_requirements_have_identical_canonical_bytes_and_hash ... ok +test the_kernel_parses_the_deterministic_rung_and_stamps_the_same_hash ... ok +test step_memory_has_identical_canonical_bytes_and_hash ... ok +test the_kernel_parses_the_rung_c_agent_spec_and_stamps_the_same_hash ... ok +test memory_declaration_acceptance_matches_the_sdk_corpus ... ok +test placement_declaration_acceptance_matches_the_sdk_corpus ... ok +test the_kernel_parses_the_sdk_compiled_spec_and_stamps_the_same_hash ... ok +test the_kernel_parses_the_rung_b_spec_and_stamps_the_same_hash ... ok + +test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s + + Running unittests src/lib.rs (target/debug/deps/relayflowd_journal-d13cb7954335385c) + +running 28 tests +test registry::tests::a_registered_run_dedupes_across_boots ... ok +test registry::tests::registry_is_a_rebuildable_run_locator ... ok +test registry::tests::a_same_boot_claim_with_no_run_yet_is_a_duplicate_not_wreckage ... ok +test registry::tests::a_previous_boots_claim_with_no_run_is_repaired ... ok +test registry::tests::releasing_a_claim_lets_the_same_boot_retry ... ok +test registry::tests::a_pre_migration_registry_gains_boot_id_and_its_claims_are_repairable ... ok +test channel::tests::stale_attempts_and_raw_forged_acknowledgements_cannot_change_offsets ... ok +test subscriptions::tests::detect_without_latch_stays_available_for_the_next_sweep ... ok +test registry::tests::releasing_is_scoped_to_the_claiming_run ... ok +test subscriptions::tests::latch_is_a_no_op_if_a_fresh_event_arrived_between_detect_and_latch ... ok +test subscriptions::tests::last_run_for_subscription_returns_none_before_first_arrival ... ok +test subscriptions::tests::last_run_for_subscription_returns_the_lex_greatest_ulid_regardless_of_insertion ... ok +test subscriptions::tests::prune_sweep_claims_deletes_only_rows_older_than_cutoff ... ok +test channel::tests::channels_cross_segment_boundaries_and_terminal_runs_reject_mutations ... ok +test subscriptions::tests::sweep_does_not_re_emit_the_same_stale_row_on_a_later_tick ... ok +test subscriptions::tests::sweep_marks_row_stale_when_silence_exceeds_budget ... ok +test subscriptions::tests::sweep_election_gives_the_first_caller_the_result_and_second_gets_empty ... ok +test subscriptions::tests::sweep_ignores_subscriptions_whose_silence_is_still_within_budget ... ok +test subscriptions::tests::upsert_is_idempotent_across_bumps_and_preserves_event_type_updates ... ok +test subscriptions::tests::upsert_after_stale_re_arms_and_next_silence_can_re_emit ... ok +test channel::tests::failed_channel_writes_never_expose_delivery_or_advance_acknowledged_offset ... ok +test tests::append_is_durable_and_monotonic_after_reopen ... ok +test tests::effects_are_deduplicated_at_the_journal_boundary ... ok +test tests::an_unconfirmed_election_is_reclaimed_by_the_next_attempt_not_treated_as_done ... ok +test tests::rollover_is_atomic_scaffolding_for_epoch_resume ... ok +test tests::failed_commit_is_returned_not_swallowed ... ok +test tests::terminal_run_refuses_every_later_entry_atomically ... ok +test channel::tests::independent_connections_serialize_send_receive_and_acknowledgement ... ok + +test result: ok. 28 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.45s + + Doc-tests relayflowd + +running 0 tests + +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + Doc-tests relayflowd_core + +running 0 tests + +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + Doc-tests relayflowd_journal + +running 0 tests + +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + +exit_code=0 diff --git a/kernel/evidence/225/green-sdk.txt b/kernel/evidence/225/green-sdk.txt new file mode 100644 index 000000000..539d621f9 --- /dev/null +++ b/kernel/evidence/225/green-sdk.txt @@ -0,0 +1,20 @@ +$ cd packages/sdk && node node_modules/vitest/vitest.mjs run tests/placement.test.ts tests/spec-parity.test.ts + + RUN v2.1.9 /Users/khaliqgant/AgentWorkforce/flows-225-placement-wt/packages/sdk + + ✓ tests/placement.test.ts (54 tests) 11ms + ✓ tests/spec-parity.test.ts (31 tests) 209ms + + Test Files 2 passed (2) + Tests 85 passed (85) + Start at 13:58:43 + Duration 819ms (transform 206ms, setup 0ms, collect 489ms, tests 221ms, environment 0ms, prepare 129ms) + + +exit_code=0 +$ cd packages/sdk && node node_modules/typescript/bin/tsc --noEmit + +exit_code=0 +$ cd packages/sdk && node node_modules/typescript/bin/tsc -p tsconfig.type-tests.json + +exit_code=0 diff --git a/kernel/relayflowd-core/src/entry.rs b/kernel/relayflowd-core/src/entry.rs index 7927017d1..81a832a99 100644 --- a/kernel/relayflowd-core/src/entry.rs +++ b/kernel/relayflowd-core/src/entry.rs @@ -25,6 +25,8 @@ pub enum EntryType { /// "Native silent-death" answer at the journal level. #[serde(rename = "subscription.stale")] SubscriptionStale, + #[serde(rename = "step.routed")] + StepRouted, #[serde(rename = "step.attempt.started")] StepAttemptStarted, #[serde(rename = "step.completed")] @@ -68,6 +70,7 @@ impl EntryType { Self::SubscriptionRegistered => "subscription.registered", Self::SubscriptionMatched => "subscription.matched", Self::SubscriptionStale => "subscription.stale", + Self::StepRouted => "step.routed", Self::StepAttemptStarted => "step.attempt.started", Self::StepCompleted => "step.completed", Self::WaitEvent => "wait.event", @@ -95,6 +98,7 @@ impl EntryType { "subscription.registered" => Self::SubscriptionRegistered, "subscription.matched" => Self::SubscriptionMatched, "subscription.stale" => Self::SubscriptionStale, + "step.routed" => Self::StepRouted, "step.attempt.started" => Self::StepAttemptStarted, "step.completed" => Self::StepCompleted, "wait.event" => Self::WaitEvent, @@ -385,6 +389,8 @@ pub struct EpochSummaryPayload { /// Accepted packs are retained without charging them again at the epoch boundary. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub memory: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub routing: BTreeMap, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] diff --git a/kernel/relayflowd-core/src/journal.rs b/kernel/relayflowd-core/src/journal.rs index 22e03af25..beb0e29c1 100644 --- a/kernel/relayflowd-core/src/journal.rs +++ b/kernel/relayflowd-core/src/journal.rs @@ -136,6 +136,7 @@ mod tests { pinned_revisions: BTreeMap::new(), budget_spent: Budget::default(), memory: BTreeMap::new(), + routing: BTreeMap::new(), }, 10, ) diff --git a/kernel/relayflowd-core/src/lib.rs b/kernel/relayflowd-core/src/lib.rs index a5310e080..c5e155000 100644 --- a/kernel/relayflowd-core/src/lib.rs +++ b/kernel/relayflowd-core/src/lib.rs @@ -11,6 +11,7 @@ pub mod event; pub mod journal; pub mod machine; pub mod memory; +pub mod placement; pub mod retry; mod schema; pub mod spec; @@ -27,6 +28,7 @@ pub use machine::{ request_cancel_action, }; pub use memory::{MemoryInjectedPayload, MemoryScope, MemorySpec}; +pub use placement::{ExecutionMode, PlacementRequirements, RoutingDecision}; pub use spec::*; pub use state::{RunState, StateError, StepRuntime, StepState}; diff --git a/kernel/relayflowd-core/src/placement.rs b/kernel/relayflowd-core/src/placement.rs new file mode 100644 index 000000000..2b9699124 --- /dev/null +++ b/kernel/relayflowd-core/src/placement.rs @@ -0,0 +1,83 @@ +//! Gate 7 declaration and durable decision. No provider or filesystem I/O. +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct PlacementRequirements { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub execution: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub network: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expected_duration_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preference: Option, +} + +impl PlacementRequirements { + pub fn validate(&self) -> Result<(), String> { + if self + .expected_duration_ms + .is_some_and(|ms| ms == 0 || ms > 9_007_199_254_740_991) + { + return Err("requirements.expected_duration_ms must be a positive safe integer".into()); + } + Ok(()) + } +} + +pub fn validate_shape(value: &serde_json::Value) -> Result<(), String> { + let object = value.as_object().ok_or("requirements must be an object")?; + if object.values().any(serde_json::Value::is_null) { + return Err("requirements fields cannot be null".into()); + } + let requirements: PlacementRequirements = + serde_json::from_value(value.clone()).map_err(|e| e.to_string())?; + requirements.validate() +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ExecutionMode { + Batch, + Interactive, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum PlacementPreference { + Cost, + Latency, + Reliability, + Balanced, +} + +/// One fixed decision per step, retained on retry and replay. A provider adapter +/// consumes this fact; it must not rank providers again after the append. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RoutingDecision { + pub profile: String, + pub provider: String, + pub fallbacks_attempted: Vec, + /// Opaque provider workspace identity. Local execution uses a canonical cwd; + /// cloud adapters can identify their existing per-run sandbox here. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace: Option, +} + +impl RoutingDecision { + pub fn is_valid(&self) -> bool { + !self.profile.trim().is_empty() + && !self.provider.trim().is_empty() + && self + .workspace + .as_ref() + .is_none_or(|value| !value.trim().is_empty()) + && self + .fallbacks_attempted + .iter() + .all(|value| !value.trim().is_empty()) + } +} diff --git a/kernel/relayflowd-core/src/spec.rs b/kernel/relayflowd-core/src/spec.rs index fc09140ec..4011873a8 100644 --- a/kernel/relayflowd-core/src/spec.rs +++ b/kernel/relayflowd-core/src/spec.rs @@ -172,6 +172,9 @@ impl RunSpec { detail, })?; } + if let Some(requirements) = &step.requirements { + requirements.validate().map_err(SpecError::Malformed)?; + } step.retry.validate(&step.id)?; } @@ -275,6 +278,7 @@ const STEP_COMMON_FIELDS: &[&str] = &[ "retry", "verification", "memory", + "requirements", ]; const STEP_DETERMINISTIC_FIELDS: &[&str] = &["command", "timeout_ms"]; const STEP_LLM_FIELDS: &[&str] = &["prompt", "model", "cli"]; @@ -312,6 +316,9 @@ fn reject_unknown_step_fields(value: &Value) -> Result<(), SpecError> { detail, })?; } + if let Some(requirements) = object.get("requirements") { + crate::placement::validate_shape(requirements).map_err(SpecError::Malformed)?; + } for key in object.keys() { if !STEP_COMMON_FIELDS.contains(&key.as_str()) && !kind_fields.contains(&key.as_str()) { return Err(SpecError::UnknownField { @@ -337,6 +344,8 @@ pub struct StepSpec { pub verification: VerificationSpec, #[serde(default, skip_serializing_if = "Option::is_none")] pub memory: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub requirements: Option, #[serde(flatten)] pub kind: StepKind, } diff --git a/kernel/relayflowd-core/src/state.rs b/kernel/relayflowd-core/src/state.rs index a31ec2f15..5f4f195cf 100644 --- a/kernel/relayflowd-core/src/state.rs +++ b/kernel/relayflowd-core/src/state.rs @@ -9,7 +9,7 @@ use crate::{ RunCancelRequestedPayload, RunCompletedPayload, RunCompletionReason, SleepUntilPayload, StepCompletedPayload, WaitCompletedPayload, WaitCompletionReason, }, - spec::{RunSpec, StepKind, StepType}, + spec::{RunSpec, StepKind}, }; mod budget; @@ -74,6 +74,7 @@ pub struct RunState { pub cancel_requested: Option, /// Appendix A rule 6 chain head: the last successful agent completion. pub current_pins: Option, + pub routing: BTreeMap, } impl RunState { @@ -111,6 +112,7 @@ impl RunState { completion: None, cancel_requested: None, current_pins: None, + routing: BTreeMap::new(), }; for entry in entries { @@ -122,6 +124,15 @@ impl RunState { } match entry.entry_type { EntryType::EpochSummary => state.apply_epoch(entry)?, + EntryType::StepRouted => { + let id = entry.step_id.as_ref().ok_or(StateError::MissingStep(entry.seq))?; + if !state.steps.contains_key(id) { return Err(StateError::UnknownStep(id.clone())); } + let route: crate::RoutingDecision = decode(entry)?; + if state.routing.contains_key(id) || route.profile.trim().is_empty() || route.provider.trim().is_empty() { + return Err(StateError::InvalidRouting(id.clone())); + } + state.routing.insert(id.clone(), route); + } EntryType::StepAttemptStarted => { let payload: crate::entry::AttemptStartedPayload = decode(entry)?; state.validate_start_pins(entry, &payload)?; @@ -133,9 +144,7 @@ impl RunState { lease_deadline_ms: payload.lease_deadline_ms, idempotency_key: payload.idempotency_key, }; - if payload.step_type == StepType::Agent { - step.last_start_pins = Some(payload.pins); - } + step.last_start_pins = Some(payload.pins); } EntryType::MemoryInjected => state.apply_memory_injected(entry)?, EntryType::StepCompleted => state.apply_step_completed(entry)?, @@ -300,7 +309,12 @@ impl RunState { if payload.disposition == Disposition::StepDone && payload.completion_reason == CompletionReason::Success { - if is_agent { + if is_agent + || matches!( + self.spec.step(&step_id).map(|s| &s.kind), + Some(StepKind::Deterministic { .. }) + ) + { self.current_pins = pins::chain_forward(self.current_pins.take(), payload.end_pins.clone()); } @@ -311,6 +325,12 @@ impl RunState { fn apply_epoch(&mut self, entry: &JournalEntry) -> Result<(), StateError> { let payload: EpochSummaryPayload = decode(entry)?; + self.routing = payload.routing; + for (id, route) in &self.routing { + if !self.steps.contains_key(id) || !route.is_valid() { + return Err(StateError::InvalidRouting(id.clone())); + } + } self.memo.clear(); self.budget = payload.budget_spent; for runtime in self.steps.values_mut() { @@ -420,6 +440,8 @@ fn decode(entry: &JournalEntry) -> Result = + serde_json::from_str(include_str!("../../../testdata/placement-spec-cases.json")).unwrap(); + for case in cases { + for kind in ["deterministic", "llm", "agent"] { + let mut step = + serde_json::json!({"id":"s","type":kind,"requirements":case["requirements"]}); + step[match kind { + "deterministic" => "command", + "llm" => "prompt", + _ => "instruction", + }] = serde_json::json!("true"); + let accepted = RunSpec::parse(&serde_json::json!({"steps":[step]})) + .and_then(|s| s.validate()) + .is_ok(); + assert_eq!( + accepted, + case["valid"].as_bool().unwrap(), + "{}: {kind}", + case["name"] + ); + } + } +} diff --git a/kernel/relayflowd-journal/src/append.rs b/kernel/relayflowd-journal/src/append.rs index 8c6dacff9..53911c807 100644 --- a/kernel/relayflowd-journal/src/append.rs +++ b/kernel/relayflowd-journal/src/append.rs @@ -93,6 +93,7 @@ pub(crate) fn insert_entry( )?; let seq = transaction.last_insert_rowid(); persisted.seq = seq; + crate::placement::validate_entry(transaction, run_id, &persisted)?; crate::memory::validate_entry(transaction, run_id, &persisted)?; crate::channel::validate_entry(transaction, run_id, &persisted)?; diff --git a/kernel/relayflowd-journal/src/lib.rs b/kernel/relayflowd-journal/src/lib.rs index 5183ccbd1..3bbf1e568 100644 --- a/kernel/relayflowd-journal/src/lib.rs +++ b/kernel/relayflowd-journal/src/lib.rs @@ -3,6 +3,7 @@ mod append; mod channel; mod memory; +mod placement; mod registry; mod segment; mod subscriptions; @@ -496,6 +497,7 @@ mod tests { pinned_revisions: BTreeMap::new(), budget_spent: Budget::default(), memory: BTreeMap::new(), + routing: BTreeMap::new(), }, 20, ) diff --git a/kernel/relayflowd-journal/src/placement.rs b/kernel/relayflowd-journal/src/placement.rs new file mode 100644 index 000000000..a6b8b2fce --- /dev/null +++ b/kernel/relayflowd-journal/src/placement.rs @@ -0,0 +1,62 @@ +//! Routing is an append-only fact, including across epoch boundaries. +use crate::{JournalStoreError, append::entry_from_row}; +use relayflowd_core::{EntryType, EpochSummaryPayload, JournalEntry, RoutingDecision, StateError}; +use rusqlite::Transaction; +use std::collections::BTreeMap; + +fn decisions( + tx: &Transaction<'_>, + run_id: &str, +) -> Result, JournalStoreError> { + let mut query = tx.prepare("SELECT seq, segment_id, entry_type, step_id, attempt, at_ms, payload FROM entries WHERE entry_type IN ('step.routed', 'epoch.summary') ORDER BY seq")?; + let entries = query + .query_map([], |row| entry_from_row(row, run_id))? + .collect::, _>>()?; + let mut routing = BTreeMap::new(); + for entry in entries { + if entry.entry_type == EntryType::EpochSummary { + routing = serde_json::from_value::(entry.payload)?.routing; + continue; + } + let id = entry.step_id.ok_or(StateError::MissingStep(entry.seq))?; + let route: RoutingDecision = serde_json::from_value(entry.payload)?; + if !route.is_valid() || routing.insert(id.clone(), route).is_some() { + return Err(StateError::InvalidRouting(id).into()); + } + } + Ok(routing) +} + +pub(crate) fn validate_entry( + tx: &Transaction<'_>, + run_id: &str, + entry: &JournalEntry, +) -> Result<(), JournalStoreError> { + if entry.entry_type == EntryType::StepRouted { + let id = entry + .step_id + .as_deref() + .ok_or(StateError::MissingStep(entry.seq))?; + let payload: String = tx.query_row( + "SELECT payload FROM entries WHERE entry_type = 'run.spawned' ORDER BY seq LIMIT 1", + [], + |row| row.get(0), + )?; + let spawn: relayflowd_core::RunSpawnedPayload = serde_json::from_str(&payload)?; + let spec = relayflowd_core::RunSpec::parse(&spawn.spec).map_err(StateError::from)?; + if spec.step(id).is_none() || entry.attempt.is_some() { + return Err(StateError::InvalidRouting(id.into()).into()); + } + decisions(tx, run_id)?; + } + Ok(()) +} + +pub(crate) fn carry_summary( + tx: &Transaction<'_>, + run_id: &str, + summary: &mut EpochSummaryPayload, +) -> Result<(), JournalStoreError> { + summary.routing = decisions(tx, run_id)?; + Ok(()) +} diff --git a/kernel/relayflowd-journal/src/segment.rs b/kernel/relayflowd-journal/src/segment.rs index 2c98ac80e..797238834 100644 --- a/kernel/relayflowd-journal/src/segment.rs +++ b/kernel/relayflowd-journal/src/segment.rs @@ -19,6 +19,7 @@ impl SqliteJournal { .connection .transaction_with_behavior(TransactionBehavior::Immediate)?; + crate::placement::carry_summary(&transaction, &self.run_id, &mut summary)?; crate::memory::carry_summary(&transaction, &self.run_id, &mut summary)?; let closed = insert_entry( diff --git a/kernel/relayflowd/Cargo.toml b/kernel/relayflowd/Cargo.toml index b23bb3fd5..d6e79a7c3 100644 --- a/kernel/relayflowd/Cargo.toml +++ b/kernel/relayflowd/Cargo.toml @@ -19,4 +19,5 @@ wait-timeout.workspace = true libc.workspace = true [dev-dependencies] +rusqlite.workspace = true tempfile.workspace = true diff --git a/kernel/relayflowd/src/engine.rs b/kernel/relayflowd/src/engine.rs index a1fe86741..4d6661613 100644 --- a/kernel/relayflowd/src/engine.rs +++ b/kernel/relayflowd/src/engine.rs @@ -37,6 +37,7 @@ mod drive; mod effects; mod memory; mod model; +mod placement; mod remote; mod wake; pub use channels::ChannelCommandError; @@ -167,6 +168,7 @@ impl Engine { options: DriveOptions, ) -> Result { spec.validate().context("invalid run spec")?; + self.preflight_placement(&spec)?; let run_id = Ulid::new().to_string(); let path = self.run_path(&run_id); let now_ms = self.clock.now_ms(); @@ -193,6 +195,7 @@ impl Engine { self.registry()? .register(&run_id, &path) .context("register run")?; + self.bind_local_workspaces(&mut journal, &spec)?; self.drive(journal, spec, options) } diff --git a/kernel/relayflowd/src/engine/drive.rs b/kernel/relayflowd/src/engine/drive.rs index 50b06a23e..c8991b8b2 100644 --- a/kernel/relayflowd/src/engine/drive.rs +++ b/kernel/relayflowd/src/engine/drive.rs @@ -1,6 +1,6 @@ use std::{collections::BTreeSet, thread, time::Duration}; -use anyhow::{Result, bail}; +use anyhow::{Context, Result, bail}; use relayflowd_core::{ Action, AttemptResult, Clock, CompletionReason, RunCompletionReason, RunSpec, RunState, abandonment_actions, completion_actions, next_actions, @@ -94,6 +94,7 @@ impl Engine { let prepared = (|| -> Result<()> { self.prepare_start_entry(&state, &mut entry)?; self.assign_executor(&state, &mut entry)?; + self.route_start(&mut journal, &state, &mut entry)?; self.append(&mut journal, &entry)?; Ok(()) })(); @@ -116,10 +117,31 @@ impl Engine { if !self.ensure_step_memory(&mut journal, &step, attempt)? { continue; } - let injected = self.load_state(&journal, spec.clone())?.steps[&step.id] - .memory - .clone(); - let result = exec_det::execute_with_memory(&step, injected.as_ref()); + let started = self.load_state(&journal, spec.clone())?; + let runtime = &started.steps[&step.id]; + let workspace = started + .routing + .get(&step.id) + .and_then(|r| r.workspace.as_deref()); + let mut result = exec_det::execute_placed( + &step, + runtime.memory.as_ref(), + workspace.map(std::path::Path::new), + ); + if let Some(path) = workspace { + match crate::workspace::pin(std::path::Path::new(path)) { + Ok(pin) => { + result.end_pins = Some(relayflowd_core::Pins { + workspace: vec![pin], + streams: vec![], + }) + } + Err(error) => { + result.failure_reason = Some(CompletionReason::WorkerError); + result.failure_detail = Some(error.to_string()); + } + } + } let semantic_executions = state.steps[&step.id].semantic_executions; for action in completion_actions( journal.run_id(), @@ -187,6 +209,7 @@ impl Engine { lease_id, idempotency_key, pins, + routing: started_state.routing.get(&step.id).context("dispatch has no journaled route")?.clone(), wake_context: journal.scan_from(1, usize::MAX).ok().and_then(|entries| entries.into_iter() .find(|entry| entry.entry_type == relayflowd_core::EntryType::SubscriptionMatched) .and_then(|entry| entry.payload.get("wake_context").cloned())), diff --git a/kernel/relayflowd/src/engine/placement.rs b/kernel/relayflowd/src/engine/placement.rs new file mode 100644 index 000000000..c0fdec8d0 --- /dev/null +++ b/kernel/relayflowd/src/engine/placement.rs @@ -0,0 +1,168 @@ +use super::Engine; +use anyhow::{Context, Result, bail}; +use relayflowd_core::{ + AttemptStartedPayload, Clock, EntryType, ExecutionMode, JournalEntry, RoutingDecision, + RunState, StepType, +}; +use relayflowd_journal::SqliteJournal; +use std::path::Path; + +impl Engine { + pub(super) fn preflight_placement(&self, spec: &relayflowd_core::RunSpec) -> Result<()> { + for step in &spec.steps { + if step.step_type() != StepType::Deterministic { + continue; + } + if let Some(requirements) = &step.requirements { + if requirements.execution == Some(ExecutionMode::Interactive) { + bail!( + "step {} requires interactive execution; local deterministic executor supports batch only", + step.id + ); + } + if requirements.workspace != Some(false) { + crate::workspace::pin(&std::env::current_dir()?)?; + } + } + } + Ok(()) + } + + /// Bind declared local workspaces at submission, before a pause/crash can + /// make a later step inherit the cwd of a different daemon process. + pub(super) fn bind_local_workspaces( + &self, + journal: &mut SqliteJournal, + spec: &relayflowd_core::RunSpec, + ) -> Result<()> { + for step in &spec.steps { + if step.step_type() != StepType::Deterministic + || !step + .requirements + .as_ref() + .is_some_and(|r| r.workspace != Some(false)) + { + continue; + } + let pin = crate::workspace::pin(&std::env::current_dir()?)?; + let route = RoutingDecision { + profile: "batch".into(), + provider: "local".into(), + fallbacks_attempted: vec![], + workspace: Some(pin.surface), + }; + self.append( + journal, + &JournalEntry::new( + EntryType::StepRouted, + journal.run_id().to_owned(), + Some(step.id.clone()), + None, + self.clock.now_ms(), + route, + ), + )?; + } + Ok(()) + } + + /// Persist the decision before the attempt starts. If that append succeeds + /// but the process dies before start, replay uses the decision already made. + pub(super) fn route_start( + &self, + journal: &mut SqliteJournal, + state: &RunState, + entry: &mut JournalEntry, + ) -> Result<()> { + if entry.entry_type != EntryType::StepAttemptStarted { + return Ok(()); + } + let step_id = entry.step_id.as_deref().context("start has no step")?; + let step = state + .spec + .step(step_id) + .context("start names unknown step")?; + let mut start: AttemptStartedPayload = serde_json::from_value(entry.payload.clone())?; + let route = if let Some(route) = state.routing.get(step_id) { + route.clone() + } else { + let route = if step.step_type() == StepType::Deterministic { + if step.requirements.as_ref().and_then(|r| r.execution) + == Some(ExecutionMode::Interactive) + { + bail!( + "step {step_id} requires interactive execution; local deterministic executor supports batch only" + ); + } + let workspace = if step + .requirements + .as_ref() + .is_some_and(|r| r.workspace != Some(false)) + { + Some( + match state + .routing + .values() + .find(|r| r.provider == "local" && r.workspace.is_some()) + { + Some(route) => route.workspace.clone().unwrap(), + None => std::env::current_dir()? + .canonicalize()? + .to_str() + .context("workspace path is not UTF-8")? + .to_owned(), + }, + ) + } else { + None + }; + RoutingDecision { + profile: "batch".into(), + provider: "local".into(), + fallbacks_attempted: vec![], + workspace, + } + } else { + self.dispatcher + .as_ref() + .context("placement requires a worker")? + .routing_decision( + &state.run_id, + step, + entry.attempt.context("start has no attempt")?, + )? + }; + if !route.is_valid() { + bail!("step {step_id} received an empty routing decision"); + } + // Resolve source facts before appending the routing decision. + if route.provider == "local" + && let Some(path) = &route.workspace + { + crate::workspace::pin(Path::new(path))?; + } + self.append( + journal, + &JournalEntry::new( + EntryType::StepRouted, + &state.run_id, + Some(step_id.to_owned()), + None, + self.clock.now_ms(), + &route, + ), + )?; + route + }; + if step.step_type() == StepType::Deterministic { + if route.provider != "local" { + bail!("local executor cannot execute provider {}", route.provider); + } + if let Some(path) = &route.workspace { + start.pins.workspace = vec![crate::workspace::pin(Path::new(path))?]; + } + } + entry.payload = serde_json::to_value(start)?; + Ok(()) + } +} diff --git a/kernel/relayflowd/src/exec_det.rs b/kernel/relayflowd/src/exec_det.rs index a590861a2..07a80635f 100644 --- a/kernel/relayflowd/src/exec_det.rs +++ b/kernel/relayflowd/src/exec_det.rs @@ -18,6 +18,14 @@ pub fn execute(step: &StepSpec) -> AttemptResult { pub fn execute_with_memory( step: &StepSpec, memory: Option<&relayflowd_core::MemoryInjectedPayload>, +) -> AttemptResult { + execute_placed(step, memory, None) +} + +pub(crate) fn execute_placed( + step: &StepSpec, + memory: Option<&relayflowd_core::MemoryInjectedPayload>, + workspace: Option<&std::path::Path>, ) -> AttemptResult { let StepKind::Deterministic { command, @@ -41,6 +49,9 @@ pub fn execute_with_memory( command } }; + if let Some(workspace) = workspace { + process.current_dir(workspace); + } process.env_remove("RELAYFLOW_MEMORY"); if let Some(memory) = memory { process.env("RELAYFLOW_MEMORY", memory.pack.to_string()); diff --git a/kernel/relayflowd/src/lib.rs b/kernel/relayflowd/src/lib.rs index df00e7885..6c964cb8d 100644 --- a/kernel/relayflowd/src/lib.rs +++ b/kernel/relayflowd/src/lib.rs @@ -9,3 +9,5 @@ pub use engine::{ CancelOptions, DriveOptions, Engine, OutOfBandCompletion, RunOutcome, RunSnapshot, RunStatus, StepSnapshot, StepStatus, }; + +mod workspace; diff --git a/kernel/relayflowd/src/worker.rs b/kernel/relayflowd/src/worker.rs index 52a4d8465..f0f44661b 100644 --- a/kernel/relayflowd/src/worker.rs +++ b/kernel/relayflowd/src/worker.rs @@ -16,6 +16,7 @@ pub struct StepDispatch { pub lease_id: String, pub idempotency_key: String, pub pins: Pins, + pub routing: relayflowd_core::RoutingDecision, #[serde(skip_serializing_if = "Option::is_none")] pub wake_context: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -67,8 +68,8 @@ pub trait StepDispatcher: Send + Sync { /// Opaque starting revisions/offsets reported by the selected worker for /// the first agent attempt. Later attempts are derived from the journal. - fn starting_pins(&self, _step: &StepSpec) -> Result { - Ok(Pins::default()) + fn starting_pins(&self, step: &StepSpec) -> Result { + crate::workspace::starting_pins(step) } /// Starting pins reported by the worker whose capacity was reserved. @@ -84,6 +85,30 @@ pub trait StepDispatcher: Send + Sync { /// Release an admission that did not become a live assignment. fn release_dispatch_reservation(&self, _run_id: &str, _step_id: &str, _attempt: u32) {} + /// Fixed attached-worker placement. Provider-backed adapters override this + /// to report their existing orchestration decision; dispatch consumes the + /// journaled decision, including on retry, without choosing again. + fn routing_decision( + &self, + _run_id: &str, + step: &StepSpec, + _attempt: u32, + ) -> Result { + if step + .requirements + .as_ref() + .is_some_and(|r| r != &relayflowd_core::PlacementRequirements::default()) + { + anyhow::bail!("worker dispatcher must match the declared placement requirements"); + } + Ok(relayflowd_core::RoutingDecision { + profile: "attached-worker".into(), + provider: "worker".into(), + fallbacks_attempted: vec![], + workspace: None, + }) + } + fn dispatch(&self, dispatch: StepDispatch) -> Result; /// Heartbeat-renewed operational deadline for one live assignment. The diff --git a/kernel/relayflowd/src/workspace.rs b/kernel/relayflowd/src/workspace.rs new file mode 100644 index 000000000..cd3b22fd5 --- /dev/null +++ b/kernel/relayflowd/src/workspace.rs @@ -0,0 +1,50 @@ +//! Local worktree facts at the I/O edge. This is not a sandbox provisioner. +use anyhow::{Context, Result, bail}; +use relayflowd_core::{Pins, StepKind, StepSpec, WorkspacePin}; +use std::{path::Path, process::Command}; + +pub(crate) fn pin(path: &Path) -> Result { + let path = path + .canonicalize() + .with_context(|| format!("workspace {} is unavailable", path.display()))?; + let output = Command::new("git") + .args(["rev-parse", "--verify", "HEAD"]) + .current_dir(&path) + .output() + .context("read workspace base commit")?; + if !output.status.success() { + bail!( + "workspace {} has no readable base commit: {}", + path.display(), + String::from_utf8_lossy(&output.stderr) + ); + } + let revision_id = String::from_utf8(output.stdout)?.trim().to_owned(); + if revision_id.is_empty() { + bail!("workspace base commit is empty"); + } + Ok(WorkspacePin { + surface: path + .to_str() + .context("workspace path is not UTF-8")? + .to_owned(), + revision_id, + }) +} + +/// Default in-process dispatcher pins declared worktrees. Remote dispatchers +/// override this with relayfile revision facts from their selected worker. +pub(crate) fn starting_pins(step: &StepSpec) -> Result { + let mut pins = Pins::default(); + if let StepKind::Agent { surfaces, .. } = &step.kind { + for surface in &surfaces.workspace { + let mut pinned = pin(Path::new(&surface.surface))?; + pinned.surface = surface.surface.clone(); + pins.workspace.push(pinned); + } + if !surfaces.streams.is_empty() { + bail!("dispatcher must report declared stream offsets"); + } + } + Ok(pins) +} diff --git a/kernel/relayflowd/tests/crash_resume/placement.rs b/kernel/relayflowd/tests/crash_resume/placement.rs index f87f56dfa..9df99b3ee 100644 --- a/kernel/relayflowd/tests/crash_resume/placement.rs +++ b/kernel/relayflowd/tests/crash_resume/placement.rs @@ -1,14 +1,35 @@ -use std::{fs, process::Command}; +use std::{ + fs, + os::unix::process::CommandExt, + process::{Command, Stdio}, +}; use relayflowd::{RunOutcome, RunStatus}; use relayflowd_core::EntryType; use serde_json::json; use tempfile::tempdir; -use super::support::{journal_entries, only_run_id}; +use super::support::{ + journal_entries, kill_group, kill_process_group, only_run_id, read_pid, wait_until, +}; #[test] fn declared_placement_keeps_one_source_tree_across_resume() { + shared_tree("between"); +} + +#[test] +fn sigkill_mid_step_keeps_the_route_and_source_tree() { + shared_tree("during"); +} + +#[test] +fn sigkill_before_first_step_preserves_the_submitted_workspace() { + shared_tree("before"); +} + +fn shared_tree(boundary: &str) { + let crash = boundary == "during"; let directory = tempdir().unwrap(); let tree = directory.path().join("source"); let elsewhere = directory.path().join("elsewhere"); @@ -19,49 +40,155 @@ fn declared_placement_keeps_one_source_tree_across_resume() { for args in [ vec!["init", "-q"], vec!["add", "source.txt"], - vec!["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-qm", "source"], + vec![ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "commit", + "-qm", + "source", + ], ] { - assert!(Command::new("git").args(args).current_dir(&tree).status().unwrap().success()); + assert!( + Command::new("git") + .args(args) + .current_dir(&tree) + .status() + .unwrap() + .success() + ); } - let revision = Command::new("git").args(["rev-parse", "HEAD"]) - .current_dir(&tree).output().unwrap(); - let revision = String::from_utf8(revision.stdout).unwrap().trim().to_owned(); + let revision = Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(&tree) + .output() + .unwrap(); + let revision = String::from_utf8(revision.stdout) + .unwrap() + .trim() + .to_owned(); let spec = directory.path().join("flow.json"); + let read_command = if crash { + "echo $$ > step.pid; touch started; while [ ! -f continue ]; do sleep 0.02; done; cat shared.txt" + } else { + "cat shared.txt" + }; fs::write(&spec, json!({"steps":[ {"id":"write","type":"deterministic","requirements":{"workspace":true,"execution":"batch"}, "command":"cat source.txt > shared.txt"}, {"id":"read","type":"deterministic","depends_on":["write"], "requirements":{"workspace":true,"execution":"batch"}, - "command":"cat shared.txt","verification":{"output_contains":"source-present"}} + "command":read_command,"verification":{"output_contains":"source-present"}} ]}).to_string()).unwrap(); - let first = Command::new(env!("CARGO_BIN_EXE_relayflowd")) - .arg("--data-dir").arg(&data).arg("run").arg(&spec) - .args(["--stop-after", "1"]).current_dir(&tree).output().unwrap(); - assert!(first.status.success(), "run failed: {}", String::from_utf8_lossy(&first.stderr)); + let mut command = Command::new(env!("CARGO_BIN_EXE_relayflowd")); + command + .arg("--data-dir") + .arg(&data) + .arg("run") + .arg(&spec) + .current_dir(&tree); + if boundary == "before" { + let mut child = command + .args(["--pause-before-step", "write"]) + .stdout(Stdio::null()) + .process_group(0) + .spawn() + .unwrap(); + wait_until("submitted workspace binding", || { + journal_entries(&data).is_some_and(|entries| { + entries + .iter() + .filter(|e| e.entry_type.as_str() == "step.routed") + .count() + == 2 + }) + }); + kill_process_group(&mut child); + } else if crash { + let mut child = command + .stdout(Stdio::null()) + .process_group(0) + .spawn() + .unwrap(); + wait_until("placed command starts", || tree.join("started").exists()); + kill_process_group(&mut child); + kill_group(read_pid(&tree.join("step.pid"))); + fs::write(tree.join("continue"), "").unwrap(); + } else { + let first = command.args(["--stop-after", "1"]).output().unwrap(); + assert!( + first.status.success(), + "run failed: {}", + String::from_utf8_lossy(&first.stderr) + ); + } let run = only_run_id(&data); let resumed = Command::new(env!("CARGO_BIN_EXE_relayflowd")) - .arg("--data-dir").arg(&data).args(["resume", &run]) - .current_dir(&elsewhere).output().unwrap(); + .arg("--data-dir") + .arg(&data) + .args(["resume", &run]) + .current_dir(&elsewhere) + .output() + .unwrap(); assert!(resumed.status.success(), "resume failed: {resumed:?}"); let outcome: RunOutcome = serde_json::from_slice(&resumed.stdout).unwrap(); assert_eq!(outcome.status, RunStatus::Completed); - assert_eq!(fs::read_to_string(tree.join("shared.txt")).unwrap(), "source-present\n"); + assert_eq!( + fs::read_to_string(tree.join("shared.txt")).unwrap(), + "source-present\n" + ); assert!(!elsewhere.join("shared.txt").exists()); let entries = journal_entries(&data).unwrap(); - let routes: Vec<_> = entries.iter().filter(|e| e.entry_type.as_str() == "step.routed").collect(); + assert_eq!( + entries + .iter() + .filter(|e| e.entry_type == EntryType::StepAttemptStarted) + .count(), + if crash { 3 } else { 2 } + ); + if crash { + assert!( + entries + .iter() + .any(|e| e.entry_type == EntryType::StepCompleted + && e.payload["completionReason"] == "crashed") + ); + } + let routes: Vec<_> = entries + .iter() + .filter(|e| e.entry_type.as_str() == "step.routed") + .collect(); assert_eq!(routes.len(), 2, "each routing decision must be durable"); for route in routes { assert_eq!(route.payload["profile"], "batch"); assert_eq!(route.payload["provider"], "local"); assert_eq!(route.payload["fallbacks_attempted"], json!([])); } - for start in entries.iter().filter(|e| e.entry_type == EntryType::StepAttemptStarted) { - assert_eq!(start.payload["pins"]["workspace"][0]["revision_id"], revision); - assert_eq!(start.payload["pins"]["workspace"][0]["surface"], fs::canonicalize(&tree).unwrap().to_str().unwrap()); + for start in entries + .iter() + .filter(|e| e.entry_type == EntryType::StepAttemptStarted) + { + assert_eq!( + start.payload["pins"]["workspace"][0]["revision_id"], + revision + ); + assert_eq!( + start.payload["pins"]["workspace"][0]["surface"], + fs::canonicalize(&tree).unwrap().to_str().unwrap() + ); } let replay = Command::new(env!("CARGO_BIN_EXE_relayflowd")) - .arg("--data-dir").arg(&data).args(["resume", &run]) - .current_dir(&elsewhere).output().unwrap(); + .arg("--data-dir") + .arg(&data) + .args(["resume", &run]) + .current_dir(&elsewhere) + .output() + .unwrap(); assert!(replay.status.success()); - assert_eq!(entries, journal_entries(&data).unwrap(), "completed replay must not decide again"); + assert_eq!( + entries, + journal_entries(&data).unwrap(), + "completed replay must not decide again" + ); } diff --git a/kernel/relayflowd/tests/memory_epoch.rs b/kernel/relayflowd/tests/memory_epoch.rs index 74951a5d1..d76f6ba1e 100644 --- a/kernel/relayflowd/tests/memory_epoch.rs +++ b/kernel/relayflowd/tests/memory_epoch.rs @@ -74,6 +74,7 @@ fn epoch_carries_pack_and_exact_charge_and_refuses_duplicate_injection() { pinned_revisions: BTreeMap::new(), budget_spent: Budget::default(), memory: BTreeMap::new(), + routing: BTreeMap::new(), }; assert!( journal diff --git a/kernel/relayflowd/tests/placement_pins.rs b/kernel/relayflowd/tests/placement_pins.rs new file mode 100644 index 000000000..d96f8be6b --- /dev/null +++ b/kernel/relayflowd/tests/placement_pins.rs @@ -0,0 +1,86 @@ +use relayflowd::{ + Engine, + worker::{DispatchOutcome, StepDispatch, StepDispatcher}, +}; +use relayflowd_core::{RunSpec, StepType}; +use serde_json::json; +use std::{path::Path, process::Command}; +use tempfile::tempdir; + +struct LocalWorker; +impl StepDispatcher for LocalWorker { + fn executor(&self, _: StepType) -> Option { + Some("local-worker".into()) + } + fn available(&self, _: StepType) -> bool { + true + } + fn dispatch(&self, _: StepDispatch) -> anyhow::Result { + unreachable!() + } +} + +fn git(tree: &Path, args: &[&str]) -> String { + let output = Command::new("git") + .args(args) + .current_dir(tree) + .output() + .unwrap(); + assert!(output.status.success(), "git failed: {output:?}"); + String::from_utf8(output.stdout).unwrap().trim().into() +} + +#[test] +fn default_worker_pins_the_declared_worktree_base_commit_and_refuses_missing_source() { + let tree = tempdir().unwrap(); + git(tree.path(), &["init", "-q"]); + git( + tree.path(), + &[ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "commit", + "--allow-empty", + "-qm", + "base", + ], + ); + let revision = git(tree.path(), &["rev-parse", "HEAD"]); + let surface = tree + .path() + .canonicalize() + .unwrap() + .to_str() + .unwrap() + .to_owned(); + let spec = RunSpec::parse( + &json!({"steps":[{"id":"edit","type":"agent","instruction":"edit", + "surfaces":{"workspace":[{"surface":surface}]}}]}), + ) + .unwrap(); + let pins = LocalWorker.starting_pins(&spec.steps[0]).unwrap(); + assert_eq!(pins.workspace.len(), 1); + assert_eq!(pins.workspace[0].surface, surface); + assert_eq!(pins.workspace[0].revision_id, revision); + std::fs::remove_dir_all(tree.path().join(".git")).unwrap(); + assert!(LocalWorker.starting_pins(&spec.steps[0]).is_err()); +} + +#[test] +fn unsupported_local_pty_is_refused_before_an_earlier_step_can_run() { + let directory = tempdir().unwrap(); + let marker = directory.path().join("must-not-exist"); + let spec = RunSpec::parse(&json!({"steps":[ + {"id":"first","type":"deterministic","command": ["touch", marker]}, + {"id":"pty","type":"deterministic","depends_on":["first"],"command":"true", + "requirements":{"execution":"interactive"}} + ]})) + .unwrap(); + let engine = Engine::new(directory.path().join("data")); + let error = engine.start(spec, "test", None).unwrap_err(); + assert!(format!("{error:#}").contains("supports batch only")); + assert!(!marker.exists()); + assert!(!directory.path().join("data/runs").exists()); +} diff --git a/kernel/relayflowd/tests/placement_routing.rs b/kernel/relayflowd/tests/placement_routing.rs new file mode 100644 index 000000000..5ece7a576 --- /dev/null +++ b/kernel/relayflowd/tests/placement_routing.rs @@ -0,0 +1,217 @@ +use relayflowd::{ + Engine, + worker::{DispatchOutcome, JournalObserver, StepDispatch, StepDispatcher}, +}; +use relayflowd_core::{ + CompletionReason, EntryType, EpochSummaryPayload, Journal, JournalEntry, Pins, RoutingDecision, + RunSpec, RunState, StepSpec, StepType, WorkspacePin, +}; +use relayflowd_journal::SqliteJournal; +use serde_json::json; +use std::{ + collections::BTreeMap, + sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, + }, +}; +use tempfile::tempdir; + +#[derive(Default)] +struct FixedProvider { + decisions: AtomicUsize, + dispatched: Mutex>, +} +impl JournalObserver for FixedProvider { + fn appended(&self, _: &JournalEntry) {} +} +impl StepDispatcher for FixedProvider { + fn available(&self, _: StepType) -> bool { + true + } + fn executor(&self, _: StepType) -> Option { + Some("cloud-worker".into()) + } + fn starting_pins(&self, _: &StepSpec) -> anyhow::Result { + Ok(Pins { + workspace: vec![WorkspacePin { + surface: "/repo".into(), + revision_id: "relayfile-revision-1".into(), + }], + streams: vec![], + }) + } + fn routing_decision(&self, _: &str, _: &StepSpec, _: u32) -> anyhow::Result { + assert_eq!( + self.decisions.fetch_add(1, Ordering::SeqCst), + 0, + "replay must not call the router again" + ); + Ok(RoutingDecision { + profile: "interactive".into(), + provider: "test-cloud-adapter".into(), + fallbacks_attempted: vec!["unavailable-pool".into()], + workspace: Some("sandbox-for-run".into()), + }) + } + fn dispatch(&self, dispatch: StepDispatch) -> anyhow::Result { + self.dispatched.lock().unwrap().push(dispatch); + Ok(DispatchOutcome::Dispatched) + } +} + +#[test] +fn worker_retry_consumes_the_original_routing_fact() { + let directory = tempdir().unwrap(); + let provider = Arc::new(FixedProvider::default()); + let engine = Engine::with_runtime(directory.path(), provider.clone(), provider.clone()); + let spec = RunSpec::parse(&json!({"steps":[{"id":"edit","type":"agent","instruction":"edit", + "requirements":{"workspace":true,"execution":"interactive"},"surfaces":{"workspace":[{"surface":"/repo"}]}}]})).unwrap(); + let run = engine.start(spec.clone(), "test", None).unwrap(); + engine + .abandon_out_of_band(&run.run_id, "edit", 1, CompletionReason::Crashed) + .unwrap(); + let engine = Engine::with_runtime(directory.path(), provider.clone(), provider.clone()); + engine.resume(&run.run_id, None).unwrap(); + let dispatches = provider.dispatched.lock().unwrap(); + assert_eq!(dispatches.len(), 2); + assert_eq!(dispatches[0].routing, dispatches[1].routing); + assert_eq!(dispatches[0].pins, dispatches[1].pins); + assert_eq!(dispatches[1].attempt, 2); + assert_eq!(provider.decisions.load(Ordering::SeqCst), 1); + let mut journal = SqliteJournal::open( + directory + .path() + .join("runs") + .join(format!("{}.sqlite3", run.run_id)), + ) + .unwrap(); + let before = RunState::fold(&run.run_id, spec.clone(), &journal.scan_all().unwrap()).unwrap(); + let duplicate = JournalEntry::new( + EntryType::StepRouted, + &run.run_id, + Some("edit".into()), + None, + 10, + &dispatches[0].routing, + ); + assert!( + journal.append(&duplicate).is_err(), + "duplicate routing must fail in the append transaction" + ); + let unknown = JournalEntry::new( + EntryType::StepRouted, + &run.run_id, + Some("unknown".into()), + None, + 10, + &dispatches[0].routing, + ); + assert!( + journal.append(&unknown).is_err(), + "an undeclared step cannot acquire a route" + ); + let summary = EpochSummaryPayload { + epoch: 0, + prev_segment_id: 0, + journal_version: 1, + steps_done: BTreeMap::new(), + steps_open: BTreeMap::new(), + open_waits: BTreeMap::new(), + stream_state: BTreeMap::new(), + pinned_revisions: BTreeMap::new(), + budget_spent: before.budget, + memory: BTreeMap::new(), + routing: BTreeMap::new(), + }; + journal.rollover(summary, 20).unwrap(); + let after = RunState::fold( + &run.run_id, + spec, + &journal + .scan_segment(journal.current_segment().unwrap()) + .unwrap(), + ) + .unwrap(); + assert_eq!( + before.routing, after.routing, + "epoch automatically retains routing decisions" + ); + assert!( + journal.append(&duplicate).is_err(), + "rollover must not permit a second choice" + ); +} + +struct RejectRoutingWrites(std::path::PathBuf, Mutex>); +impl JournalObserver for RejectRoutingWrites { + fn appended(&self, entry: &JournalEntry) { + if entry.entry_type == EntryType::RunSpawned { + *self.1.lock().unwrap() = Some(entry.run_id.clone()); + let connection = rusqlite::Connection::open( + self.0 + .join("runs") + .join(format!("{}.sqlite3", entry.run_id)), + ) + .unwrap(); + connection.execute_batch("CREATE TRIGGER reject_route BEFORE INSERT ON entries WHEN NEW.entry_type = 'step.routed' BEGIN SELECT RAISE(ABORT, 'injected routing write failure'); END;").unwrap(); + } + } +} + +#[test] +fn a_failed_routing_append_never_starts_or_dispatches_work() { + let directory = tempdir().unwrap(); + let provider = Arc::new(FixedProvider::default()); + let observer = Arc::new(RejectRoutingWrites( + directory.path().into(), + Mutex::new(None), + )); + let engine = Engine::with_runtime(directory.path(), provider.clone(), observer.clone()); + let spec = + RunSpec::parse(&json!({"steps":[{"id":"edit","type":"agent","instruction":"edit"}]})) + .unwrap(); + let error = engine.start(spec, "test", None).unwrap_err(); + assert!(format!("{error:#}").contains("injected routing write failure")); + assert!(provider.dispatched.lock().unwrap().is_empty()); + let run_id = observer.1.lock().unwrap().clone().unwrap(); + let entries = engine.journal_entries(&run_id, 0, 100).unwrap(); + assert!(entries.iter().all(|e| !matches!( + e.entry_type, + EntryType::StepRouted | EntryType::StepAttemptStarted + ))); +} + +struct CrashAfterRoute(Mutex>); +impl JournalObserver for CrashAfterRoute { + fn appended(&self, entry: &JournalEntry) { + if entry.entry_type == EntryType::StepRouted { + *self.0.lock().unwrap() = Some(entry.run_id.clone()); + panic!("injected crash after durable routing, before attempt start"); + } + } +} + +#[test] +fn crash_between_routing_and_start_does_not_redecide() { + let directory = tempdir().unwrap(); + let provider = Arc::new(FixedProvider::default()); + let observer = Arc::new(CrashAfterRoute(Mutex::new(None))); + let engine = Engine::with_runtime(directory.path(), provider.clone(), observer.clone()); + let spec = + RunSpec::parse(&json!({"steps":[{"id":"edit","type":"agent","instruction":"edit"}]})) + .unwrap(); + assert!( + std::panic::catch_unwind(std::panic::AssertUnwindSafe( + || engine.start(spec, "test", None) + )) + .is_err() + ); + let run_id = observer.0.lock().unwrap().clone().unwrap(); + let engine = Engine::with_runtime(directory.path(), provider.clone(), provider.clone()); + engine.resume(&run_id, None).unwrap(); + assert_eq!(provider.decisions.load(Ordering::SeqCst), 1); + let dispatches = provider.dispatched.lock().unwrap(); + assert_eq!(dispatches.len(), 1); + assert_eq!(dispatches[0].routing.provider, "test-cloud-adapter"); +} diff --git a/packages/sdk/src/compile.ts b/packages/sdk/src/compile.ts index 87f1f2489..444c2a6fd 100644 --- a/packages/sdk/src/compile.ts +++ b/packages/sdk/src/compile.ts @@ -110,6 +110,7 @@ function compileStep(step: StepSpec): StepSpec { ...(step.dependsOn !== undefined ? { dependsOn: step.dependsOn } : {}), maxIterations, ...(step.memory !== undefined ? { memory: step.memory } : {}), + ...(step.requirements !== undefined ? { requirements: step.requirements } : {}), }; switch (step.type as StepType) { @@ -353,13 +354,13 @@ function kernelTriggerToAuthoring(value: unknown, at: string): unknown { function kernelStepToAuthoring(value: unknown, at: string): unknown { const unionKeys = [ - 'id', 'type', 'depends_on', 'max_iterations', 'retry', 'verification', 'memory', + 'id', 'type', 'depends_on', 'max_iterations', 'retry', 'verification', 'memory', 'requirements', 'command', 'timeout_ms', 'prompt', 'model', 'cli', 'instruction', 'recovery_mode', 'surfaces', 'permissions', ] as const; const step = requireKernelObject(value, unionKeys, at); const type = step['type']; - const commonKeys = ['id', 'type', 'depends_on', 'max_iterations', 'retry', 'verification', 'memory'] as const; + const commonKeys = ['id', 'type', 'depends_on', 'max_iterations', 'retry', 'verification', 'memory', 'requirements'] as const; const typeKeys = type === 'deterministic' ? ['command', 'timeout_ms'] as const : type === 'llm' @@ -373,6 +374,7 @@ function kernelStepToAuthoring(value: unknown, at: string): unknown { const common = { id: step['id'], type, + ...(step['requirements'] !== undefined ? { requirements: kernelRequirementsToAuthoring(step['requirements'], `${at}.requirements`) } : {}), ...(dependsOn !== undefined && (!Array.isArray(dependsOn) || dependsOn.length > 0) ? { dependsOn } : {}), @@ -507,6 +509,10 @@ function toKernelStep(step: StepSpec): KernelStepSpec { max_iterations: step.maxIterations ?? 1, retry: { ...KERNEL_RETRY_DEFAULTS }, verification: toKernelVerification(step), + ...(step.requirements !== undefined ? { requirements: { + ...Object.fromEntries(Object.entries(step.requirements).filter(([key]) => key !== 'expectedDurationMs')), + ...(step.requirements.expectedDurationMs !== undefined ? { expected_duration_ms: step.requirements.expectedDurationMs } : {}), + } } : {}), ...(step.memory !== undefined ? { memory: { scope: step.memory.scope, query: step.memory.query, @@ -590,3 +596,11 @@ export function compileAndHash(yaml: string): { spec: FlowSpec; kernelSpec: Kern } export { SPEC_SCHEMA_VERSION, canonicalize, specHash }; + +function kernelRequirementsToAuthoring(value: unknown, at: string): unknown { + const requirements = requireKernelObject(value, ['execution', 'workspace', 'network', 'expected_duration_ms', 'preference'], at); + return { + ...copyDefined(requirements, ['execution', 'workspace', 'network', 'preference']), + ...(requirements['expected_duration_ms'] !== undefined ? { expectedDurationMs: requirements['expected_duration_ms'] } : {}), + }; +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 6c4daefb6..53ca90408 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -35,6 +35,8 @@ export type { StreamSurface, StepSpec, StepType, + PlacementRequirements, + KernelPlacementRequirements, TriggerSpec, VerificationGateType, VerificationSpec, @@ -118,6 +120,7 @@ export type { StepCompleteParams, StepCompleteResult, StepDispatchEvent, + RoutingDecision, MemoryInjectedPayload, StepHeartbeatParams, StepHeartbeatResult, diff --git a/packages/sdk/src/protocol.ts b/packages/sdk/src/protocol.ts index 24f81aeec..ad07426cd 100644 --- a/packages/sdk/src/protocol.ts +++ b/packages/sdk/src/protocol.ts @@ -171,7 +171,16 @@ export interface MemoryInjectedPayload { provider: string; } +export interface RoutingDecision { + profile: string; + provider: string; + fallbacks_attempted: string[]; + workspace?: string; +} + export interface StepDispatchEvent { + /** Durable choice; optional only for older kernel protocol compatibility. */ + routing?: RoutingDecision; /** Already journaled and charged; completion usage excludes this cost. */ memory?: MemoryInjectedPayload; run_id: string; diff --git a/packages/sdk/src/spec.ts b/packages/sdk/src/spec.ts index 408ffb771..422643955 100644 --- a/packages/sdk/src/spec.ts +++ b/packages/sdk/src/spec.ts @@ -48,7 +48,7 @@ export type OutputVerificationSpec = OutputContainsGate | JsonSchemaGate; /** * Agent-step recovery modes (RFC Appendix A rule 4). Default is `reset`. - * `deterministic` / `llm` steps have no workspace, so these do not apply. + * These recovery modes apply to agent steps; deterministic steps retry their commands. */ export type RecoveryMode = 'reset' | 'inspect' | 'manual'; @@ -107,8 +107,24 @@ export interface KernelMemorySpec { budget: KernelBudgetSpec; } +/** Capability needs, never provider names or source revisions. */ +export interface PlacementRequirements { + execution?: 'batch' | 'interactive'; + /** Share the run tree; defaults true for deterministic steps declaring requirements. */ + workspace?: boolean; + /** True requests connectivity; false does not impose a network deny policy. */ + network?: boolean; + expectedDurationMs?: number; + preference?: 'cost' | 'latency' | 'reliability' | 'balanced'; +} + +export interface KernelPlacementRequirements extends Omit { + expected_duration_ms?: number; +} + /** Fields shared by every step on the ladder. */ export interface BaseStepSpec { + requirements?: PlacementRequirements; memory?: MemorySpec; /** Stable step identity; journaled as `step_id` and hashed into the idempotency key. */ id: string; @@ -122,7 +138,7 @@ export interface BaseStepSpec { /** * Rung 1 — a pure script. Executed by the `relayflowd` binary: spawn command, * capture stdout/exit code. Output = `{exit_code, stdout_tail}`. Gate-1 - * deterministic steps are pure (no pins). + * deterministic steps with placement requirements pin their worktree base commit. */ export interface DeterministicStepSpec extends BaseStepSpec { type: 'deterministic'; @@ -286,6 +302,7 @@ export interface KernelVerificationSpec { } export interface KernelStepCommon { + requirements?: KernelPlacementRequirements; memory?: KernelMemorySpec; id: string; depends_on: string[]; diff --git a/packages/sdk/src/step-fields.ts b/packages/sdk/src/step-fields.ts index a67fbf782..634900d4b 100644 --- a/packages/sdk/src/step-fields.ts +++ b/packages/sdk/src/step-fields.ts @@ -23,6 +23,7 @@ export const STEP_COMMON_FIELDS = [ 'verification', 'maxIterations', 'memory', + 'requirements', ] as const; /** diff --git a/packages/sdk/src/validate.ts b/packages/sdk/src/validate.ts index 3eb261438..1429a5f51 100644 --- a/packages/sdk/src/validate.ts +++ b/packages/sdk/src/validate.ts @@ -229,6 +229,24 @@ class Validator { } } + private validateRequirements(value: unknown, at: string): void { + if (!isObject(value)) { this.fail(`${at}: expected an object`); return; } + this.checkKeys(value, ['execution', 'workspace', 'network', 'expectedDurationMs', 'preference'], at); + for (const field of ['workspace', 'network']) { + if (value[field] !== undefined && typeof value[field] !== 'boolean') this.fail(`${at}.${field}: expected a boolean`); + } + if (value['execution'] !== undefined && !['batch', 'interactive'].includes(value['execution'] as string)) { + this.fail(`${at}.execution: expected batch | interactive`); + } + if (value['preference'] !== undefined && !['cost', 'latency', 'reliability', 'balanced'].includes(value['preference'] as string)) { + this.fail(`${at}.preference: expected cost | latency | reliability | balanced`); + } + if (value['expectedDurationMs'] !== undefined && + (!Number.isSafeInteger(value['expectedDurationMs']) || (value['expectedDurationMs'] as number) <= 0)) { + this.fail(`${at}.expectedDurationMs: expected a positive safe integer`); + } + } + private validateMemory(value: unknown, at: string): void { if (!isObject(value)) { this.fail(`${at}: expected an object`); @@ -328,6 +346,7 @@ class Validator { const type = st['type'] as StepType; this.checkKeys(st, [...STEP_COMMON_FIELDS, ...STEP_FIELDS_BY_TYPE[type]], at); + if (st['requirements'] !== undefined) this.validateRequirements(st['requirements'], `${at}.requirements`); if (st['memory'] !== undefined) this.validateMemory(st['memory'], `${at}.memory`); if (st['dependsOn'] !== undefined) { diff --git a/packages/sdk/tests/placement.test.ts b/packages/sdk/tests/placement.test.ts new file mode 100644 index 000000000..635336782 --- /dev/null +++ b/packages/sdk/tests/placement.test.ts @@ -0,0 +1,27 @@ +import { readFileSync } from 'node:fs'; +import { describe, it, expect } from 'vitest'; +import { compileSpec, kernelToAuthoring, toKernelSpec } from '../src/compile.js'; + +const cases = JSON.parse(readFileSync(new URL('../../../testdata/placement-spec-cases.json', import.meta.url), 'utf8')); + +describe('placement declarations', () => { + for (const test of cases) { + for (const type of ['deterministic', 'llm', 'agent']) { + it(`${test.name}: ${type}`, () => { + const requirements = test.requirements && !Array.isArray(test.requirements) + ? Object.fromEntries(Object.entries(test.requirements).map(([key, value]) => [key === 'expected_duration_ms' ? 'expectedDurationMs' : key, value])) + : test.requirements; + const field = type === 'deterministic' ? 'command' : type === 'llm' ? 'prompt' : 'instruction'; + const flow = { version: '0.1.0', steps: [{ id: 's', type, [field]: 'true', requirements }] }; + if (test.valid) { + const compiled = compileSpec(flow); + const kernel = toKernelSpec(compiled); + expect(kernel.steps[0]?.requirements).toEqual(test.requirements); + expect(kernelToAuthoring(kernel)).toEqual(compiled); + } else { + expect(() => compileSpec(flow)).toThrow(); + } + }); + } + } +}); diff --git a/packages/sdk/tests/spec-parity.test.ts b/packages/sdk/tests/spec-parity.test.ts index 309a832ed..48e25ac2d 100644 --- a/packages/sdk/tests/spec-parity.test.ts +++ b/packages/sdk/tests/spec-parity.test.ts @@ -25,7 +25,7 @@ function fixture(name: string): string { } describe('spec parity: one dialect at the SDK<->kernel boundary', () => { - for (const name of ['hello-deterministic', 'hello-ladder', 'hello-llm', 'hello-agent', 'step-memory']) { + for (const name of ['hello-deterministic', 'hello-ladder', 'hello-llm', 'hello-agent', 'step-memory', 'step-placement']) { it(`compiles ${name} to the pinned canonical JSON`, () => { const yaml = fixture(`${name}.flow.yaml`); const canonical = compileYamlToCanonicalJson(yaml); diff --git a/testdata/placement-spec-cases.json b/testdata/placement-spec-cases.json new file mode 100644 index 000000000..b81247418 --- /dev/null +++ b/testdata/placement-spec-cases.json @@ -0,0 +1,127 @@ +[ + { + "name": "all", + "requirements": { + "execution": "batch", + "workspace": true, + "network": true, + "expected_duration_ms": 120000, + "preference": "cost" + }, + "valid": true + }, + { + "name": "empty", + "requirements": {}, + "valid": true + }, + { + "name": "interactive", + "requirements": { + "execution": "interactive" + }, + "valid": true + }, + { + "name": "false", + "requirements": { + "workspace": false, + "network": false + }, + "valid": true + }, + { + "name": "null", + "requirements": null, + "valid": false + }, + { + "name": "array", + "requirements": [], + "valid": false + }, + { + "name": "typo", + "requirements": { + "workspce": true + }, + "valid": false + }, + { + "name": "provider", + "requirements": { + "provider": "daytona" + }, + "valid": false + }, + { + "name": "null-field", + "requirements": { + "execution": null + }, + "valid": false + }, + { + "name": "bad-mode", + "requirements": { + "execution": "shell" + }, + "valid": false + }, + { + "name": "bad-bool", + "requirements": { + "workspace": "true" + }, + "valid": false + }, + { + "name": "bad-network", + "requirements": { + "network": "internet" + }, + "valid": false + }, + { + "name": "bad-preference", + "requirements": { + "preference": "cheap" + }, + "valid": false + }, + { + "name": "zero", + "requirements": { + "expected_duration_ms": 0 + }, + "valid": false + }, + { + "name": "negative", + "requirements": { + "expected_duration_ms": -1 + }, + "valid": false + }, + { + "name": "fraction", + "requirements": { + "expected_duration_ms": 1.5 + }, + "valid": false + }, + { + "name": "unsafe", + "requirements": { + "expected_duration_ms": 9007199254740992 + }, + "valid": false + }, + { + "name": "safe", + "requirements": { + "expected_duration_ms": 9007199254740991 + }, + "valid": true + } +] diff --git a/testdata/step-placement.flow.yaml b/testdata/step-placement.flow.yaml new file mode 100644 index 000000000..931c78db7 --- /dev/null +++ b/testdata/step-placement.flow.yaml @@ -0,0 +1,11 @@ +version: 0.1.0 +steps: + - id: write + type: deterministic + command: cat source.txt > shared.txt + requirements: + execution: batch + workspace: true + network: true + expectedDurationMs: 120000 + preference: cost diff --git a/testdata/step-placement.spec.canonical.json b/testdata/step-placement.spec.canonical.json new file mode 100644 index 000000000..f10743e2e --- /dev/null +++ b/testdata/step-placement.spec.canonical.json @@ -0,0 +1 @@ +{"steps":[{"command":"cat source.txt > shared.txt","depends_on":[],"id":"write","max_iterations":1,"requirements":{"execution":"batch","expected_duration_ms":120000,"network":true,"preference":"cost","workspace":true},"retry":{"initial_backoff_ms":100,"jitter_percent":20,"max_backoff_ms":60000,"multiplier":2},"type":"deterministic","verification":{}}],"version":"0.1.0"} diff --git a/testdata/step-placement.spec.sha256 b/testdata/step-placement.spec.sha256 new file mode 100644 index 000000000..8c4b10ded --- /dev/null +++ b/testdata/step-placement.spec.sha256 @@ -0,0 +1 @@ +e9ff5958dc0424087468f4d9fa2867c1ca59cfe9f442b9d3d2b1311de4e5558f From 9f3b2650be2c963463960e04ad9535e61b97f9f1 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Mon, 7 Sep 2026 14:12:39 +0200 Subject: [PATCH 3/6] fix(sdk): pin `requirements` in STEP_COMMON_FIELDS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `linux-x64-artifact` fails on this branch: FAIL tests/verb-field-lint.test.ts > closed per-verb step fields AssertionError: expected [ 'id','type','dependsOn', …(4) ] to deeply equal [ 'id','type','dependsOn', …(3) ] `requirements` was added to STEP_COMMON_FIELDS (step-fields.ts:26) without updating the pin that guards that list. The pin is an acknowledgement gate rather than a duplicate of the source, so adding the field to it IS the acknowledgement. The comment records why it is common rather than verb-specific, matching the `memory` entry directly above: any step kind may declare placement requirements, so it generates no foreign-field pairs. Second time this trap has fired — #221 hit it with `memory` and merged red, breaking main for ~90 minutes. Filing a follow-up so the failure message says what to do rather than a third lane rediscovering it. Verified: vitest tests/verb-field-lint.test.ts 78 passed full SDK suite 741 passed, 3 skipped, 0 failed Pushed to this PR's own branch rather than a new PR, so the fix lands where the work is. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR --- packages/sdk/tests/verb-field-lint.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/sdk/tests/verb-field-lint.test.ts b/packages/sdk/tests/verb-field-lint.test.ts index 821794b1b..3d6c6847f 100644 --- a/packages/sdk/tests/verb-field-lint.test.ts +++ b/packages/sdk/tests/verb-field-lint.test.ts @@ -183,6 +183,10 @@ describe('closed per-verb step fields', () => { // verb-specific: any step kind may declare a pack, so it generates no // foreign-field pairs. 'memory', + // Added by #227 (gate 7 slice 1), and common for the same reason: any + // step kind may declare placement requirements, so `requirements` + // generates no foreign-field pairs either. + 'requirements', ]); expect(STEP_FIELDS_BY_TYPE).toEqual({ deterministic: ['command', 'timeoutMs'], From 8bbafca34b6fb15556b67b374da0625dd65d11ea Mon Sep 17 00:00:00 2001 From: kjgbot Date: Mon, 7 Sep 2026 14:34:40 +0200 Subject: [PATCH 4/6] fix: document local pin defaults and explain routing failures --- kernel/evidence/225/review-9f3b265-green.txt | 321 ++++++++++++++++++ kernel/evidence/225/review-9f3b265-red.txt | 32 ++ kernel/evidence/225/review-9f3b265.md | 30 ++ kernel/relayflowd-core/src/placement.rs | 35 +- kernel/relayflowd-core/src/state.rs | 21 +- kernel/relayflowd-core/src/state/routing.rs | 49 +++ kernel/relayflowd-journal/src/placement.rs | 26 +- kernel/relayflowd/src/engine/placement.rs | 7 +- kernel/relayflowd/src/worker.rs | 13 +- .../relayflowd/tests/routing_diagnostics.rs | 112 ++++++ 10 files changed, 609 insertions(+), 37 deletions(-) create mode 100644 kernel/evidence/225/review-9f3b265-green.txt create mode 100644 kernel/evidence/225/review-9f3b265-red.txt create mode 100644 kernel/evidence/225/review-9f3b265.md create mode 100644 kernel/relayflowd-core/src/state/routing.rs create mode 100644 kernel/relayflowd/tests/routing_diagnostics.rs diff --git a/kernel/evidence/225/review-9f3b265-green.txt b/kernel/evidence/225/review-9f3b265-green.txt new file mode 100644 index 000000000..a789d61ee --- /dev/null +++ b/kernel/evidence/225/review-9f3b265-green.txt @@ -0,0 +1,321 @@ +$ cd kernel && cargo test --workspace + Compiling relayflowd-core v0.1.0 (/Users/khaliqgant/AgentWorkforce/flows-225-placement-wt/kernel/relayflowd-core) + Compiling relayflowd-journal v0.1.0 (/Users/khaliqgant/AgentWorkforce/flows-225-placement-wt/kernel/relayflowd-journal) + Compiling relayflowd v0.1.0 (/Users/khaliqgant/AgentWorkforce/flows-225-placement-wt/kernel/relayflowd) + Finished `test` profile [unoptimized + debuginfo] target(s) in 8.51s + Running unittests src/lib.rs (target/debug/deps/relayflowd-3287862779f564ca) + +running 36 tests +test engine::remote::worker_failure_detail_tests::a_null_or_blank_output_yields_no_detail ... ok +test engine::remote::worker_failure_detail_tests::a_string_output_is_carried_verbatim_and_trimmed ... ok +test engine::remote::worker_failure_detail_tests::an_output_at_the_boundary_is_not_truncated ... ok +test engine::remote::worker_failure_detail_tests::a_non_string_output_is_rendered_rather_than_dropped ... ok +test engine::remote::worker_failure_detail_tests::truncation_does_not_split_a_multi_byte_char ... ok +test server::client::tests::resume_waits_while_the_heartbeat_renewed_lease_is_live ... ok +test server::liveness::tests::sweep_id_buckets_by_the_interval ... ok +test server::channels::tests::unknown_verb_never_falls_through_to_receive ... ok +test engine::boot_identity_tests::every_engine_in_this_process_shares_one_boot_id ... ok +test exec_det::tests::timeout_has_an_explicit_completion_reason ... ok +test exec_det::tests::captures_deterministic_output ... ok +test server::liveness::tests::sweep_pass_healthy_subscription_is_a_noop ... ok +test engine::wake::claim_guard_tests::a_disarmed_guard_leaves_the_claim_alone ... ok +test server::tests::agent::contract::an_agent_worker_attaching_without_pins_is_refused_at_attach ... ok +test server::liveness::tests::sweep_pass_latches_after_journaling_and_next_bucket_is_empty ... ok +test server::tests::agent::contract::an_oversized_trajectory_tail_is_refused_at_step_complete ... ok +test engine::wake::claim_guard_tests::a_panic_between_claim_and_register_still_releases ... ok +test engine::wake::claim_guard_tests::a_guard_only_releases_its_own_run ... ok +test engine::wake::claim_guard_tests::an_armed_guard_releases_the_claim_when_dropped ... ok +test server::tests::agent::contract::agent_without_a_compatible_worker_parks_without_starting ... ok +test server::tests::hello_enforces_protocol_version ... ok +test server::tests::agent::contract::an_agent_worker_missing_a_declared_surface_parks_the_run_instead_of_erroring ... ok +test server::tests::agent::contract::a_replacement_worker_that_never_reported_the_pinned_surface_is_not_dispatched_to ... ok +test server::tests::run_resume_asks_the_registry_instead_of_treating_an_orphan_file_as_a_run ... ok +test server::tests::a_failed_disconnect_journal_append_is_retained_and_retried_not_dropped ... ok +test server::tests::run_start_fails_closed_on_an_unknown_verification_key ... ok +test server::tests::agent::contract::an_llm_completion_claiming_an_effect_fails_closed_with_the_reason_journaled ... ok +test server::tests::agent::pins::reset_worker_reporting_a_revision_other_than_its_pin_fails_closed_as_worker_error ... ok +test server::tests::run_resume_refuses_a_journal_that_never_recorded_its_run ... ok +test server::tests::run_resume_adopts_a_real_journal_whose_registry_row_is_missing ... ok +test server::tests::agent::pins::consecutive_agent_steps_on_different_surfaces_each_start_from_their_own_pins ... ok +test server::tests::run_resume_refuses_a_valid_journal_that_belongs_to_another_run ... ok +test server::tests::stopped_heartbeats_past_the_deadline_journal_lease_expired_and_release_the_step ... ok +test exec_det::tests::timeout_kills_the_whole_process_group ... ok +test server::tests::agent::pins::a_replacement_worker_at_a_different_revision_is_not_dispatched_the_stale_pins ... ok +test server::tests::an_entry_appended_during_watch_registration_is_delivered_exactly_once ... ok + +test result: ok. 36 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.61s + + Running unittests src/main.rs (target/debug/deps/relayflowd-ae3ceb16e5d803ec) + +running 0 tests + +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + Running tests/crash_resume.rs (target/debug/deps/crash_resume-4619905d2726c36c) + +running 40 tests +test concurrency::cancel_closes_the_lease_and_rejects_a_late_completion ... ok +test agent::rung_c_sigkill_after_final_effect_replays_results_without_redispatch ... ok +test agent::resume_without_a_worker_parks_immediately_instead_of_timing_out ... ok +test concurrency::cancel_and_completion_race_has_one_terminal_fact ... ok +test concurrency::concurrent_resumes_lease_exactly_one_attempt ... ok +test agent::rung_c_sigkill_between_agent_completion_and_final_effect_memoizes_the_agent ... ok +test channels::channels_reject_foreign_workers_stale_attempts_and_invalid_acknowledgements ... ok +test concurrency::run_start_dispatches_every_independent_lane_before_any_completion ... ok +test concurrency::live_resume_leaves_an_active_lease_running ... ok +test agent::rung_c_reset_sigkill_mid_edit_restores_pins_dedupes_effect_and_explains_attempts ... ok +test agent::rung_c_crash_between_effect_election_and_the_provider_call_performs_it_exactly_once ... ok +test llm::serve_plumbs_watch_events_and_replayable_stream_verbs ... ok +test llm::completed_llm_output_is_memoized_when_serve_dies_during_the_next_step ... ok +test llm::sigkill_after_the_final_rung_b_effect_resumes_without_redispatching_llm ... ok +test llm::llm_verification_exhaustion_is_a_declared_failure_kind ... ok +test llm::failing_llm_verification_schedules_a_durable_retry_and_succeeds ... ok +test concurrency::server_restart_recovers_every_parallel_lease_without_duplicate_success ... ok +test agent::rung_c_sigkill_boundaries_resume_only_unfinished_steps_via_real_cli ... ok +test llm::sigkill_under_serve_mid_llm_releases_the_lease_and_finishes_via_cli_resume ... ok +test pin_projection::rejected_completion_cannot_forge_inspect_retry_pins_over_the_real_socket ... ok +test memory::memory_sigkill_after_injection_replays_pack_and_charges_it_once ... ok +test llm::worker_killed_while_holding_a_lease_is_explained_and_released_on_cli_resume ... ok +test placement::declared_placement_keeps_one_source_tree_across_resume ... ok +test sigkill_after_cancel_request_resumes_to_one_canceled_fact ... ok +test protocol_admission::every_mutating_run_verb_refuses_terminal_before_changing_state ... ok +test parallel_lifecycle::overlapping_agent_conflict_survives_server_crash_and_resume ... ok +test sigkill_mid_step_replaces_and_explains_the_dead_attempt ... ok +test placement::sigkill_before_first_step_preserves_the_submitted_workspace ... ok +test placement::sigkill_mid_step_keeps_the_route_and_source_tree ... ok +test parallel_lifecycle::overlapping_agent_lanes_serialize_while_disjoint_lanes_merge_in_either_order ... ok +test surface_identity::aliases_are_rejected_and_external_ancestors_serialize_over_real_sockets ... ok +test parallel_lifecycle::terminal_failure_drains_or_explains_every_live_sibling ... ok +test llm::sigkill_sweep_covers_before_and_between_the_rung_b_steps ... ok +test sigkill_under_serve_resumes_the_socket_started_run ... ok +test worker_capacity::two_workers_receive_a_deterministic_fair_capacity_bounded_batch ... ok +test workspace_identity::workspace_aliases_are_refused_and_canonical_subtrees_serialize_over_real_sockets ... ok +test worker_capacity::default_capacity_one_reopens_only_after_durable_completion_or_crash ... ok +test channels::channels_sigkill_resume_redelivers_unacked_messages_with_exactly_once_effects ... ok +test sigkill_sweep_covers_every_hello_step_boundary ... ok +test parallel_lifecycle::renewed_parallel_leases_survive_the_original_grant_and_remain_distinct ... ok + +test result: ok. 40 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 37.95s + + Running tests/event_wake.rs (target/debug/deps/event_wake-bc602276d1cebcd0) + +running 3 tests +test matching_event_wakes_once_with_fresh_context ... ok +test two_racing_deliveries_of_one_event_produce_exactly_one_run ... ok +test a_resumed_run_dispatches_the_original_wake_context ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s + + Running tests/hn_monitor_integration.rs (target/debug/deps/hn_monitor_integration-6b22693ad979dd80) + +running 1 test +test hn_story_event_wakes_monitor_once_with_story_context ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s + + Running tests/invalid_schema_preflight.rs (target/debug/deps/invalid_schema_preflight-2c352b9351d5acf8) + +running 3 tests +test invalid_json_schema_is_refused_before_journal_or_command ... ok +test unbounded_json_schema_is_refused_before_journal_or_command ... ok +test legitimately_recursive_json_schema_still_starts ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 7.36s + + Running tests/memory.rs (target/debug/deps/memory-a74848a8274a290e) + +running 5 tests +test rejected_journal_fact_releases_reservation_and_never_dispatches ... ok +test llm_dispatch_receives_same_pack_after_resume_without_provider ... ok +test semantic_retry_reuses_memory_without_a_second_charge ... ok +test replay_and_resume_need_no_provider_and_script_receives_recorded_pack ... ok +test over_budget_and_provider_errors_fail_without_dispatch_or_charge ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s + + Running tests/memory_epoch.rs (target/debug/deps/memory_epoch-edee30a47cad81b5) + +running 1 test +test epoch_carries_pack_and_exact_charge_and_refuses_duplicate_injection ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s + + Running tests/parallel_driver.rs (target/debug/deps/parallel_driver-74d9d10149c534bd) + +running 4 tests +test stop_after_one_holds_for_an_independent_deterministic_batch ... ok +test backpressured_or_mismatched_lane_does_not_drop_a_later_dispatch ... ok +test crash_boundaries_resume_the_real_driver_with_one_effect_per_lane ... ok +test pause_before_second_independent_step_holds_the_driver_boundary ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.18s + + Running tests/placement_pins.rs (target/debug/deps/placement_pins-893a8773fbe94aee) + +running 2 tests +test unsupported_local_pty_is_refused_before_an_earlier_step_can_run ... ok +test default_worker_pins_the_declared_worktree_base_commit_and_refuses_missing_source ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.05s + + Running tests/placement_routing.rs (target/debug/deps/placement_routing-aabf5947f3ca76b4) + +running 3 tests +test a_failed_routing_append_never_starts_or_dispatches_work ... ok +test crash_between_routing_and_start_does_not_redecide ... ok +test worker_retry_consumes_the_original_routing_fact ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s + + Running tests/routing_diagnostics.rs (target/debug/deps/routing_diagnostics-ddddee3a427ee9ae) + +running 2 tests +test duplicate_routes_have_a_distinct_diagnostic_and_leave_the_original_fact_intact ... ok +test malformed_routes_name_the_same_field_at_append_replay_and_epoch_replay ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s + + Running tests/subscription_liveness.rs (target/debug/deps/subscription_liveness-9506d11f678a9586) + +running 3 tests +test submit_event_upserts_subscription_row_and_sweep_flags_it_stale_after_budget ... ok +test stale_transition_is_journaled_as_subscription_stale_entry_in_the_last_known_run ... ok +test a_fresh_arrival_re_arms_the_latch_and_the_next_silence_can_stale_again ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s + + Running unittests src/lib.rs (target/debug/deps/relayflowd_core-b1fe3b3250e9e7a2) + +running 60 tests +test clock::tests::simulated_clock_is_explicitly_advanced ... ok +test journal::tests::memory_journal_assigns_sequences_and_rolls_epochs ... ok +test channel::tests::send_retry_is_stable_and_conflicting_content_is_rejected ... ok +test channel::tests::forged_deliveries_and_acknowledgements_fail_closed ... ok +test channel::tests::malformed_payloads_and_invalid_new_channel_appends_leave_state_unchanged ... ok +test channel::tests::delivery_replay_and_independent_acknowledged_offsets ... ok +test machine::parallel_tests::external_ancestor_and_descendant_paths_conflict_but_siblings_do_not ... ok +test machine::parallel_tests::every_declared_mutable_surface_participates_in_conflict_selection ... ok +test machine::parallel_tests::workspace_ancestor_and_descendant_paths_conflict_but_siblings_do_not ... ok +test machine::parallel_tests::failed_run_drains_open_siblings_before_terminal_entry ... ok +test machine::tests::all_backing_off_steps_return_timers ... ok +test machine::tests::cancel_request_closes_the_active_lease_before_the_terminal_fact ... ok +test machine::parallel_tests::crash_resume_preserves_each_parallel_lease_exactly_once ... ok +test machine::parallel_tests::machine_starts_every_runnable_step_in_authored_order ... ok +test machine::parallel_tests::parallel_lanes_do_not_cross_the_dependency_barrier_early ... ok +test machine::tests::every_reason_label_matches_its_serialized_form ... ok +test machine::parallel_tests::overlapping_agent_surfaces_are_serialized_in_authored_order ... ok +test machine::tests::durable_cancel_request_outranks_crash_recovery ... ok +test machine::tests::machine_starts_runnable_step_with_stable_effect_key ... ok +test machine::tests::crashed_attempt_does_not_consume_an_iteration ... ok +test machine::parallel_tests::disjoint_agent_lanes_merge_pins_in_either_completion_order ... ok +test machine::tests::repeated_cancel_request_is_idempotent ... ok +test machine::tests::manual_recovery_parks_needs_human_and_never_redispatches ... ok +test machine::tests::worker_reported_failure_without_detail_still_records_a_verification ... ok +test machine::tests::successful_memo_is_never_scheduled_again ... ok +test retry::tests::jitter_is_repeatable_and_bounded ... ok +test machine::tests::every_failed_run_terminates_with_declared_completion_reasons ... ok +test machine::tests::verification_failure_schedules_a_durable_retry ... ok +test memory::tests::caps_compare_exact_decimals_and_each_token_dimension ... ok +test machine::tests::inspect_recovery_injects_the_dirty_pin_completion_reason_and_tail ... ok +test machine::tests::reset_recovery_dispatches_the_original_pinned_revision ... ok +test schema::tests::in_document_uri_references_resolve_to_the_node_they_name ... ok +test schema::tests::refusal_names_the_cycle_it_found ... ok +test spec::tests::a_misspelled_step_level_key_is_a_parse_error ... ok +test spec::tests::a_misspelled_verification_gate_key_is_a_parse_error_not_a_dropped_gate ... ok +test spec::tests::cycles_are_rejected ... ok +test spec::tests::external_surface_paths_must_have_one_canonical_spelling ... ok +test spec::tests::preflight_data_is_fail_closed ... ok +test spec::tests::spec_version_is_semver_and_gated ... ok +test schema::tests::references_the_bound_leaves_opaque_are_refused_by_the_engine ... ok +test spec::tests::unknown_root_and_nested_fields_are_rejected ... ok +test spec::tests::zero_agent_flow_is_valid ... ok +test state::budget::tests::adds_costs_exactly_beyond_machine_decimal_precision ... ok +test state::budget::tests::overflow_and_malformed_cost_leave_total_unchanged ... ok +test spec::tests::workspace_mounts_and_worktrees_must_have_one_canonical_spelling ... ok +test state::tests::budget_decimal_strings_add_without_floats ... ok +test state::tests::end_pin_chain_is_enforced_and_a_broken_chain_is_a_hard_error ... ok +test state::tests::a_completion_that_omits_a_surface_does_not_drop_it_from_the_pin_chain ... ok +test verify::tests::an_unbounded_schema_in_a_journal_fails_its_gate_instead_of_aborting ... ok +test state::tests::journal_replays_data_gate_verdict_without_rerunning_completed_code ... ok +test verify::tests::deterministic_output_requires_successful_exit_and_content ... ok +test spec::tests::the_full_ladder_parses_in_the_one_dialect ... ok +test verify::tests::json_schema_is_a_control_gate ... ok +test schema::tests::a_property_named_ref_is_not_a_reference ... ok +test schema::tests::shared_declarations_and_boolean_schemas_are_validated ... ok +test schema::tests::every_accepted_corpus_schema_is_accepted ... ok +test schema::tests::every_refused_corpus_schema_compiles_but_is_refused_by_the_bound ... ok +test spec::tests::sdk_boundary_rejects_a_10_000_step_cycle_with_a_typed_error ... ok +test spec::tests::sdk_boundary_accepts_a_valid_10_000_step_reverse_chain ... ok +test schema::tests::deeply_nested_schemas_do_not_overflow_the_checker ... ok + +test result: ok. 60 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.61s + + Running tests/spec_parity.rs (target/debug/deps/spec_parity-bbda6cf1e1cf1c19) + +running 9 tests +test placement_requirements_have_identical_canonical_bytes_and_hash ... ok +test the_kernel_parses_the_deterministic_rung_and_stamps_the_same_hash ... ok +test the_kernel_parses_the_event_triggered_spec_and_stamps_the_same_hash ... ok +test step_memory_has_identical_canonical_bytes_and_hash ... ok +test the_kernel_parses_the_rung_c_agent_spec_and_stamps_the_same_hash ... ok +test memory_declaration_acceptance_matches_the_sdk_corpus ... ok +test placement_declaration_acceptance_matches_the_sdk_corpus ... ok +test the_kernel_parses_the_sdk_compiled_spec_and_stamps_the_same_hash ... ok +test the_kernel_parses_the_rung_b_spec_and_stamps_the_same_hash ... ok + +test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s + + Running unittests src/lib.rs (target/debug/deps/relayflowd_journal-d13cb7954335385c) + +running 28 tests +test registry::tests::a_same_boot_claim_with_no_run_yet_is_a_duplicate_not_wreckage ... ok +test registry::tests::a_previous_boots_claim_with_no_run_is_repaired ... ok +test registry::tests::registry_is_a_rebuildable_run_locator ... ok +test registry::tests::a_registered_run_dedupes_across_boots ... ok +test registry::tests::releasing_a_claim_lets_the_same_boot_retry ... ok +test registry::tests::a_pre_migration_registry_gains_boot_id_and_its_claims_are_repairable ... ok +test channel::tests::stale_attempts_and_raw_forged_acknowledgements_cannot_change_offsets ... ok +test subscriptions::tests::detect_without_latch_stays_available_for_the_next_sweep ... ok +test registry::tests::releasing_is_scoped_to_the_claiming_run ... ok +test subscriptions::tests::last_run_for_subscription_returns_none_before_first_arrival ... ok +test subscriptions::tests::last_run_for_subscription_returns_the_lex_greatest_ulid_regardless_of_insertion ... ok +test subscriptions::tests::latch_is_a_no_op_if_a_fresh_event_arrived_between_detect_and_latch ... ok +test subscriptions::tests::prune_sweep_claims_deletes_only_rows_older_than_cutoff ... ok +test channel::tests::channels_cross_segment_boundaries_and_terminal_runs_reject_mutations ... ok +test subscriptions::tests::sweep_election_gives_the_first_caller_the_result_and_second_gets_empty ... ok +test subscriptions::tests::sweep_ignores_subscriptions_whose_silence_is_still_within_budget ... ok +test subscriptions::tests::sweep_marks_row_stale_when_silence_exceeds_budget ... ok +test subscriptions::tests::sweep_does_not_re_emit_the_same_stale_row_on_a_later_tick ... ok +test subscriptions::tests::upsert_after_stale_re_arms_and_next_silence_can_re_emit ... ok +test subscriptions::tests::upsert_is_idempotent_across_bumps_and_preserves_event_type_updates ... ok +test tests::append_is_durable_and_monotonic_after_reopen ... ok +test tests::failed_commit_is_returned_not_swallowed ... ok +test tests::an_unconfirmed_election_is_reclaimed_by_the_next_attempt_not_treated_as_done ... ok +test tests::terminal_run_refuses_every_later_entry_atomically ... ok +test tests::effects_are_deduplicated_at_the_journal_boundary ... ok +test tests::rollover_is_atomic_scaffolding_for_epoch_resume ... ok +test channel::tests::failed_channel_writes_never_expose_delivery_or_advance_acknowledged_offset ... ok +test channel::tests::independent_connections_serialize_send_receive_and_acknowledgement ... ok + +test result: ok. 28 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.20s + + Doc-tests relayflowd + +running 0 tests + +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + Doc-tests relayflowd_core + +running 0 tests + +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + Doc-tests relayflowd_journal + +running 0 tests + +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + +exit_code=0 diff --git a/kernel/evidence/225/review-9f3b265-red.txt b/kernel/evidence/225/review-9f3b265-red.txt new file mode 100644 index 000000000..ff3b27b5e --- /dev/null +++ b/kernel/evidence/225/review-9f3b265-red.txt @@ -0,0 +1,32 @@ +$ cd kernel && cargo test --workspace --test routing_diagnostics + Compiling relayflowd v0.1.0 (/Users/khaliqgant/AgentWorkforce/flows-225-placement-wt/kernel/relayflowd) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.95s + Running tests/routing_diagnostics.rs (target/debug/deps/routing_diagnostics-ddddee3a427ee9ae) + +running 2 tests +test malformed_routes_name_the_same_field_at_append_replay_and_epoch_replay ... FAILED +test duplicate_routes_have_a_distinct_diagnostic_and_leave_the_original_fact_intact ... FAILED + +failures: + +---- malformed_routes_name_the_same_field_at_append_replay_and_epoch_replay stdout ---- + +thread 'malformed_routes_name_the_same_field_at_append_replay_and_epoch_replay' (85896877) panicked at relayflowd/tests/routing_diagnostics.rs:40:9: +assertion failed: journal.append(&entry).unwrap_err().to_string().contains(&expected) +note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace + +---- duplicate_routes_have_a_distinct_diagnostic_and_leave_the_original_fact_intact stdout ---- + +thread 'duplicate_routes_have_a_distinct_diagnostic_and_leave_the_original_fact_intact' (85896876) panicked at relayflowd/tests/routing_diagnostics.rs:57:5: +assertion failed: journal.append(&entry).unwrap_err().to_string().contains(expected) + + +failures: + duplicate_routes_have_a_distinct_diagnostic_and_leave_the_original_fact_intact + malformed_routes_name_the_same_field_at_append_replay_and_epoch_replay + +test result: FAILED. 0 passed; 2 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s + +error: test failed, to rerun pass `-p relayflowd --test routing_diagnostics` + +exit_code=101 diff --git a/kernel/evidence/225/review-9f3b265.md b/kernel/evidence/225/review-9f3b265.md new file mode 100644 index 000000000..4c52aa643 --- /dev/null +++ b/kernel/evidence/225/review-9f3b265.md @@ -0,0 +1,30 @@ +# Maintainability blockers at 9f3b265 + +Both requested blockers are addressed: + +1. `StepDispatcher::starting_pins` documents the local filesystem/Git default, + its failure conditions, and the overrides remote dispatchers must supply. + `reserved_starting_pins` documents that its default delegates to that same + behavior. This is a documentation correction, with no pin-source behavior change. +2. `StateError::InvalidRouting` carries `step` and `detail`. Duplicate decisions + report `routing decision already recorded`; malformed decisions identify + `profile`, `provider`, `workspace`, or the blank fallback's array index. + `RoutingDecision::validate` is the shared field validator for engine admission, + SQLite routing admission, state replay, and epoch replay. Unknown steps retain + their own error, and attempt-scoped routing has its own detail. Routing replay + moved into `state/routing.rs` to keep `state.rs` below 500 lines. + +The new tests check diagnostic agreement across SQLite append, replay, and epoch +replay, plus transaction rollback and preservation of the original routing fact +on duplicate admission. Existing tests and review gates were not edited. + +Other review concerns are outside this repair. In particular, this does not +claim to change epoch-summary write admission, source-revision selection, +workspace inheritance, or execution/pinning failure precedence. + +Literal commands and complete captured output: + +- [Failing diagnostic tests before the fix](review-9f3b265-red.txt): + `cd kernel && cargo test --workspace --test routing_diagnostics` +- [Full workspace gate after the fix](review-9f3b265-green.txt): + `cd kernel && cargo test --workspace` diff --git a/kernel/relayflowd-core/src/placement.rs b/kernel/relayflowd-core/src/placement.rs index 2b9699124..da20ee114 100644 --- a/kernel/relayflowd-core/src/placement.rs +++ b/kernel/relayflowd-core/src/placement.rs @@ -68,16 +68,29 @@ pub struct RoutingDecision { } impl RoutingDecision { - pub fn is_valid(&self) -> bool { - !self.profile.trim().is_empty() - && !self.provider.trim().is_empty() - && self - .workspace - .as_ref() - .is_none_or(|value| !value.trim().is_empty()) - && self - .fallbacks_attempted - .iter() - .all(|value| !value.trim().is_empty()) + /// Shared field validation for admission, replay, and epoch reconstruction. + /// Return the rejected field so operational failures retain their cause. + pub fn validate(&self) -> Result<(), String> { + for (field, value) in [ + ("profile", self.profile.as_str()), + ("provider", self.provider.as_str()), + ] { + if value.trim().is_empty() { + return Err(format!("{field} must not be blank")); + } + } + if self + .workspace + .as_ref() + .is_some_and(|value| value.trim().is_empty()) + { + return Err("workspace must not be blank".into()); + } + for (index, value) in self.fallbacks_attempted.iter().enumerate() { + if value.trim().is_empty() { + return Err(format!("fallbacks_attempted[{index}] must not be blank")); + } + } + Ok(()) } } diff --git a/kernel/relayflowd-core/src/state.rs b/kernel/relayflowd-core/src/state.rs index 5f4f195cf..9575fd854 100644 --- a/kernel/relayflowd-core/src/state.rs +++ b/kernel/relayflowd-core/src/state.rs @@ -15,6 +15,7 @@ use crate::{ mod budget; mod memory; mod pins; +mod routing; use budget::add_budget; #[derive(Debug, Clone, PartialEq)] @@ -124,15 +125,7 @@ impl RunState { } match entry.entry_type { EntryType::EpochSummary => state.apply_epoch(entry)?, - EntryType::StepRouted => { - let id = entry.step_id.as_ref().ok_or(StateError::MissingStep(entry.seq))?; - if !state.steps.contains_key(id) { return Err(StateError::UnknownStep(id.clone())); } - let route: crate::RoutingDecision = decode(entry)?; - if state.routing.contains_key(id) || route.profile.trim().is_empty() || route.provider.trim().is_empty() { - return Err(StateError::InvalidRouting(id.clone())); - } - state.routing.insert(id.clone(), route); - } + EntryType::StepRouted => state.apply_routing(entry)?, EntryType::StepAttemptStarted => { let payload: crate::entry::AttemptStartedPayload = decode(entry)?; state.validate_start_pins(entry, &payload)?; @@ -325,12 +318,8 @@ impl RunState { fn apply_epoch(&mut self, entry: &JournalEntry) -> Result<(), StateError> { let payload: EpochSummaryPayload = decode(entry)?; + self.validate_routing(&payload.routing)?; self.routing = payload.routing; - for (id, route) in &self.routing { - if !self.steps.contains_key(id) || !route.is_valid() { - return Err(StateError::InvalidRouting(id.clone())); - } - } self.memo.clear(); self.budget = payload.budget_spent; for runtime in self.steps.values_mut() { @@ -440,8 +429,8 @@ fn decode(entry: &JournalEntry) -> Result Result<(), StateError> { + let id = entry + .step_id + .as_ref() + .ok_or(StateError::MissingStep(entry.seq))?; + if self.routing.contains_key(id) { + return Err(StateError::InvalidRouting { + step: id.clone(), + detail: "routing decision already recorded".into(), + }); + } + let route: RoutingDecision = decode(entry)?; + self.validate_routing_decision(id, &route)?; + self.routing.insert(id.clone(), route); + Ok(()) + } + + pub(super) fn validate_routing( + &self, + routing: &BTreeMap, + ) -> Result<(), StateError> { + for (id, route) in routing { + self.validate_routing_decision(id, route)?; + } + Ok(()) + } + + fn validate_routing_decision( + &self, + id: &str, + route: &RoutingDecision, + ) -> Result<(), StateError> { + if !self.steps.contains_key(id) { + return Err(StateError::UnknownStep(id.into())); + } + route + .validate() + .map_err(|detail| StateError::InvalidRouting { + step: id.into(), + detail, + }) + } +} diff --git a/kernel/relayflowd-journal/src/placement.rs b/kernel/relayflowd-journal/src/placement.rs index a6b8b2fce..2d1d78d0a 100644 --- a/kernel/relayflowd-journal/src/placement.rs +++ b/kernel/relayflowd-journal/src/placement.rs @@ -20,9 +20,20 @@ fn decisions( } let id = entry.step_id.ok_or(StateError::MissingStep(entry.seq))?; let route: RoutingDecision = serde_json::from_value(entry.payload)?; - if !route.is_valid() || routing.insert(id.clone(), route).is_some() { - return Err(StateError::InvalidRouting(id).into()); + if routing.contains_key(&id) { + return Err(StateError::InvalidRouting { + step: id, + detail: "routing decision already recorded".into(), + } + .into()); } + route + .validate() + .map_err(|detail| StateError::InvalidRouting { + step: id.clone(), + detail, + })?; + routing.insert(id, route); } Ok(routing) } @@ -44,8 +55,15 @@ pub(crate) fn validate_entry( )?; let spawn: relayflowd_core::RunSpawnedPayload = serde_json::from_str(&payload)?; let spec = relayflowd_core::RunSpec::parse(&spawn.spec).map_err(StateError::from)?; - if spec.step(id).is_none() || entry.attempt.is_some() { - return Err(StateError::InvalidRouting(id.into()).into()); + if spec.step(id).is_none() { + return Err(StateError::UnknownStep(id.into()).into()); + } + if entry.attempt.is_some() { + return Err(StateError::InvalidRouting { + step: id.into(), + detail: "routing decision must not specify an attempt".into(), + } + .into()); } decisions(tx, run_id)?; } diff --git a/kernel/relayflowd/src/engine/placement.rs b/kernel/relayflowd/src/engine/placement.rs index c0fdec8d0..d094376a1 100644 --- a/kernel/relayflowd/src/engine/placement.rs +++ b/kernel/relayflowd/src/engine/placement.rs @@ -132,9 +132,10 @@ impl Engine { entry.attempt.context("start has no attempt")?, )? }; - if !route.is_valid() { - bail!("step {step_id} received an empty routing decision"); - } + route + .validate() + .map_err(anyhow::Error::msg) + .with_context(|| format!("invalid routing decision for step {step_id}"))?; // Resolve source facts before appending the routing decision. if route.provider == "local" && let Some(path) = &route.workspace diff --git a/kernel/relayflowd/src/worker.rs b/kernel/relayflowd/src/worker.rs index f0f44661b..854814e25 100644 --- a/kernel/relayflowd/src/worker.rs +++ b/kernel/relayflowd/src/worker.rs @@ -66,13 +66,20 @@ pub trait StepDispatcher: Send + Sync { self.executor(step.step_type()) } - /// Opaque starting revisions/offsets reported by the selected worker for - /// the first agent attempt. Later attempts are derived from the journal. + /// Supply starting pins for declared surfaces not yet covered by the journal. + /// The default runs `git rev-parse --verify HEAD` in each declared local + /// worktree, using this process's filesystem, and fails on unreadable + /// worktrees or declared streams (it cannot report stream offsets). + /// Remote dispatchers must override this or `reserved_starting_pins` to + /// report revisions/offsets from their selected worker instead of local Git. + /// Surfaces already pinned by the run are carried forward from the journal. fn starting_pins(&self, step: &StepSpec) -> Result { crate::workspace::starting_pins(step) } - /// Starting pins reported by the worker whose capacity was reserved. + /// Starting pins for the worker whose capacity was reserved. The default + /// delegates to `starting_pins`, including its local filesystem/Git behavior; + /// remote dispatchers override this when pin lookup depends on the reservation. fn reserved_starting_pins( &self, _run_id: &str, diff --git a/kernel/relayflowd/tests/routing_diagnostics.rs b/kernel/relayflowd/tests/routing_diagnostics.rs new file mode 100644 index 000000000..873284740 --- /dev/null +++ b/kernel/relayflowd/tests/routing_diagnostics.rs @@ -0,0 +1,112 @@ +use relayflowd_core::{EntryType, Journal, JournalEntry, RunSpec, RunState}; +use relayflowd_journal::SqliteJournal; +use serde_json::{Value, json}; +use tempfile::tempdir; + +fn spec() -> RunSpec { + RunSpec::parse(&json!({"steps":[{"id":"s","type":"deterministic","command":"true"}]})).unwrap() +} + +fn route() -> Value { + json!({"profile":"batch","provider":"local","workspace":"/repo","fallbacks_attempted":["pool-a"]}) +} + +fn routed(payload: Value) -> JournalEntry { + JournalEntry::new( + EntryType::StepRouted, + "run", + Some("s".into()), + None, + 1, + payload, + ) +} + +fn journal(path: &std::path::Path) -> SqliteJournal { + let mut journal = SqliteJournal::create(path, "run", 0).unwrap(); + journal.append(&JournalEntry::new(EntryType::RunSpawned, "run", None, None, 0, + json!({"spec":spec(),"spec_hash":"test","parent_run_id":null,"journal_version":1,"created_by":"test"}))).unwrap(); + journal +} + +#[test] +fn malformed_routes_name_the_same_field_at_append_replay_and_epoch_replay() { + for (field, value, detail) in [ + ("profile", json!(" "), "profile must not be blank"), + ("provider", json!("\t"), "provider must not be blank"), + ("workspace", json!(""), "workspace must not be blank"), + ( + "fallbacks_attempted", + json!(["pool-a", " "]), + "fallbacks_attempted[1] must not be blank", + ), + ] { + let directory = tempdir().unwrap(); + let mut journal = journal(&directory.path().join("run.sqlite3")); + let mut payload = route(); + payload[field] = value; + let entry = routed(payload.clone()); + let expected = format!("invalid routing decision for step s: {detail}"); + let before = journal.scan_all().unwrap(); + assert!( + journal + .append(&entry) + .unwrap_err() + .to_string() + .contains(&expected) + ); + assert_eq!( + journal.scan_all().unwrap(), + before, + "invalid append must roll back" + ); + assert_eq!( + RunState::fold("run", spec(), &[entry]) + .unwrap_err() + .to_string(), + expected + ); + let epoch = JournalEntry::new( + EntryType::EpochSummary, + "run", + None, + None, + 2, + json!({"epoch":2,"prev_segment_id":1,"journal_version":1,"budget_spent":{},"routing":{"s":payload}}), + ); + assert_eq!( + RunState::fold("run", spec(), &[epoch]) + .unwrap_err() + .to_string(), + expected + ); + } +} + +#[test] +fn duplicate_routes_have_a_distinct_diagnostic_and_leave_the_original_fact_intact() { + let directory = tempdir().unwrap(); + let mut journal = journal(&directory.path().join("run.sqlite3")); + let entry = routed(route()); + journal.append(&entry).unwrap(); + let before = journal.scan_all().unwrap(); + let expected = "invalid routing decision for step s: routing decision already recorded"; + assert!( + journal + .append(&entry) + .unwrap_err() + .to_string() + .contains(expected) + ); + assert_eq!(journal.scan_all().unwrap(), before); + assert_eq!( + RunState::fold("run", spec(), &[entry.clone(), entry]) + .unwrap_err() + .to_string(), + expected + ); + assert_eq!( + RunState::fold("run", spec(), &before).unwrap().routing["s"].provider, + "local" + ); +} From 733cf17a216234cc08f284444910a279c4c1553d Mon Sep 17 00:00:00 2001 From: kjgbot Date: Mon, 7 Sep 2026 23:55:41 +0200 Subject: [PATCH 5/6] fix(placement): reject invalid replay facts and pin commit objects --- kernel/evidence/225/SPEC-REVIEW-0907.md | 38 ++ .../225/spec-review-kernel-tests-final.txt | 328 +++++++++ .../evidence/225/spec-review-kernel-tests.txt | 634 ++++++++++++++++++ .../225/spec-review-regressions-after.txt | 17 + .../225/spec-review-regressions-before.txt | 177 +++++ kernel/evidence/225/spec-review-sdk-tests.txt | 16 + .../225/spec-review-source-drift-repro.py | 27 + .../225/spec-review-source-drift-repro.txt | 23 + kernel/relayflowd-core/src/state/routing.rs | 6 + kernel/relayflowd-journal/src/placement.rs | 40 +- kernel/relayflowd/src/worker.rs | 2 +- kernel/relayflowd/src/workspace.rs | 2 +- .../relayflowd/tests/spec_review_routing.rs | 173 +++++ 13 files changed, 1477 insertions(+), 6 deletions(-) create mode 100644 kernel/evidence/225/SPEC-REVIEW-0907.md create mode 100644 kernel/evidence/225/spec-review-kernel-tests-final.txt create mode 100644 kernel/evidence/225/spec-review-kernel-tests.txt create mode 100644 kernel/evidence/225/spec-review-regressions-after.txt create mode 100644 kernel/evidence/225/spec-review-regressions-before.txt create mode 100644 kernel/evidence/225/spec-review-sdk-tests.txt create mode 100644 kernel/evidence/225/spec-review-source-drift-repro.py create mode 100644 kernel/evidence/225/spec-review-source-drift-repro.txt create mode 100644 kernel/relayflowd/tests/spec_review_routing.rs diff --git a/kernel/evidence/225/SPEC-REVIEW-0907.md b/kernel/evidence/225/SPEC-REVIEW-0907.md new file mode 100644 index 000000000..1a29660eb --- /dev/null +++ b/kernel/evidence/225/SPEC-REVIEW-0907.md @@ -0,0 +1,38 @@ +# Spec review 2026-09-07: not approved for merge + +Four focused regression tests failed before the repair and passed after it. +These are before/after regressions, not a full mutation-verification claim. +- [Before](spec-review-regressions-before.txt) +- [After](spec-review-regressions-after.txt) + +The full workspace run initially failed in doctests because ambient rustdoc +used a different compiler from the explicitly selected rustc. The failure is +preserved in [first run](spec-review-kernel-tests.txt). Selecting matching +RUSTC and RUSTDOC produced the [final workspace output](spec-review-kernel-tests-final.txt), +including crash/resume tests and doctests. [SDK output](spec-review-sdk-tests.txt) +covers placement, parity and verb-field lint. Each file contains its literal +command, complete captured output, and exit code. + +Repairs tighten admission/replay without adding further vocabulary: +- Attempt-scoped routes now fail replay with the same diagnostic as append. +- Epoch routing is validated transactionally, including preservation of prior + routes; a raw summary cannot silently drop or replace a routing decision. +- Worktree pins peel HEAD to a commit and refuse non-commit objects. + +Unresolved blockers: +1. RFC-0001 decision #13 / the assigned vocabulary rule: `step.routed`, + `epoch.summary.routing`, `StepDispatch.routing`, and the exact kernel + `requirements` schema have no explicit specification in RFC-0001. Gate 7 + requires routing evidence but does not settle this extension's schema. + Khaliq/spec owner must settle the contract or require lowering to existing + facts. This repair does not amend the RFC or approve the vocabulary. +2. `engine/placement.rs:163` still re-reads HEAD at every deterministic attempt. + [Reproduction](spec-review-source-drift-repro.txt) shows a source commit + changed between steps and resume completing against different source pins. + The [repro script](spec-review-source-drift-repro.py) takes the built daemon + path. Pin storage/recovery needs a design consistent with the settled + vocabulary; this patch does not invent another durable field to conceal it. + +The existing descriptor and blank routing-field findings were already fixed +at the reviewed head. Neither local passing tests nor vendor checks supply +independent review or override the specification blockers. diff --git a/kernel/evidence/225/spec-review-kernel-tests-final.txt b/kernel/evidence/225/spec-review-kernel-tests-final.txt new file mode 100644 index 000000000..fba16adde --- /dev/null +++ b/kernel/evidence/225/spec-review-kernel-tests-final.txt @@ -0,0 +1,328 @@ +$ env RUSTC=/Users/khaliqgant/.rustup/toolchains/stable-aarch64-apple-darwin/bin/rustc RUSTDOC=/Users/khaliqgant/.rustup/toolchains/stable-aarch64-apple-darwin/bin/rustdoc /Users/khaliqgant/.rustup/toolchains/stable-aarch64-apple-darwin/bin/cargo test --manifest-path kernel/Cargo.toml --locked --offline --workspace + Finished `test` profile [unoptimized + debuginfo] target(s) in 0.38s + Running unittests src/lib.rs (kernel/target/debug/deps/relayflowd-7cc6b6adaeba2a85) + +running 36 tests +test engine::remote::worker_failure_detail_tests::a_null_or_blank_output_yields_no_detail ... ok +test engine::remote::worker_failure_detail_tests::a_non_string_output_is_rendered_rather_than_dropped ... ok +test engine::remote::worker_failure_detail_tests::an_output_at_the_boundary_is_not_truncated ... ok +test engine::remote::worker_failure_detail_tests::a_string_output_is_carried_verbatim_and_trimmed ... ok +test engine::remote::worker_failure_detail_tests::truncation_does_not_split_a_multi_byte_char ... ok +test engine::boot_identity_tests::every_engine_in_this_process_shares_one_boot_id ... ok +test server::client::tests::resume_waits_while_the_heartbeat_renewed_lease_is_live ... ok +test server::liveness::tests::sweep_id_buckets_by_the_interval ... ok +test server::channels::tests::unknown_verb_never_falls_through_to_receive ... ok +test exec_det::tests::captures_deterministic_output ... ok +test exec_det::tests::timeout_has_an_explicit_completion_reason ... ok +test server::liveness::tests::sweep_pass_healthy_subscription_is_a_noop ... ok +test server::liveness::tests::sweep_pass_latches_after_journaling_and_next_bucket_is_empty ... ok +test engine::wake::claim_guard_tests::a_disarmed_guard_leaves_the_claim_alone ... ok +test server::tests::agent::contract::an_agent_worker_attaching_without_pins_is_refused_at_attach ... ok +test engine::wake::claim_guard_tests::a_guard_only_releases_its_own_run ... ok +test engine::wake::claim_guard_tests::an_armed_guard_releases_the_claim_when_dropped ... ok +test server::tests::agent::contract::an_oversized_trajectory_tail_is_refused_at_step_complete ... ok +test engine::wake::claim_guard_tests::a_panic_between_claim_and_register_still_releases ... ok +test server::tests::agent::contract::an_agent_worker_missing_a_declared_surface_parks_the_run_instead_of_erroring ... ok +test server::tests::hello_enforces_protocol_version ... ok +test server::tests::agent::contract::agent_without_a_compatible_worker_parks_without_starting ... ok +test server::tests::run_resume_asks_the_registry_instead_of_treating_an_orphan_file_as_a_run ... ok +test server::tests::agent::contract::a_replacement_worker_that_never_reported_the_pinned_surface_is_not_dispatched_to ... ok +test server::tests::agent::contract::an_llm_completion_claiming_an_effect_fails_closed_with_the_reason_journaled ... ok +test server::tests::a_failed_disconnect_journal_append_is_retained_and_retried_not_dropped ... ok +test server::tests::run_start_fails_closed_on_an_unknown_verification_key ... ok +test server::tests::run_resume_refuses_a_journal_that_never_recorded_its_run ... ok +test server::tests::agent::pins::reset_worker_reporting_a_revision_other_than_its_pin_fails_closed_as_worker_error ... ok +test server::tests::run_resume_adopts_a_real_journal_whose_registry_row_is_missing ... ok +test server::tests::agent::pins::consecutive_agent_steps_on_different_surfaces_each_start_from_their_own_pins ... ok +test server::tests::run_resume_refuses_a_valid_journal_that_belongs_to_another_run ... ok +test server::tests::stopped_heartbeats_past_the_deadline_journal_lease_expired_and_release_the_step ... ok +test exec_det::tests::timeout_kills_the_whole_process_group ... ok +test server::tests::agent::pins::a_replacement_worker_at_a_different_revision_is_not_dispatched_the_stale_pins ... ok +test server::tests::an_entry_appended_during_watch_registration_is_delivered_exactly_once ... ok + +test result: ok. 36 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.58s + + Running unittests src/main.rs (kernel/target/debug/deps/relayflowd-a3a150c1162c49ec) + +running 0 tests + +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + Running tests/crash_resume.rs (kernel/target/debug/deps/crash_resume-8980b58a474e7aba) + +running 40 tests +test agent::resume_without_a_worker_parks_immediately_instead_of_timing_out ... ok +test concurrency::cancel_and_completion_race_has_one_terminal_fact ... ok +test agent::rung_c_sigkill_after_final_effect_replays_results_without_redispatch ... ok +test concurrency::cancel_closes_the_lease_and_rejects_a_late_completion ... ok +test channels::channels_reject_foreign_workers_stale_attempts_and_invalid_acknowledgements ... ok +test agent::rung_c_sigkill_between_agent_completion_and_final_effect_memoizes_the_agent ... ok +test concurrency::concurrent_resumes_lease_exactly_one_attempt ... ok +test concurrency::live_resume_leaves_an_active_lease_running ... ok +test concurrency::run_start_dispatches_every_independent_lane_before_any_completion ... ok +test agent::rung_c_crash_between_effect_election_and_the_provider_call_performs_it_exactly_once ... ok +test concurrency::server_restart_recovers_every_parallel_lease_without_duplicate_success ... ok +test agent::rung_c_reset_sigkill_mid_edit_restores_pins_dedupes_effect_and_explains_attempts ... ok +test llm::failing_llm_verification_schedules_a_durable_retry_and_succeeds ... ok +test llm::serve_plumbs_watch_events_and_replayable_stream_verbs ... ok +test llm::completed_llm_output_is_memoized_when_serve_dies_during_the_next_step ... ok +test llm::llm_verification_exhaustion_is_a_declared_failure_kind ... ok +test llm::sigkill_after_the_final_rung_b_effect_resumes_without_redispatching_llm ... ok +test agent::rung_c_sigkill_boundaries_resume_only_unfinished_steps_via_real_cli ... ok +test memory::memory_sigkill_after_injection_replays_pack_and_charges_it_once ... ok +test llm::worker_killed_while_holding_a_lease_is_explained_and_released_on_cli_resume ... ok +test llm::sigkill_under_serve_mid_llm_releases_the_lease_and_finishes_via_cli_resume ... ok +test pin_projection::rejected_completion_cannot_forge_inspect_retry_pins_over_the_real_socket ... ok +test protocol_admission::every_mutating_run_verb_refuses_terminal_before_changing_state ... ok +test parallel_lifecycle::overlapping_agent_conflict_survives_server_crash_and_resume ... ok +test placement::declared_placement_keeps_one_source_tree_across_resume ... ok +test placement::sigkill_mid_step_keeps_the_route_and_source_tree ... ok +test placement::sigkill_before_first_step_preserves_the_submitted_workspace ... ok +test parallel_lifecycle::overlapping_agent_lanes_serialize_while_disjoint_lanes_merge_in_either_order ... ok +test parallel_lifecycle::terminal_failure_drains_or_explains_every_live_sibling ... ok +test sigkill_after_cancel_request_resumes_to_one_canceled_fact ... ok +test sigkill_mid_step_replaces_and_explains_the_dead_attempt ... ok +test llm::sigkill_sweep_covers_before_and_between_the_rung_b_steps ... ok +test surface_identity::aliases_are_rejected_and_external_ancestors_serialize_over_real_sockets ... ok +test worker_capacity::two_workers_receive_a_deterministic_fair_capacity_bounded_batch ... ok +test sigkill_under_serve_resumes_the_socket_started_run ... ok +test workspace_identity::workspace_aliases_are_refused_and_canonical_subtrees_serialize_over_real_sockets ... ok +test worker_capacity::default_capacity_one_reopens_only_after_durable_completion_or_crash ... ok +test channels::channels_sigkill_resume_redelivers_unacked_messages_with_exactly_once_effects ... ok +test sigkill_sweep_covers_every_hello_step_boundary ... ok +test parallel_lifecycle::renewed_parallel_leases_survive_the_original_grant_and_remain_distinct ... ok + +test result: ok. 40 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 37.53s + + Running tests/event_wake.rs (kernel/target/debug/deps/event_wake-694cba08f5e199b7) + +running 3 tests +test two_racing_deliveries_of_one_event_produce_exactly_one_run ... ok +test matching_event_wakes_once_with_fresh_context ... ok +test a_resumed_run_dispatches_the_original_wake_context ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.07s + + Running tests/hn_monitor_integration.rs (kernel/target/debug/deps/hn_monitor_integration-712d52524bb3367c) + +running 1 test +test hn_story_event_wakes_monitor_once_with_story_context ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.06s + + Running tests/invalid_schema_preflight.rs (kernel/target/debug/deps/invalid_schema_preflight-c3ac725a2193ec68) + +running 3 tests +test invalid_json_schema_is_refused_before_journal_or_command ... ok +test unbounded_json_schema_is_refused_before_journal_or_command ... ok +test legitimately_recursive_json_schema_still_starts ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 5.56s + + Running tests/memory.rs (kernel/target/debug/deps/memory-a5944bc39239e789) + +running 5 tests +test rejected_journal_fact_releases_reservation_and_never_dispatches ... ok +test llm_dispatch_receives_same_pack_after_resume_without_provider ... ok +test replay_and_resume_need_no_provider_and_script_receives_recorded_pack ... ok +test over_budget_and_provider_errors_fail_without_dispatch_or_charge ... ok +test semantic_retry_reuses_memory_without_a_second_charge ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.06s + + Running tests/memory_epoch.rs (kernel/target/debug/deps/memory_epoch-53e5ca10ed87e9cb) + +running 1 test +test epoch_carries_pack_and_exact_charge_and_refuses_duplicate_injection ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s + + Running tests/parallel_driver.rs (kernel/target/debug/deps/parallel_driver-2af26d6a5e052c55) + +running 4 tests +test stop_after_one_holds_for_an_independent_deterministic_batch ... ok +test backpressured_or_mismatched_lane_does_not_drop_a_later_dispatch ... ok +test crash_boundaries_resume_the_real_driver_with_one_effect_per_lane ... ok +test pause_before_second_independent_step_holds_the_driver_boundary ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.09s + + Running tests/placement_pins.rs (kernel/target/debug/deps/placement_pins-3ca6f44ccad88596) + +running 2 tests +test unsupported_local_pty_is_refused_before_an_earlier_step_can_run ... ok +test default_worker_pins_the_declared_worktree_base_commit_and_refuses_missing_source ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.05s + + Running tests/placement_routing.rs (kernel/target/debug/deps/placement_routing-11acbd842d4a637e) + +running 3 tests +test a_failed_routing_append_never_starts_or_dispatches_work ... ok +test crash_between_routing_and_start_does_not_redecide ... ok +test worker_retry_consumes_the_original_routing_fact ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s + + Running tests/routing_diagnostics.rs (kernel/target/debug/deps/routing_diagnostics-2e576930d54bdbaf) + +running 2 tests +test duplicate_routes_have_a_distinct_diagnostic_and_leave_the_original_fact_intact ... ok +test malformed_routes_name_the_same_field_at_append_replay_and_epoch_replay ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s + + Running tests/spec_review_routing.rs (kernel/target/debug/deps/spec_review_routing-a37130322a188264) + +running 4 tests +test attempt_scoped_route_is_rejected_at_append_and_replay ... ok +test malformed_epoch_routes_are_rejected_before_commit ... ok +test epoch_cannot_drop_or_replace_a_durable_route ... ok +test workspace_pin_peels_tags_and_refuses_non_commit_objects ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.07s + + Running tests/subscription_liveness.rs (kernel/target/debug/deps/subscription_liveness-14d251638e2a76c8) + +running 3 tests +test submit_event_upserts_subscription_row_and_sweep_flags_it_stale_after_budget ... ok +test stale_transition_is_journaled_as_subscription_stale_entry_in_the_last_known_run ... ok +test a_fresh_arrival_re_arms_the_latch_and_the_next_silence_can_stale_again ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s + + Running unittests src/lib.rs (kernel/target/debug/deps/relayflowd_core-81c19dffa1c9ee81) + +running 60 tests +test clock::tests::simulated_clock_is_explicitly_advanced ... ok +test journal::tests::memory_journal_assigns_sequences_and_rolls_epochs ... ok +test channel::tests::malformed_payloads_and_invalid_new_channel_appends_leave_state_unchanged ... ok +test channel::tests::forged_deliveries_and_acknowledgements_fail_closed ... ok +test channel::tests::send_retry_is_stable_and_conflicting_content_is_rejected ... ok +test channel::tests::delivery_replay_and_independent_acknowledged_offsets ... ok +test machine::parallel_tests::workspace_ancestor_and_descendant_paths_conflict_but_siblings_do_not ... ok +test machine::parallel_tests::external_ancestor_and_descendant_paths_conflict_but_siblings_do_not ... ok +test machine::tests::all_backing_off_steps_return_timers ... ok +test machine::parallel_tests::every_declared_mutable_surface_participates_in_conflict_selection ... ok +test machine::parallel_tests::machine_starts_every_runnable_step_in_authored_order ... ok +test machine::tests::every_reason_label_matches_its_serialized_form ... ok +test machine::tests::durable_cancel_request_outranks_crash_recovery ... ok +test machine::parallel_tests::crash_resume_preserves_each_parallel_lease_exactly_once ... ok +test machine::tests::cancel_request_closes_the_active_lease_before_the_terminal_fact ... ok +test machine::tests::machine_starts_runnable_step_with_stable_effect_key ... ok +test machine::parallel_tests::overlapping_agent_surfaces_are_serialized_in_authored_order ... ok +test machine::tests::repeated_cancel_request_is_idempotent ... ok +test machine::tests::inspect_recovery_injects_the_dirty_pin_completion_reason_and_tail ... ok +test machine::parallel_tests::parallel_lanes_do_not_cross_the_dependency_barrier_early ... ok +test machine::tests::successful_memo_is_never_scheduled_again ... ok +test machine::tests::crashed_attempt_does_not_consume_an_iteration ... ok +test machine::tests::verification_failure_schedules_a_durable_retry ... ok +test machine::tests::worker_reported_failure_without_detail_still_records_a_verification ... ok +test machine::parallel_tests::failed_run_drains_open_siblings_before_terminal_entry ... ok +test memory::tests::caps_compare_exact_decimals_and_each_token_dimension ... ok +test machine::tests::reset_recovery_dispatches_the_original_pinned_revision ... ok +test machine::tests::manual_recovery_parks_needs_human_and_never_redispatches ... ok +test retry::tests::jitter_is_repeatable_and_bounded ... ok +test machine::tests::every_failed_run_terminates_with_declared_completion_reasons ... ok +test machine::parallel_tests::disjoint_agent_lanes_merge_pins_in_either_completion_order ... ok +test spec::tests::a_misspelled_step_level_key_is_a_parse_error ... ok +test spec::tests::a_misspelled_verification_gate_key_is_a_parse_error_not_a_dropped_gate ... ok +test spec::tests::cycles_are_rejected ... ok +test spec::tests::preflight_data_is_fail_closed ... ok +test spec::tests::external_surface_paths_must_have_one_canonical_spelling ... ok +test schema::tests::in_document_uri_references_resolve_to_the_node_they_name ... ok +test schema::tests::refusal_names_the_cycle_it_found ... ok +test spec::tests::spec_version_is_semver_and_gated ... ok +test spec::tests::unknown_root_and_nested_fields_are_rejected ... ok +test spec::tests::workspace_mounts_and_worktrees_must_have_one_canonical_spelling ... ok +test spec::tests::zero_agent_flow_is_valid ... ok +test state::budget::tests::adds_costs_exactly_beyond_machine_decimal_precision ... ok +test state::budget::tests::overflow_and_malformed_cost_leave_total_unchanged ... ok +test state::tests::a_completion_that_omits_a_surface_does_not_drop_it_from_the_pin_chain ... ok +test state::tests::budget_decimal_strings_add_without_floats ... ok +test schema::tests::references_the_bound_leaves_opaque_are_refused_by_the_engine ... ok +test state::tests::end_pin_chain_is_enforced_and_a_broken_chain_is_a_hard_error ... ok +test state::tests::journal_replays_data_gate_verdict_without_rerunning_completed_code ... ok +test verify::tests::deterministic_output_requires_successful_exit_and_content ... ok +test verify::tests::an_unbounded_schema_in_a_journal_fails_its_gate_instead_of_aborting ... ok +test spec::tests::the_full_ladder_parses_in_the_one_dialect ... ok +test verify::tests::json_schema_is_a_control_gate ... ok +test schema::tests::a_property_named_ref_is_not_a_reference ... ok +test schema::tests::shared_declarations_and_boolean_schemas_are_validated ... ok +test schema::tests::every_accepted_corpus_schema_is_accepted ... ok +test schema::tests::every_refused_corpus_schema_compiles_but_is_refused_by_the_bound ... ok +test spec::tests::sdk_boundary_rejects_a_10_000_step_cycle_with_a_typed_error ... ok +test spec::tests::sdk_boundary_accepts_a_valid_10_000_step_reverse_chain ... ok +test schema::tests::deeply_nested_schemas_do_not_overflow_the_checker ... ok + +test result: ok. 60 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.59s + + Running tests/spec_parity.rs (kernel/target/debug/deps/spec_parity-2e346aa0d81e3bc8) + +running 9 tests +test step_memory_has_identical_canonical_bytes_and_hash ... ok +test the_kernel_parses_the_deterministic_rung_and_stamps_the_same_hash ... ok +test placement_requirements_have_identical_canonical_bytes_and_hash ... ok +test the_kernel_parses_the_event_triggered_spec_and_stamps_the_same_hash ... ok +test the_kernel_parses_the_rung_c_agent_spec_and_stamps_the_same_hash ... ok +test memory_declaration_acceptance_matches_the_sdk_corpus ... ok +test placement_declaration_acceptance_matches_the_sdk_corpus ... ok +test the_kernel_parses_the_sdk_compiled_spec_and_stamps_the_same_hash ... ok +test the_kernel_parses_the_rung_b_spec_and_stamps_the_same_hash ... ok + +test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s + + Running unittests src/lib.rs (kernel/target/debug/deps/relayflowd_journal-a18eeb9f7cf88775) + +running 28 tests +test registry::tests::a_pre_migration_registry_gains_boot_id_and_its_claims_are_repairable ... ok +test registry::tests::a_previous_boots_claim_with_no_run_is_repaired ... ok +test registry::tests::releasing_a_claim_lets_the_same_boot_retry ... ok +test registry::tests::a_registered_run_dedupes_across_boots ... ok +test registry::tests::a_same_boot_claim_with_no_run_yet_is_a_duplicate_not_wreckage ... ok +test registry::tests::registry_is_a_rebuildable_run_locator ... ok +test subscriptions::tests::last_run_for_subscription_returns_none_before_first_arrival ... ok +test registry::tests::releasing_is_scoped_to_the_claiming_run ... ok +test subscriptions::tests::detect_without_latch_stays_available_for_the_next_sweep ... ok +test subscriptions::tests::prune_sweep_claims_deletes_only_rows_older_than_cutoff ... ok +test subscriptions::tests::last_run_for_subscription_returns_the_lex_greatest_ulid_regardless_of_insertion ... ok +test subscriptions::tests::latch_is_a_no_op_if_a_fresh_event_arrived_between_detect_and_latch ... ok +test subscriptions::tests::sweep_does_not_re_emit_the_same_stale_row_on_a_later_tick ... ok +test subscriptions::tests::sweep_election_gives_the_first_caller_the_result_and_second_gets_empty ... ok +test subscriptions::tests::sweep_ignores_subscriptions_whose_silence_is_still_within_budget ... ok +test subscriptions::tests::sweep_marks_row_stale_when_silence_exceeds_budget ... ok +test subscriptions::tests::upsert_after_stale_re_arms_and_next_silence_can_re_emit ... ok +test subscriptions::tests::upsert_is_idempotent_across_bumps_and_preserves_event_type_updates ... ok +test tests::failed_commit_is_returned_not_swallowed ... ok +test tests::terminal_run_refuses_every_later_entry_atomically ... ok +test tests::effects_are_deduplicated_at_the_journal_boundary ... ok +test tests::append_is_durable_and_monotonic_after_reopen ... ok +test tests::rollover_is_atomic_scaffolding_for_epoch_resume ... ok +test tests::an_unconfirmed_election_is_reclaimed_by_the_next_attempt_not_treated_as_done ... ok +test channel::tests::stale_attempts_and_raw_forged_acknowledgements_cannot_change_offsets ... ok +test channel::tests::channels_cross_segment_boundaries_and_terminal_runs_reject_mutations ... ok +test channel::tests::failed_channel_writes_never_expose_delivery_or_advance_acknowledged_offset ... ok +test channel::tests::independent_connections_serialize_send_receive_and_acknowledgement ... ok + +test result: ok. 28 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.34s + + Doc-tests relayflowd + +running 0 tests + +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + Doc-tests relayflowd_core + +running 0 tests + +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + Doc-tests relayflowd_journal + +running 0 tests + +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + +exit_code=0 diff --git a/kernel/evidence/225/spec-review-kernel-tests.txt b/kernel/evidence/225/spec-review-kernel-tests.txt new file mode 100644 index 000000000..2ea962e1c --- /dev/null +++ b/kernel/evidence/225/spec-review-kernel-tests.txt @@ -0,0 +1,634 @@ +$ env RUSTC=/Users/khaliqgant/.rustup/toolchains/stable-aarch64-apple-darwin/bin/rustc /Users/khaliqgant/.rustup/toolchains/stable-aarch64-apple-darwin/bin/cargo test --manifest-path kernel/Cargo.toml --locked --offline --workspace + Compiling relayflowd-core v0.1.0 (/Users/khaliqgant/AgentWorkforce/flows-225-placement-wt/kernel/relayflowd-core) + Compiling relayflowd-journal v0.1.0 (/Users/khaliqgant/AgentWorkforce/flows-225-placement-wt/kernel/relayflowd-journal) + Compiling relayflowd v0.1.0 (/Users/khaliqgant/AgentWorkforce/flows-225-placement-wt/kernel/relayflowd) + Finished `test` profile [unoptimized + debuginfo] target(s) in 4.56s + Running unittests src/lib.rs (kernel/target/debug/deps/relayflowd-7cc6b6adaeba2a85) + +running 36 tests +test engine::remote::worker_failure_detail_tests::a_null_or_blank_output_yields_no_detail ... ok +test engine::remote::worker_failure_detail_tests::a_string_output_is_carried_verbatim_and_trimmed ... ok +test engine::remote::worker_failure_detail_tests::an_output_at_the_boundary_is_not_truncated ... ok +test engine::remote::worker_failure_detail_tests::a_non_string_output_is_rendered_rather_than_dropped ... ok +test engine::remote::worker_failure_detail_tests::truncation_does_not_split_a_multi_byte_char ... ok +test engine::boot_identity_tests::every_engine_in_this_process_shares_one_boot_id ... ok +test server::client::tests::resume_waits_while_the_heartbeat_renewed_lease_is_live ... ok +test server::liveness::tests::sweep_id_buckets_by_the_interval ... ok +test server::channels::tests::unknown_verb_never_falls_through_to_receive ... ok +test exec_det::tests::timeout_has_an_explicit_completion_reason ... ok +test exec_det::tests::captures_deterministic_output ... ok +test server::liveness::tests::sweep_pass_latches_after_journaling_and_next_bucket_is_empty ... ok +test server::tests::agent::contract::an_agent_worker_attaching_without_pins_is_refused_at_attach ... ok +test server::liveness::tests::sweep_pass_healthy_subscription_is_a_noop ... ok +test engine::wake::claim_guard_tests::a_guard_only_releases_its_own_run ... ok +test engine::wake::claim_guard_tests::a_disarmed_guard_leaves_the_claim_alone ... ok +test engine::wake::claim_guard_tests::a_panic_between_claim_and_register_still_releases ... ok +test server::tests::agent::contract::an_oversized_trajectory_tail_is_refused_at_step_complete ... ok +test engine::wake::claim_guard_tests::an_armed_guard_releases_the_claim_when_dropped ... ok +test server::tests::agent::contract::agent_without_a_compatible_worker_parks_without_starting ... ok +test server::tests::hello_enforces_protocol_version ... ok +test server::tests::agent::contract::an_agent_worker_missing_a_declared_surface_parks_the_run_instead_of_erroring ... ok +test server::tests::a_failed_disconnect_journal_append_is_retained_and_retried_not_dropped ... ok +test server::tests::agent::contract::an_llm_completion_claiming_an_effect_fails_closed_with_the_reason_journaled ... ok +test server::tests::run_resume_asks_the_registry_instead_of_treating_an_orphan_file_as_a_run ... ok +test server::tests::run_start_fails_closed_on_an_unknown_verification_key ... ok +test server::tests::agent::contract::a_replacement_worker_that_never_reported_the_pinned_surface_is_not_dispatched_to ... ok +test server::tests::agent::pins::reset_worker_reporting_a_revision_other_than_its_pin_fails_closed_as_worker_error ... ok +test server::tests::run_resume_refuses_a_journal_that_never_recorded_its_run ... ok +test server::tests::run_resume_adopts_a_real_journal_whose_registry_row_is_missing ... ok +test server::tests::agent::pins::consecutive_agent_steps_on_different_surfaces_each_start_from_their_own_pins ... ok +test server::tests::run_resume_refuses_a_valid_journal_that_belongs_to_another_run ... ok +test server::tests::stopped_heartbeats_past_the_deadline_journal_lease_expired_and_release_the_step ... ok +test exec_det::tests::timeout_kills_the_whole_process_group ... ok +test server::tests::agent::pins::a_replacement_worker_at_a_different_revision_is_not_dispatched_the_stale_pins ... ok +test server::tests::an_entry_appended_during_watch_registration_is_delivered_exactly_once ... ok + +test result: ok. 36 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.57s + + Running unittests src/main.rs (kernel/target/debug/deps/relayflowd-a3a150c1162c49ec) + +running 0 tests + +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + Running tests/crash_resume.rs (kernel/target/debug/deps/crash_resume-8980b58a474e7aba) + +running 40 tests +test concurrency::cancel_closes_the_lease_and_rejects_a_late_completion ... ok +test agent::rung_c_sigkill_after_final_effect_replays_results_without_redispatch ... ok +test agent::resume_without_a_worker_parks_immediately_instead_of_timing_out ... ok +test concurrency::cancel_and_completion_race_has_one_terminal_fact ... ok +test channels::channels_reject_foreign_workers_stale_attempts_and_invalid_acknowledgements ... ok +test agent::rung_c_sigkill_between_agent_completion_and_final_effect_memoizes_the_agent ... ok +test concurrency::live_resume_leaves_an_active_lease_running ... ok +test concurrency::concurrent_resumes_lease_exactly_one_attempt ... ok +test concurrency::run_start_dispatches_every_independent_lane_before_any_completion ... ok +test agent::rung_c_crash_between_effect_election_and_the_provider_call_performs_it_exactly_once ... ok +test agent::rung_c_reset_sigkill_mid_edit_restores_pins_dedupes_effect_and_explains_attempts ... ok +test llm::serve_plumbs_watch_events_and_replayable_stream_verbs ... ok +test llm::sigkill_after_the_final_rung_b_effect_resumes_without_redispatching_llm ... ok +test llm::completed_llm_output_is_memoized_when_serve_dies_during_the_next_step ... ok +test concurrency::server_restart_recovers_every_parallel_lease_without_duplicate_success ... ok +test llm::failing_llm_verification_schedules_a_durable_retry_and_succeeds ... ok +test llm::llm_verification_exhaustion_is_a_declared_failure_kind ... ok +test agent::rung_c_sigkill_boundaries_resume_only_unfinished_steps_via_real_cli ... ok +test llm::sigkill_under_serve_mid_llm_releases_the_lease_and_finishes_via_cli_resume ... ok +test pin_projection::rejected_completion_cannot_forge_inspect_retry_pins_over_the_real_socket ... ok +test placement::declared_placement_keeps_one_source_tree_across_resume ... ok +test llm::worker_killed_while_holding_a_lease_is_explained_and_released_on_cli_resume ... ok +test memory::memory_sigkill_after_injection_replays_pack_and_charges_it_once ... ok +test parallel_lifecycle::overlapping_agent_conflict_survives_server_crash_and_resume ... ok +test placement::sigkill_before_first_step_preserves_the_submitted_workspace ... ok +test protocol_admission::every_mutating_run_verb_refuses_terminal_before_changing_state ... ok +test placement::sigkill_mid_step_keeps_the_route_and_source_tree ... ok +test sigkill_after_cancel_request_resumes_to_one_canceled_fact ... ok +test sigkill_mid_step_replaces_and_explains_the_dead_attempt ... ok +test parallel_lifecycle::terminal_failure_drains_or_explains_every_live_sibling ... ok +test parallel_lifecycle::overlapping_agent_lanes_serialize_while_disjoint_lanes_merge_in_either_order ... ok +test surface_identity::aliases_are_rejected_and_external_ancestors_serialize_over_real_sockets ... ok +test llm::sigkill_sweep_covers_before_and_between_the_rung_b_steps ... ok +test worker_capacity::two_workers_receive_a_deterministic_fair_capacity_bounded_batch ... ok +test workspace_identity::workspace_aliases_are_refused_and_canonical_subtrees_serialize_over_real_sockets ... ok +test sigkill_under_serve_resumes_the_socket_started_run ... ok +test channels::channels_sigkill_resume_redelivers_unacked_messages_with_exactly_once_effects ... ok +test worker_capacity::default_capacity_one_reopens_only_after_durable_completion_or_crash ... ok +test sigkill_sweep_covers_every_hello_step_boundary ... ok +test parallel_lifecycle::renewed_parallel_leases_survive_the_original_grant_and_remain_distinct ... ok + +test result: ok. 40 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 38.02s + + Running tests/event_wake.rs (kernel/target/debug/deps/event_wake-694cba08f5e199b7) + +running 3 tests +test matching_event_wakes_once_with_fresh_context ... ok +test a_resumed_run_dispatches_the_original_wake_context ... ok +test two_racing_deliveries_of_one_event_produce_exactly_one_run ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s + + Running tests/hn_monitor_integration.rs (kernel/target/debug/deps/hn_monitor_integration-712d52524bb3367c) + +running 1 test +test hn_story_event_wakes_monitor_once_with_story_context ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s + + Running tests/invalid_schema_preflight.rs (kernel/target/debug/deps/invalid_schema_preflight-c3ac725a2193ec68) + +running 3 tests +test invalid_json_schema_is_refused_before_journal_or_command ... ok +test unbounded_json_schema_is_refused_before_journal_or_command ... ok +test legitimately_recursive_json_schema_still_starts ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 7.08s + + Running tests/memory.rs (kernel/target/debug/deps/memory-a5944bc39239e789) + +running 5 tests +test rejected_journal_fact_releases_reservation_and_never_dispatches ... ok +test llm_dispatch_receives_same_pack_after_resume_without_provider ... ok +test replay_and_resume_need_no_provider_and_script_receives_recorded_pack ... ok +test over_budget_and_provider_errors_fail_without_dispatch_or_charge ... ok +test semantic_retry_reuses_memory_without_a_second_charge ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s + + Running tests/memory_epoch.rs (kernel/target/debug/deps/memory_epoch-53e5ca10ed87e9cb) + +running 1 test +test epoch_carries_pack_and_exact_charge_and_refuses_duplicate_injection ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s + + Running tests/parallel_driver.rs (kernel/target/debug/deps/parallel_driver-2af26d6a5e052c55) + +running 4 tests +test stop_after_one_holds_for_an_independent_deterministic_batch ... ok +test backpressured_or_mismatched_lane_does_not_drop_a_later_dispatch ... ok +test crash_boundaries_resume_the_real_driver_with_one_effect_per_lane ... ok +test pause_before_second_independent_step_holds_the_driver_boundary ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.12s + + Running tests/placement_pins.rs (kernel/target/debug/deps/placement_pins-3ca6f44ccad88596) + +running 2 tests +test unsupported_local_pty_is_refused_before_an_earlier_step_can_run ... ok +test default_worker_pins_the_declared_worktree_base_commit_and_refuses_missing_source ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.06s + + Running tests/placement_routing.rs (kernel/target/debug/deps/placement_routing-11acbd842d4a637e) + +running 3 tests +test a_failed_routing_append_never_starts_or_dispatches_work ... ok +test crash_between_routing_and_start_does_not_redecide ... ok +test worker_retry_consumes_the_original_routing_fact ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s + + Running tests/routing_diagnostics.rs (kernel/target/debug/deps/routing_diagnostics-2e576930d54bdbaf) + +running 2 tests +test duplicate_routes_have_a_distinct_diagnostic_and_leave_the_original_fact_intact ... ok +test malformed_routes_name_the_same_field_at_append_replay_and_epoch_replay ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s + + Running tests/spec_review_routing.rs (kernel/target/debug/deps/spec_review_routing-a37130322a188264) + +running 4 tests +test attempt_scoped_route_is_rejected_at_append_and_replay ... ok +test malformed_epoch_routes_are_rejected_before_commit ... ok +test epoch_cannot_drop_or_replace_a_durable_route ... ok +test workspace_pin_peels_tags_and_refuses_non_commit_objects ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.08s + + Running tests/subscription_liveness.rs (kernel/target/debug/deps/subscription_liveness-14d251638e2a76c8) + +running 3 tests +test submit_event_upserts_subscription_row_and_sweep_flags_it_stale_after_budget ... ok +test stale_transition_is_journaled_as_subscription_stale_entry_in_the_last_known_run ... ok +test a_fresh_arrival_re_arms_the_latch_and_the_next_silence_can_stale_again ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s + + Running unittests src/lib.rs (kernel/target/debug/deps/relayflowd_core-81c19dffa1c9ee81) + +running 60 tests +test clock::tests::simulated_clock_is_explicitly_advanced ... ok +test channel::tests::send_retry_is_stable_and_conflicting_content_is_rejected ... ok +test channel::tests::forged_deliveries_and_acknowledgements_fail_closed ... ok +test journal::tests::memory_journal_assigns_sequences_and_rolls_epochs ... ok +test channel::tests::malformed_payloads_and_invalid_new_channel_appends_leave_state_unchanged ... ok +test channel::tests::delivery_replay_and_independent_acknowledged_offsets ... ok +test machine::parallel_tests::every_declared_mutable_surface_participates_in_conflict_selection ... ok +test machine::parallel_tests::machine_starts_every_runnable_step_in_authored_order ... ok +test machine::parallel_tests::workspace_ancestor_and_descendant_paths_conflict_but_siblings_do_not ... ok +test machine::tests::all_backing_off_steps_return_timers ... ok +test machine::parallel_tests::external_ancestor_and_descendant_paths_conflict_but_siblings_do_not ... ok +test machine::parallel_tests::parallel_lanes_do_not_cross_the_dependency_barrier_early ... ok +test machine::parallel_tests::overlapping_agent_surfaces_are_serialized_in_authored_order ... ok +test machine::tests::every_reason_label_matches_its_serialized_form ... ok +test machine::tests::cancel_request_closes_the_active_lease_before_the_terminal_fact ... ok +test machine::tests::machine_starts_runnable_step_with_stable_effect_key ... ok +test machine::tests::durable_cancel_request_outranks_crash_recovery ... ok +test machine::parallel_tests::failed_run_drains_open_siblings_before_terminal_entry ... ok +test machine::tests::repeated_cancel_request_is_idempotent ... ok +test machine::tests::successful_memo_is_never_scheduled_again ... ok +test machine::parallel_tests::crash_resume_preserves_each_parallel_lease_exactly_once ... ok +test machine::parallel_tests::disjoint_agent_lanes_merge_pins_in_either_completion_order ... ok +test retry::tests::jitter_is_repeatable_and_bounded ... ok +test machine::tests::crashed_attempt_does_not_consume_an_iteration ... ok +test memory::tests::caps_compare_exact_decimals_and_each_token_dimension ... ok +test machine::tests::verification_failure_schedules_a_durable_retry ... ok +test machine::tests::worker_reported_failure_without_detail_still_records_a_verification ... ok +test machine::tests::every_failed_run_terminates_with_declared_completion_reasons ... ok +test machine::tests::reset_recovery_dispatches_the_original_pinned_revision ... ok +test machine::tests::inspect_recovery_injects_the_dirty_pin_completion_reason_and_tail ... ok +test machine::tests::manual_recovery_parks_needs_human_and_never_redispatches ... ok +test spec::tests::a_misspelled_step_level_key_is_a_parse_error ... ok +test spec::tests::a_misspelled_verification_gate_key_is_a_parse_error_not_a_dropped_gate ... ok +test spec::tests::cycles_are_rejected ... ok +test schema::tests::in_document_uri_references_resolve_to_the_node_they_name ... ok +test schema::tests::refusal_names_the_cycle_it_found ... ok +test spec::tests::preflight_data_is_fail_closed ... ok +test spec::tests::external_surface_paths_must_have_one_canonical_spelling ... ok +test spec::tests::spec_version_is_semver_and_gated ... ok +test spec::tests::unknown_root_and_nested_fields_are_rejected ... ok +test schema::tests::references_the_bound_leaves_opaque_are_refused_by_the_engine ... ok +test spec::tests::zero_agent_flow_is_valid ... ok +test spec::tests::workspace_mounts_and_worktrees_must_have_one_canonical_spelling ... ok +test state::budget::tests::adds_costs_exactly_beyond_machine_decimal_precision ... ok +test state::budget::tests::overflow_and_malformed_cost_leave_total_unchanged ... ok +test state::tests::budget_decimal_strings_add_without_floats ... ok +test state::tests::a_completion_that_omits_a_surface_does_not_drop_it_from_the_pin_chain ... ok +test state::tests::end_pin_chain_is_enforced_and_a_broken_chain_is_a_hard_error ... ok +test state::tests::journal_replays_data_gate_verdict_without_rerunning_completed_code ... ok +test verify::tests::deterministic_output_requires_successful_exit_and_content ... ok +test verify::tests::an_unbounded_schema_in_a_journal_fails_its_gate_instead_of_aborting ... ok +test spec::tests::the_full_ladder_parses_in_the_one_dialect ... ok +test schema::tests::a_property_named_ref_is_not_a_reference ... ok +test verify::tests::json_schema_is_a_control_gate ... ok +test schema::tests::shared_declarations_and_boolean_schemas_are_validated ... ok +test schema::tests::every_accepted_corpus_schema_is_accepted ... ok +test schema::tests::every_refused_corpus_schema_compiles_but_is_refused_by_the_bound ... ok +test spec::tests::sdk_boundary_rejects_a_10_000_step_cycle_with_a_typed_error ... ok +test spec::tests::sdk_boundary_accepts_a_valid_10_000_step_reverse_chain ... ok +test schema::tests::deeply_nested_schemas_do_not_overflow_the_checker ... ok + +test result: ok. 60 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.56s + + Running tests/spec_parity.rs (kernel/target/debug/deps/spec_parity-2e346aa0d81e3bc8) + +running 9 tests +test placement_requirements_have_identical_canonical_bytes_and_hash ... ok +test the_kernel_parses_the_event_triggered_spec_and_stamps_the_same_hash ... ok +test step_memory_has_identical_canonical_bytes_and_hash ... ok +test the_kernel_parses_the_deterministic_rung_and_stamps_the_same_hash ... ok +test the_kernel_parses_the_rung_c_agent_spec_and_stamps_the_same_hash ... ok +test memory_declaration_acceptance_matches_the_sdk_corpus ... ok +test placement_declaration_acceptance_matches_the_sdk_corpus ... ok +test the_kernel_parses_the_sdk_compiled_spec_and_stamps_the_same_hash ... ok +test the_kernel_parses_the_rung_b_spec_and_stamps_the_same_hash ... ok + +test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s + + Running unittests src/lib.rs (kernel/target/debug/deps/relayflowd_journal-a18eeb9f7cf88775) + +running 28 tests +test registry::tests::a_previous_boots_claim_with_no_run_is_repaired ... ok +test registry::tests::a_same_boot_claim_with_no_run_yet_is_a_duplicate_not_wreckage ... ok +test registry::tests::registry_is_a_rebuildable_run_locator ... ok +test registry::tests::releasing_a_claim_lets_the_same_boot_retry ... ok +test registry::tests::a_registered_run_dedupes_across_boots ... ok +test registry::tests::a_pre_migration_registry_gains_boot_id_and_its_claims_are_repairable ... ok +test registry::tests::releasing_is_scoped_to_the_claiming_run ... ok +test channel::tests::stale_attempts_and_raw_forged_acknowledgements_cannot_change_offsets ... ok +test subscriptions::tests::last_run_for_subscription_returns_none_before_first_arrival ... ok +test subscriptions::tests::detect_without_latch_stays_available_for_the_next_sweep ... ok +test subscriptions::tests::last_run_for_subscription_returns_the_lex_greatest_ulid_regardless_of_insertion ... ok +test subscriptions::tests::latch_is_a_no_op_if_a_fresh_event_arrived_between_detect_and_latch ... ok +test subscriptions::tests::prune_sweep_claims_deletes_only_rows_older_than_cutoff ... ok +test channel::tests::channels_cross_segment_boundaries_and_terminal_runs_reject_mutations ... ok +test subscriptions::tests::sweep_does_not_re_emit_the_same_stale_row_on_a_later_tick ... ok +test subscriptions::tests::sweep_ignores_subscriptions_whose_silence_is_still_within_budget ... ok +test subscriptions::tests::sweep_election_gives_the_first_caller_the_result_and_second_gets_empty ... ok +test subscriptions::tests::upsert_is_idempotent_across_bumps_and_preserves_event_type_updates ... ok +test channel::tests::failed_channel_writes_never_expose_delivery_or_advance_acknowledged_offset ... ok +test tests::append_is_durable_and_monotonic_after_reopen ... ok +test tests::failed_commit_is_returned_not_swallowed ... ok +test tests::rollover_is_atomic_scaffolding_for_epoch_resume ... ok +test tests::terminal_run_refuses_every_later_entry_atomically ... ok +test subscriptions::tests::sweep_marks_row_stale_when_silence_exceeds_budget ... ok +test subscriptions::tests::upsert_after_stale_re_arms_and_next_silence_can_re_emit ... ok +test tests::effects_are_deduplicated_at_the_journal_boundary ... ok +test tests::an_unconfirmed_election_is_reclaimed_by_the_next_attempt_not_treated_as_done ... ok +test channel::tests::independent_connections_serialize_send_receive_and_acknowledgement ... ok + +test result: ok. 28 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.16s + + Doc-tests relayflowd +error[E0514]: found crate `anyhow` compiled by an incompatible version of rustc + --> relayflowd/src/engine/drive.rs:3:5 + | +3 | use anyhow::{Context, Result, bail}; + | ^^^^^^ + | + = note: the following crate versions were found: + crate `anyhow` compiled by rustc 1.98.0 (88d9e12ae 2026-08-18): /Users/khaliqgant/AgentWorkforce/flows-225-placement-wt/kernel/target/debug/deps/libanyhow-db1f6a292fd18298.rlib + = help: please recompile that crate using this compiler (rustc 1.94.0 (4a4ef493e 2026-03-02)) (consider running `cargo clean` first) + +error[E0514]: found crate `relayflowd_core` compiled by an incompatible version of rustc + --> relayflowd/src/engine/drive.rs:4:5 + | +4 | use relayflowd_core::{ + | ^^^^^^^^^^^^^^^ + | + = note: the following crate versions were found: + crate `relayflowd_core` compiled by rustc 1.98.0 (88d9e12ae 2026-08-18): /Users/khaliqgant/AgentWorkforce/flows-225-placement-wt/kernel/target/debug/deps/librelayflowd_core-d5097a4d719260fb.rlib + = help: please recompile that crate using this compiler (rustc 1.94.0 (4a4ef493e 2026-03-02)) (consider running `cargo clean` first) + +error[E0514]: found crate `relayflowd_journal` compiled by an incompatible version of rustc + --> relayflowd/src/engine/drive.rs:8:5 + | +8 | use relayflowd_journal::SqliteJournal; + | ^^^^^^^^^^^^^^^^^^ + | + = note: the following crate versions were found: + crate `relayflowd_journal` compiled by rustc 1.98.0 (88d9e12ae 2026-08-18): /Users/khaliqgant/AgentWorkforce/flows-225-placement-wt/kernel/target/debug/deps/librelayflowd_journal-2ab3e7006dceb7ea.rlib + = help: please recompile that crate using this compiler (rustc 1.94.0 (4a4ef493e 2026-03-02)) (consider running `cargo clean` first) + +error[E0514]: found crate `serde_json` compiled by an incompatible version of rustc + --> relayflowd/src/exec_det.rs:9:5 + | +9 | use serde_json::json; + | ^^^^^^^^^^ + | + = note: the following crate versions were found: + crate `serde_json` compiled by rustc 1.98.0 (88d9e12ae 2026-08-18): /Users/khaliqgant/AgentWorkforce/flows-225-placement-wt/kernel/target/debug/deps/libserde_json-86e149e43221e071.rlib + = help: please recompile that crate using this compiler (rustc 1.94.0 (4a4ef493e 2026-03-02)) (consider running `cargo clean` first) + +error[E0514]: found crate `wait_timeout` compiled by an incompatible version of rustc + --> relayflowd/src/exec_det.rs:10:5 + | +10 | use wait_timeout::ChildExt; + | ^^^^^^^^^^^^ + | + = note: the following crate versions were found: + crate `wait_timeout` compiled by rustc 1.98.0 (88d9e12ae 2026-08-18): /Users/khaliqgant/AgentWorkforce/flows-225-placement-wt/kernel/target/debug/deps/libwait_timeout-b434de2ecc7c1a45.rlib + = help: please recompile that crate using this compiler (rustc 1.94.0 (4a4ef493e 2026-03-02)) (consider running `cargo clean` first) + +error[E0514]: found crate `serde` compiled by an incompatible version of rustc + --> relayflowd/src/server/protocol.rs:6:5 + | +6 | use serde::{Serialize, de::DeserializeOwned}; + | ^^^^^ + | + = note: the following crate versions were found: + crate `serde` compiled by rustc 1.98.0 (88d9e12ae 2026-08-18): /Users/khaliqgant/AgentWorkforce/flows-225-placement-wt/kernel/target/debug/deps/libserde-331050ca9df51783.rlib + = help: please recompile that crate using this compiler (rustc 1.94.0 (4a4ef493e 2026-03-02)) (consider running `cargo clean` first) + +error[E0514]: found crate `sha2` compiled by an incompatible version of rustc + --> relayflowd/src/engine.rs:12:5 + | +12 | use sha2::{Digest, Sha256}; + | ^^^^ + | + = note: the following crate versions were found: + crate `sha2` compiled by rustc 1.98.0 (88d9e12ae 2026-08-18): /Users/khaliqgant/AgentWorkforce/flows-225-placement-wt/kernel/target/debug/deps/libsha2-d085d145be19940e.rlib + = help: please recompile that crate using this compiler (rustc 1.94.0 (4a4ef493e 2026-03-02)) (consider running `cargo clean` first) + +error[E0514]: found crate `ulid` compiled by an incompatible version of rustc + --> relayflowd/src/engine.rs:13:5 + | +13 | use ulid::Ulid; + | ^^^^ + | + = note: the following crate versions were found: + crate `ulid` compiled by rustc 1.98.0 (88d9e12ae 2026-08-18): /Users/khaliqgant/AgentWorkforce/flows-225-placement-wt/kernel/target/debug/deps/libulid-d636a874fa77b528.rlib + = help: please recompile that crate using this compiler (rustc 1.94.0 (4a4ef493e 2026-03-02)) (consider running `cargo clean` first) + +error[E0432]: unresolved imports `anyhow::Context`, `anyhow::Result`, `anyhow::bail`, `anyhow::Context`, `anyhow::Result`, `anyhow::bail` + --> relayflowd/src/engine.rs:6:14 + | +6 | use anyhow::{Context, Result, anyhow, bail}; + | ^^^^^^^ ^^^^^^ ^^^^ + | + ::: relayflowd/src/engine/remote.rs:1:14 + | +1 | use anyhow::{Context, Result, anyhow, bail}; + | ^^^^^^^ ^^^^^^ ^^^^ + +error: cannot find attribute `serde` in this scope + --> relayflowd/src/worker.rs:14:7 + | +14 | #[serde(skip_serializing_if = "Option::is_none")] + | ^^^^^ + +error: cannot find attribute `serde` in this scope + --> relayflowd/src/worker.rs:20:7 + | +20 | #[serde(skip_serializing_if = "Option::is_none")] + | ^^^^^ + +error: cannot find attribute `serde` in this scope + --> relayflowd/src/worker.rs:22:7 + | +22 | #[serde(skip_serializing_if = "Option::is_none")] + | ^^^^^ + +error: cannot find attribute `serde` in this scope + --> relayflowd/src/server/wire.rs:152:3 + | +152 | #[serde(deny_unknown_fields)] + | ^^^^^ + +error: cannot find attribute `serde` in this scope + --> relayflowd/src/server/wire.rs:143:3 + | +143 | #[serde(deny_unknown_fields)] + | ^^^^^ + +error: cannot find attribute `serde` in this scope + --> relayflowd/src/server/wire.rs:135:3 + | +135 | #[serde(deny_unknown_fields)] + | ^^^^^ + +error: cannot find attribute `serde` in this scope + --> relayflowd/src/server/wire.rs:128:3 + | +128 | #[serde(deny_unknown_fields)] + | ^^^^^ + +error: cannot find attribute `serde` in this scope + --> relayflowd/src/server/wire.rs:120:3 + | +120 | #[serde(deny_unknown_fields)] + | ^^^^^ + +error: cannot find attribute `serde` in this scope + --> relayflowd/src/server/wire.rs:110:3 + | +110 | #[serde(deny_unknown_fields)] + | ^^^^^ + +error: cannot find attribute `serde` in this scope + --> relayflowd/src/server/wire.rs:98:3 + | +98 | #[serde(deny_unknown_fields)] + | ^^^^^ + +error: cannot find attribute `serde` in this scope + --> relayflowd/src/server/wire.rs:75:3 + | +75 | #[serde(deny_unknown_fields)] + | ^^^^^ + +error: cannot find attribute `serde` in this scope + --> relayflowd/src/server/wire.rs:81:7 + | +81 | #[serde(rename = "completionReason")] + | ^^^^^ + +error: cannot find attribute `serde` in this scope + --> relayflowd/src/server/wire.rs:83:7 + | +83 | #[serde(default)] + | ^^^^^ + +error: cannot find attribute `serde` in this scope + --> relayflowd/src/server/wire.rs:85:7 + | +85 | #[serde(default)] + | ^^^^^ + +error: cannot find attribute `serde` in this scope + --> relayflowd/src/server/wire.rs:87:7 + | +87 | #[serde(default)] + | ^^^^^ + +error: cannot find attribute `serde` in this scope + --> relayflowd/src/server/wire.rs:89:7 + | +89 | #[serde(default)] + | ^^^^^ + +error: cannot find attribute `serde` in this scope + --> relayflowd/src/server/wire.rs:91:7 + | +91 | #[serde(default)] + | ^^^^^ + +error: cannot find attribute `serde` in this scope + --> relayflowd/src/server/wire.rs:93:7 + | +93 | #[serde(default)] + | ^^^^^ + +error: cannot find attribute `serde` in this scope + --> relayflowd/src/server/wire.rs:66:3 + | +66 | #[serde(deny_unknown_fields)] + | ^^^^^ + +error: cannot find attribute `serde` in this scope + --> relayflowd/src/server/wire.rs:51:3 + | +51 | #[serde(deny_unknown_fields)] + | ^^^^^ + +error: cannot find attribute `serde` in this scope + --> relayflowd/src/server/wire.rs:55:7 + | +55 | #[serde(default = "default_worker_capacity")] + | ^^^^^ + +error: cannot find attribute `serde` in this scope + --> relayflowd/src/server/wire.rs:57:7 + | +57 | #[serde(default)] + | ^^^^^ + +error: cannot find attribute `serde` in this scope + --> relayflowd/src/server/wire.rs:45:3 + | +45 | #[serde(deny_unknown_fields)] + | ^^^^^ + +error: cannot find attribute `serde` in this scope + --> relayflowd/src/server/wire.rs:39:3 + | +39 | #[serde(deny_unknown_fields)] + | ^^^^^ + +error: cannot find attribute `serde` in this scope + --> relayflowd/src/server/wire.rs:31:3 + | +31 | #[serde(deny_unknown_fields)] + | ^^^^^ + +error: cannot find attribute `serde` in this scope + --> relayflowd/src/server/wire.rs:18:7 + | +18 | #[serde(skip_serializing_if = "Option::is_none")] + | ^^^^^ + +error: cannot find attribute `serde` in this scope + --> relayflowd/src/server/wire.rs:20:7 + | +20 | #[serde(skip_serializing_if = "Option::is_none")] + | ^^^^^ + +error: cannot find attribute `serde` in this scope + --> relayflowd/src/server/wire.rs:6:3 + | +6 | #[serde(deny_unknown_fields)] + | ^^^^^ + +error: cannot find attribute `serde` in this scope + --> relayflowd/src/server/wire.rs:10:7 + | +10 | #[serde(default)] + | ^^^^^ + +error: cannot find attribute `serde` in this scope + --> relayflowd/src/server/channels.rs:17:3 + | +17 | #[serde(deny_unknown_fields)] + | ^^^^^ + +error: cannot find attribute `serde` in this scope + --> relayflowd/src/server/channels.rs:25:7 + | +25 | #[serde(default)] + | ^^^^^ + +error: cannot find attribute `serde` in this scope + --> relayflowd/src/engine/model.rs:42:3 + | +42 | #[serde(rename_all = "snake_case")] + | ^^^^^ + +error: cannot find attribute `serde` in this scope + --> relayflowd/src/engine/model.rs:34:7 + | +34 | #[serde(rename = "type")] + | ^^^^^ + +error: cannot find attribute `serde` in this scope + --> relayflowd/src/engine/model.rs:37:7 + | +37 | #[serde(skip_serializing_if = "Option::is_none")] + | ^^^^^ + +error: cannot find attribute `serde` in this scope + --> relayflowd/src/engine/model.rs:7:3 + | +7 | #[serde(rename_all = "snake_case")] + | ^^^^^ + +error[E0514]: found crate `libc` compiled by an incompatible version of rustc + --> relayflowd/src/exec_det.rs:121:13 + | +121 | libc::kill(-pid, libc::SIGKILL); + | ^^^^ + | + = note: the following crate versions were found: + crate `libc` compiled by rustc 1.98.0 (88d9e12ae 2026-08-18): /Users/khaliqgant/AgentWorkforce/flows-225-placement-wt/kernel/target/debug/deps/liblibc-daad9fe4d12f6b51.rlib + = help: please recompile that crate using this compiler (rustc 1.94.0 (4a4ef493e 2026-03-02)) (consider running `cargo clean` first) + +error: aborting due to 45 previous errors + +Some errors have detailed explanations: E0432, E0514. +For more information about an error, try `rustc --explain E0432`. +error: doctest failed, to rerun pass `-p relayflowd --doc` + +Caused by: + process didn't exit successfully: `rustdoc --edition=2024 --crate-type lib --color auto --crate-name relayflowd --test relayflowd/src/lib.rs --test-run-directory /Users/khaliqgant/AgentWorkforce/flows-225-placement-wt/kernel/relayflowd -L native=/Users/khaliqgant/AgentWorkforce/flows-225-placement-wt/kernel/target/debug/build/libsqlite3-sys-6b76e21bfd9356be/out --extern anyhow=/Users/khaliqgant/AgentWorkforce/flows-225-placement-wt/kernel/target/debug/deps/libanyhow-db1f6a292fd18298.rlib --extern clap=/Users/khaliqgant/AgentWorkforce/flows-225-placement-wt/kernel/target/debug/deps/libclap-9d3f04c2650b1190.rlib --extern libc=/Users/khaliqgant/AgentWorkforce/flows-225-placement-wt/kernel/target/debug/deps/liblibc-daad9fe4d12f6b51.rlib --extern relayflowd=/Users/khaliqgant/AgentWorkforce/flows-225-placement-wt/kernel/target/debug/deps/librelayflowd-d13be38a03485bdc.rlib --extern relayflowd_core=/Users/khaliqgant/AgentWorkforce/flows-225-placement-wt/kernel/target/debug/deps/librelayflowd_core-d5097a4d719260fb.rlib --extern relayflowd_journal=/Users/khaliqgant/AgentWorkforce/flows-225-placement-wt/kernel/target/debug/deps/librelayflowd_journal-2ab3e7006dceb7ea.rlib --extern rusqlite=/Users/khaliqgant/AgentWorkforce/flows-225-placement-wt/kernel/target/debug/deps/librusqlite-93cf743320caa29a.rlib --extern serde=/Users/khaliqgant/AgentWorkforce/flows-225-placement-wt/kernel/target/debug/deps/libserde-331050ca9df51783.rlib --extern serde_json=/Users/khaliqgant/AgentWorkforce/flows-225-placement-wt/kernel/target/debug/deps/libserde_json-86e149e43221e071.rlib --extern sha2=/Users/khaliqgant/AgentWorkforce/flows-225-placement-wt/kernel/target/debug/deps/libsha2-d085d145be19940e.rlib --extern tempfile=/Users/khaliqgant/AgentWorkforce/flows-225-placement-wt/kernel/target/debug/deps/libtempfile-ca1a264b2b376e63.rlib --extern ulid=/Users/khaliqgant/AgentWorkforce/flows-225-placement-wt/kernel/target/debug/deps/libulid-d636a874fa77b528.rlib --extern wait_timeout=/Users/khaliqgant/AgentWorkforce/flows-225-placement-wt/kernel/target/debug/deps/libwait_timeout-b434de2ecc7c1a45.rlib -L dependency=/Users/khaliqgant/AgentWorkforce/flows-225-placement-wt/kernel/target/debug/deps -C embed-bitcode=no --check-cfg 'cfg(docsrs,test)' --check-cfg 'cfg(feature, values())' --error-format human` (exit status: 1) +note: test exited abnormally; to see the full output pass --no-capture to the harness. + +exit_code=1 diff --git a/kernel/evidence/225/spec-review-regressions-after.txt b/kernel/evidence/225/spec-review-regressions-after.txt new file mode 100644 index 000000000..51fdc9d41 --- /dev/null +++ b/kernel/evidence/225/spec-review-regressions-after.txt @@ -0,0 +1,17 @@ +$ env RUSTC=/Users/khaliqgant/.rustup/toolchains/stable-aarch64-apple-darwin/bin/rustc /Users/khaliqgant/.rustup/toolchains/stable-aarch64-apple-darwin/bin/cargo test --manifest-path kernel/Cargo.toml --locked --offline --test spec_review_routing + Compiling relayflowd-core v0.1.0 (/Users/khaliqgant/AgentWorkforce/flows-225-placement-wt/kernel/relayflowd-core) + Compiling relayflowd-journal v0.1.0 (/Users/khaliqgant/AgentWorkforce/flows-225-placement-wt/kernel/relayflowd-journal) + Compiling relayflowd v0.1.0 (/Users/khaliqgant/AgentWorkforce/flows-225-placement-wt/kernel/relayflowd) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.99s + Running tests/spec_review_routing.rs (kernel/target/debug/deps/spec_review_routing-a37130322a188264) + +running 4 tests +test attempt_scoped_route_is_rejected_at_append_and_replay ... ok +test malformed_epoch_routes_are_rejected_before_commit ... ok +test epoch_cannot_drop_or_replace_a_durable_route ... ok +test workspace_pin_peels_tags_and_refuses_non_commit_objects ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.10s + + +exit_code=0 diff --git a/kernel/evidence/225/spec-review-regressions-before.txt b/kernel/evidence/225/spec-review-regressions-before.txt new file mode 100644 index 000000000..f3bf8f84c --- /dev/null +++ b/kernel/evidence/225/spec-review-regressions-before.txt @@ -0,0 +1,177 @@ +$ env RUSTC=/Users/khaliqgant/.rustup/toolchains/stable-aarch64-apple-darwin/bin/rustc /Users/khaliqgant/.rustup/toolchains/stable-aarch64-apple-darwin/bin/cargo test --manifest-path kernel/Cargo.toml --locked --offline --test spec_review_routing + Compiling proc-macro2 v1.0.107 + Compiling unicode-ident v1.0.24 + Compiling quote v1.0.47 + Compiling libc v0.2.189 + Compiling cfg-if v1.0.4 + Compiling stable_deref_trait v1.2.1 + Compiling version_check v0.9.5 + Compiling autocfg v1.5.1 + Compiling serde_core v1.0.229 + Compiling zerocopy v0.8.56 + Compiling getrandom v0.3.4 + Compiling serde v1.0.229 + Compiling smallvec v1.15.2 + Compiling memchr v2.8.3 + Compiling num-traits v0.2.19 + Compiling writeable v0.6.4 + Compiling litemap v0.8.3 + Compiling generic-array v0.14.7 + Compiling icu_properties_data v2.3.0 + Compiling icu_normalizer_data v2.3.0 + Compiling utf8_iter v1.0.4 + Compiling syn v3.0.4 + Compiling syn v2.0.119 + Compiling synstructure v0.13.2 + Compiling typenum v1.20.1 + Compiling parking_lot_core v0.9.12 + Compiling zerofrom-derive v0.1.7 + Compiling yoke-derive v0.8.2 + Compiling num-integer v0.1.47 + Compiling zmij v1.0.23 + Compiling ref-cast v1.0.27 + Compiling num-bigint v0.4.8 + Compiling zerofrom v0.1.8 + Compiling aho-corasick v1.1.5 + Compiling ahash v0.8.12 + Compiling scopeguard v1.2.0 + Compiling shlex v2.0.1 + Compiling zerovec-derive v0.11.6 + Compiling displaydoc v0.2.7 + Compiling serde_derive v1.0.229 + Compiling ref-cast-impl v1.0.27 + Compiling serde_json v1.0.151 + Compiling find-msvc-tools v0.1.11 + Compiling once_cell v1.21.4 + Compiling regex-syntax v0.8.11 + Compiling ppv-lite86 v0.2.21 + Compiling cc v1.4.4 + Compiling num-rational v0.4.2 + Compiling lock_api v0.4.14 + Compiling yoke v0.8.3 + Compiling num-iter v0.1.46 + Compiling num-complex v0.4.6 + Compiling rand_core v0.9.5 + Compiling borrow-or-share v0.2.4 + Compiling pkg-config v0.3.34 + Compiling itoa v1.0.18 + Compiling bit-vec v0.8.0 + Compiling vcpkg v0.2.15 + Compiling bit-set v0.8.0 + Compiling num v0.4.3 + Compiling rand_chacha v0.9.0 + Compiling parking_lot v0.12.5 + Compiling crypto-common v0.1.7 + Compiling block-buffer v0.10.4 + Compiling outref v0.5.2 + Compiling vsimd v0.8.0 + Compiling lazy_static v1.5.0 + Compiling regex-automata v0.4.18 + Compiling libsqlite3-sys v0.35.0 + Compiling bitflags v2.13.1 + Compiling uuid v1.26.0 + Compiling thiserror v2.0.20 + Compiling percent-encoding v2.3.2 + Compiling foldhash v0.1.5 + Compiling utf8parse v0.2.2 + Compiling anstyle-parse v1.0.0 + Compiling hashbrown v0.15.5 + Compiling zerovec v0.11.8 + Compiling zerotrie v0.2.5 + Compiling uuid-simd v0.8.0 + Compiling fraction v0.15.4 + Compiling digest v0.10.7 + Compiling rand v0.9.5 + Compiling thiserror-impl v2.0.20 + Compiling cpufeatures v0.2.17 + Compiling fluent-uri v0.3.2 + Compiling email_address v0.2.9 + Compiling colorchoice v1.0.5 + Compiling is_terminal_polyfill v1.70.2 + Compiling base64 v0.22.1 + Compiling bytecount v0.6.9 + Compiling num-cmp v0.1.0 + Compiling anstyle v1.0.14 + Compiling referencing v0.33.0 + Compiling anstyle-query v1.1.5 + Compiling ulid v1.2.1 + Compiling sha2 v0.10.9 + Compiling hashlink v0.10.0 + Compiling anstream v1.0.0 + Compiling clap_lex v1.1.0 + Compiling heck v0.5.0 + Compiling fallible-streaming-iterator v0.1.9 + Compiling anyhow v1.0.104 + Compiling fallible-iterator v0.3.0 + Compiling strsim v0.11.1 + Compiling clap_derive v4.6.4 + Compiling getrandom v0.4.3 + Compiling clap_builder v4.6.6 + Compiling rustix v1.1.4 + Compiling errno v0.3.14 + Compiling tinystr v0.8.4 + Compiling potential_utf v0.1.6 + Compiling fancy-regex v0.16.2 + Compiling icu_locale_core v2.3.0 + Compiling icu_collections v2.3.0 + Compiling regex v1.13.1 + Compiling wait-timeout v0.2.1 + Compiling fastrand v2.5.0 + Compiling clap v4.6.6 + Compiling icu_provider v2.3.1 + Compiling icu_normalizer v2.3.0 + Compiling icu_properties v2.3.0 + Compiling tempfile v3.27.0 + Compiling idna_adapter v1.2.2 + Compiling idna v1.1.0 + Compiling jsonschema v0.33.0 + Compiling rusqlite v0.37.0 + Compiling relayflowd-core v0.1.0 (/Users/khaliqgant/AgentWorkforce/flows-225-placement-wt/kernel/relayflowd-core) + Compiling relayflowd-journal v0.1.0 (/Users/khaliqgant/AgentWorkforce/flows-225-placement-wt/kernel/relayflowd-journal) + Compiling relayflowd v0.1.0 (/Users/khaliqgant/AgentWorkforce/flows-225-placement-wt/kernel/relayflowd) + Finished `test` profile [unoptimized + debuginfo] target(s) in 15.90s + Running tests/spec_review_routing.rs (kernel/target/debug/deps/spec_review_routing-a37130322a188264) + +running 4 tests +test malformed_epoch_routes_are_rejected_before_commit ... FAILED +test attempt_scoped_route_is_rejected_at_append_and_replay ... FAILED +test epoch_cannot_drop_or_replace_a_durable_route ... FAILED +test workspace_pin_peels_tags_and_refuses_non_commit_objects ... FAILED + +failures: + +---- malformed_epoch_routes_are_rejected_before_commit stdout ---- + +thread 'malformed_epoch_routes_are_rejected_before_commit' (89080030) panicked at relayflowd/tests/spec_review_routing.rs:38:9: +malformed routing summary was committed +note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace + +---- attempt_scoped_route_is_rejected_at_append_and_replay stdout ---- + +thread 'attempt_scoped_route_is_rejected_at_append_and_replay' (89080028) panicked at relayflowd/tests/spec_review_routing.rs:28:53: +called `Result::unwrap_err()` on an `Ok` value: RunState { run_id: "run", spec: RunSpec { version: "0.1.0", name: None, description: None, cli: None, triggers: [], steps: [StepSpec { id: "s", depends_on: [], max_iterations: 1, retry: RetryPolicy { initial_backoff_ms: 100, max_backoff_ms: 60000, multiplier: 2, jitter_percent: 20 }, verification: VerificationSpec { output_contains: None, json_schema: None }, memory: None, requirements: None, kind: Deterministic { command: Shell("true"), timeout_ms: None } }], budget: None }, steps: {"s": StepRuntime { state: Runnable, attempts: 0, semantic_executions: 0, last_start_pins: None, last_end_pins: None, last_completion_reason: None, trajectory_tail: None, memory: None }}, memo: {}, budget: Budget { tokens_in: 0, tokens_out: 0, dollars: "0" }, completion: None, cancel_requested: None, current_pins: None, routing: {"s": RoutingDecision { profile: "batch", provider: "local", fallbacks_attempted: [], workspace: None }} } + +---- epoch_cannot_drop_or_replace_a_durable_route stdout ---- + +thread 'epoch_cannot_drop_or_replace_a_durable_route' (89080029) panicked at relayflowd/tests/spec_review_routing.rs:51:9: +epoch rewrote a durable route + +---- workspace_pin_peels_tags_and_refuses_non_commit_objects stdout ---- + +thread 'workspace_pin_peels_tags_and_refuses_non_commit_objects' (89080031) panicked at relayflowd/tests/spec_review_routing.rs:78:5: +assertion `left == right` failed + left: "657cfabb34dda0a5749214a3cde7216fe937b2c1" + right: "7f15257e6e0007ab0cd0717af19ef5bf8602dbff" + + +failures: + attempt_scoped_route_is_rejected_at_append_and_replay + epoch_cannot_drop_or_replace_a_durable_route + malformed_epoch_routes_are_rejected_before_commit + workspace_pin_peels_tags_and_refuses_non_commit_objects + +test result: FAILED. 0 passed; 4 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.06s + +error: test failed, to rerun pass `-p relayflowd --test spec_review_routing` + +exit_code=101 diff --git a/kernel/evidence/225/spec-review-sdk-tests.txt b/kernel/evidence/225/spec-review-sdk-tests.txt new file mode 100644 index 000000000..4be86aed6 --- /dev/null +++ b/kernel/evidence/225/spec-review-sdk-tests.txt @@ -0,0 +1,16 @@ +$ node node_modules/vitest/vitest.mjs run tests/placement.test.ts tests/spec-parity.test.ts tests/verb-field-lint.test.ts + + RUN v2.1.9 /Users/khaliqgant/AgentWorkforce/flows-225-placement-wt/packages/sdk + + ✓ tests/placement.test.ts (54 tests) 11ms + ✓ tests/spec-parity.test.ts (31 tests) 226ms + ✓ tests/verb-field-lint.test.ts (78 tests) 518ms + ✓ closed per-verb step fields > carries the llm/agent `output` sugar through every path > flows check accepts output on llm 321ms + + Test Files 3 passed (3) + Tests 163 passed (163) + Start at 23:52:12 + Duration 1.23s (transform 359ms, setup 0ms, collect 1.16s, tests 755ms, environment 0ms, prepare 170ms) + + +exit_code=0 diff --git a/kernel/evidence/225/spec-review-source-drift-repro.py b/kernel/evidence/225/spec-review-source-drift-repro.py new file mode 100644 index 000000000..c9541fc71 --- /dev/null +++ b/kernel/evidence/225/spec-review-source-drift-repro.py @@ -0,0 +1,27 @@ +import json,pathlib,subprocess,tempfile,shlex,sys +binary=str(pathlib.Path(sys.argv[1]).resolve()) +def run(args,cwd): + print('$ '+shlex.join(args), flush=True) + r=subprocess.run(args,cwd=cwd,stdout=subprocess.PIPE,stderr=subprocess.STDOUT,text=True) + print(r.stdout,end='');print('exit_code='+str(r.returncode),flush=True) + if r.returncode: raise SystemExit(r.returncode) + return r.stdout +with tempfile.TemporaryDirectory(prefix='placement-drift-') as temp: + root=pathlib.Path(temp); tree=root/'tree';tree.mkdir();data=root/'data' + run(['git','init','-q'],tree) + (tree/'source.txt').write_text('original\n') + run(['git','add','source.txt'],tree) + commit=['git','-c','user.name=Fixture','-c','user.email=fixture@example.test','-c','commit.gpgsign=false','commit','-qm'] + run(commit+['original'],tree) + spec=root/'flow.json';spec.write_text(json.dumps({'steps':[ + {'id':'first','type':'deterministic','command':'cat source.txt','requirements':{'workspace':True}}, + {'id':'second','type':'deterministic','command':'cat source.txt','depends_on':['first'],'requirements':{'workspace':True}}]})) + outcome=json.loads(run([binary,'--data-dir',str(data),'run',str(spec),'--stop-after','1'],tree)) + (tree/'source.txt').write_text('changed-between-steps\n') + run(['git','add','source.txt'],tree);run(commit+['changed'],tree) + run([binary,'--data-dir',str(data),'resume',outcome['run_id']],tree) + import sqlite3 + with sqlite3.connect(next((data/'runs').glob('*.sqlite3'))) as db: + for entry_type,step,payload in db.execute("select entry_type,step_id,payload from entries where entry_type in ('step.attempt.started','step.completed') order by seq"): + payload=json.loads(payload) + print(json.dumps({'entry_type':entry_type,'step':step,'pins':payload.get('pins'),'output':payload.get('output')})) diff --git a/kernel/evidence/225/spec-review-source-drift-repro.txt b/kernel/evidence/225/spec-review-source-drift-repro.txt new file mode 100644 index 000000000..026b512a1 --- /dev/null +++ b/kernel/evidence/225/spec-review-source-drift-repro.txt @@ -0,0 +1,23 @@ +$ python3 ops/spec-review-0907-evidence/227-source-drift-repro.py /Users/khaliqgant/AgentWorkforce/flows-225-placement-wt/kernel/target/debug/relayflowd +$ git init -q +exit_code=0 +$ git add source.txt +exit_code=0 +$ git -c user.name=Fixture -c user.email=fixture@example.test -c commit.gpgsign=false commit -qm original +exit_code=0 +$ /Users/khaliqgant/AgentWorkforce/flows-225-placement-wt/kernel/target/debug/relayflowd --data-dir /var/folders/yv/nbp9l2c55wlbj1x0gml37s7c0000gn/T/placement-drift-dsqt865h/data run /var/folders/yv/nbp9l2c55wlbj1x0gml37s7c0000gn/T/placement-drift-dsqt865h/flow.json --stop-after 1 +{"run_id":"01M1YXR74Z6RWZMCYJ8HW9SY9S","status":"interrupted","completion_reason":null,"completed_steps":1} +exit_code=0 +$ git add source.txt +exit_code=0 +$ git -c user.name=Fixture -c user.email=fixture@example.test -c commit.gpgsign=false commit -qm changed +exit_code=0 +$ /Users/khaliqgant/AgentWorkforce/flows-225-placement-wt/kernel/target/debug/relayflowd --data-dir /var/folders/yv/nbp9l2c55wlbj1x0gml37s7c0000gn/T/placement-drift-dsqt865h/data resume 01M1YXR74Z6RWZMCYJ8HW9SY9S +{"run_id":"01M1YXR74Z6RWZMCYJ8HW9SY9S","status":"completed","completion_reason":"success","completed_steps":2} +exit_code=0 +{"entry_type": "step.attempt.started", "step": "first", "pins": {"streams": [], "workspace": [{"revision_id": "c2df7c0316d15be56f8cb282cd67da83a70c7704", "surface": "/private/var/folders/yv/nbp9l2c55wlbj1x0gml37s7c0000gn/T/placement-drift-dsqt865h/tree"}]}, "output": null} +{"entry_type": "step.completed", "step": "first", "pins": null, "output": {"exit_code": 0, "stderr_tail": "", "stdout_tail": "original\n"}} +{"entry_type": "step.attempt.started", "step": "second", "pins": {"streams": [], "workspace": [{"revision_id": "14f735f5866363a1de0b1fef8faa1200c6d0e7f8", "surface": "/private/var/folders/yv/nbp9l2c55wlbj1x0gml37s7c0000gn/T/placement-drift-dsqt865h/tree"}]}, "output": null} +{"entry_type": "step.completed", "step": "second", "pins": null, "output": {"exit_code": 0, "stderr_tail": "", "stdout_tail": "changed-between-steps\n"}} + +exit_code=0 diff --git a/kernel/relayflowd-core/src/state/routing.rs b/kernel/relayflowd-core/src/state/routing.rs index 2c2ef4764..8e3a32f48 100644 --- a/kernel/relayflowd-core/src/state/routing.rs +++ b/kernel/relayflowd-core/src/state/routing.rs @@ -9,6 +9,12 @@ impl RunState { .step_id .as_ref() .ok_or(StateError::MissingStep(entry.seq))?; + if entry.attempt.is_some() { + return Err(StateError::InvalidRouting { + step: id.clone(), + detail: "routing decision must not specify an attempt".into(), + }); + } if self.routing.contains_key(id) { return Err(StateError::InvalidRouting { step: id.clone(), diff --git a/kernel/relayflowd-journal/src/placement.rs b/kernel/relayflowd-journal/src/placement.rs index 2d1d78d0a..1595f5f22 100644 --- a/kernel/relayflowd-journal/src/placement.rs +++ b/kernel/relayflowd-journal/src/placement.rs @@ -7,10 +7,11 @@ use std::collections::BTreeMap; fn decisions( tx: &Transaction<'_>, run_id: &str, + before_seq: i64, ) -> Result, JournalStoreError> { - let mut query = tx.prepare("SELECT seq, segment_id, entry_type, step_id, attempt, at_ms, payload FROM entries WHERE entry_type IN ('step.routed', 'epoch.summary') ORDER BY seq")?; + let mut query = tx.prepare("SELECT seq, segment_id, entry_type, step_id, attempt, at_ms, payload FROM entries WHERE entry_type IN ('step.routed', 'epoch.summary') AND seq < ?1 ORDER BY seq")?; let entries = query - .query_map([], |row| entry_from_row(row, run_id))? + .query_map([before_seq], |row| entry_from_row(row, run_id))? .collect::, _>>()?; let mut routing = BTreeMap::new(); for entry in entries { @@ -43,6 +44,37 @@ pub(crate) fn validate_entry( run_id: &str, entry: &JournalEntry, ) -> Result<(), JournalStoreError> { + if entry.entry_type == EntryType::EpochSummary { + let summary: EpochSummaryPayload = serde_json::from_value(entry.payload.clone())?; + if !summary.routing.is_empty() { + let payload: String = tx.query_row( + "SELECT payload FROM entries WHERE entry_type = 'run.spawned' ORDER BY seq LIMIT 1", + [], + |row| row.get(0), + )?; + let spawn: relayflowd_core::RunSpawnedPayload = serde_json::from_str(&payload)?; + let spec = relayflowd_core::RunSpec::parse(&spawn.spec).map_err(StateError::from)?; + for (id, route) in &summary.routing { + if spec.step(id).is_none() { + return Err(StateError::UnknownStep(id.clone()).into()); + } + route + .validate() + .map_err(|detail| StateError::InvalidRouting { + step: id.clone(), + detail, + })?; + } + } + let previous = decisions(tx, run_id, entry.seq)?; + if summary.routing != previous { + return Err(StateError::InvalidRouting { + step: String::new(), + detail: "epoch must preserve recorded routing decisions".into(), + } + .into()); + } + } if entry.entry_type == EntryType::StepRouted { let id = entry .step_id @@ -65,7 +97,7 @@ pub(crate) fn validate_entry( } .into()); } - decisions(tx, run_id)?; + decisions(tx, run_id, i64::MAX)?; } Ok(()) } @@ -75,6 +107,6 @@ pub(crate) fn carry_summary( run_id: &str, summary: &mut EpochSummaryPayload, ) -> Result<(), JournalStoreError> { - summary.routing = decisions(tx, run_id)?; + summary.routing = decisions(tx, run_id, i64::MAX)?; Ok(()) } diff --git a/kernel/relayflowd/src/worker.rs b/kernel/relayflowd/src/worker.rs index 854814e25..806e5f87a 100644 --- a/kernel/relayflowd/src/worker.rs +++ b/kernel/relayflowd/src/worker.rs @@ -67,7 +67,7 @@ pub trait StepDispatcher: Send + Sync { } /// Supply starting pins for declared surfaces not yet covered by the journal. - /// The default runs `git rev-parse --verify HEAD` in each declared local + /// The default runs `git rev-parse --verify HEAD^{commit}` in each declared local /// worktree, using this process's filesystem, and fails on unreadable /// worktrees or declared streams (it cannot report stream offsets). /// Remote dispatchers must override this or `reserved_starting_pins` to diff --git a/kernel/relayflowd/src/workspace.rs b/kernel/relayflowd/src/workspace.rs index cd3b22fd5..3abfcbc45 100644 --- a/kernel/relayflowd/src/workspace.rs +++ b/kernel/relayflowd/src/workspace.rs @@ -8,7 +8,7 @@ pub(crate) fn pin(path: &Path) -> Result { .canonicalize() .with_context(|| format!("workspace {} is unavailable", path.display()))?; let output = Command::new("git") - .args(["rev-parse", "--verify", "HEAD"]) + .args(["rev-parse", "--verify", "HEAD^{commit}"]) .current_dir(&path) .output() .context("read workspace base commit")?; diff --git a/kernel/relayflowd/tests/spec_review_routing.rs b/kernel/relayflowd/tests/spec_review_routing.rs new file mode 100644 index 000000000..afe4e24a8 --- /dev/null +++ b/kernel/relayflowd/tests/spec_review_routing.rs @@ -0,0 +1,173 @@ +use relayflowd::worker::{DispatchOutcome, StepDispatch, StepDispatcher}; +use relayflowd_core::{EntryType, Journal, JournalEntry, RunSpec, RunState, StepType}; +use relayflowd_journal::SqliteJournal; +use serde_json::{Value, json}; +use tempfile::tempdir; + +fn spec() -> RunSpec { + RunSpec::parse(&json!({"steps":[{"id":"s","type":"deterministic","command":"true"}]})).unwrap() +} +fn route() -> Value { + json!({"profile":"batch","provider":"local","fallbacks_attempted":[]}) +} +fn journal(path: &std::path::Path) -> SqliteJournal { + let mut journal = SqliteJournal::create(path, "run", 0).unwrap(); + journal.append(&JournalEntry::new(EntryType::RunSpawned, "run", None, None, 0, + json!({"spec":spec(),"spec_hash":"test","parent_run_id":null,"journal_version":1,"created_by":"test"}))).unwrap(); + journal +} +#[test] +fn attempt_scoped_route_is_rejected_at_append_and_replay() { + let directory = tempdir().unwrap(); + let mut journal = journal(&directory.path().join("run.sqlite3")); + let entry = JournalEntry::new( + EntryType::StepRouted, + "run", + Some("s".into()), + Some(1), + 1, + route(), + ); + let before = journal.scan_all().unwrap(); + let expected = "routing decision must not specify an attempt"; + assert!( + journal + .append(&entry) + .unwrap_err() + .to_string() + .contains(expected) + ); + assert_eq!(journal.scan_all().unwrap(), before); + assert!( + RunState::fold("run", spec(), &[entry]) + .unwrap_err() + .to_string() + .contains(expected) + ); +} +#[test] +fn malformed_epoch_routes_are_rejected_before_commit() { + for routing in [ + json!({"unknown":route()}), + json!({"s":{"profile":"","provider":"local","fallbacks_attempted":[]}}), + ] { + let directory = tempdir().unwrap(); + let mut journal = journal(&directory.path().join("run.sqlite3")); + let before = journal.scan_all().unwrap(); + let entry = JournalEntry::new( + EntryType::EpochSummary, + "run", + None, + None, + 1, + json!({"epoch":2,"prev_segment_id":1,"journal_version":1,"budget_spent":{},"routing":routing}), + ); + assert!( + journal.append(&entry).is_err(), + "malformed routing summary was committed" + ); + assert_eq!(journal.scan_all().unwrap(), before); + } +} +#[test] +fn epoch_cannot_drop_or_replace_a_durable_route() { + for routing in [ + json!({}), + json!({"s":{"profile":"batch","provider":"other","fallbacks_attempted":[]}}), + ] { + let directory = tempdir().unwrap(); + let mut journal = journal(&directory.path().join("run.sqlite3")); + journal + .append(&JournalEntry::new( + EntryType::StepRouted, + "run", + Some("s".into()), + None, + 1, + route(), + )) + .unwrap(); + let before = journal.scan_all().unwrap(); + let entry = JournalEntry::new( + EntryType::EpochSummary, + "run", + None, + None, + 2, + json!({"epoch":2,"prev_segment_id":1,"journal_version":1,"budget_spent":{},"routing":routing}), + ); + assert!( + journal.append(&entry).is_err(), + "epoch rewrote a durable route" + ); + assert_eq!(journal.scan_all().unwrap(), before); + } +} +struct LocalWorker; +impl StepDispatcher for LocalWorker { + fn executor(&self, _: StepType) -> Option { + Some("local".into()) + } + fn available(&self, _: StepType) -> bool { + true + } + fn dispatch(&self, _: StepDispatch) -> anyhow::Result { + unreachable!() + } +} +#[test] +fn workspace_pin_peels_tags_and_refuses_non_commit_objects() { + use std::process::Command; + let directory = tempdir().unwrap(); + let git = |args: &[&str]| { + let output = Command::new("git") + .args(args) + .current_dir(directory.path()) + .output() + .unwrap(); + assert!(output.status.success(), "{output:?}"); + String::from_utf8(output.stdout).unwrap().trim().to_owned() + }; + git(&["init", "-q"]); + git(&[ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.test", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-qm", + "base", + ]); + let commit = git(&["rev-parse", "HEAD"]); + git(&[ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.test", + "tag", + "-a", + "base", + "-m", + "base", + ]); + let tag = git(&["rev-parse", "refs/tags/base"]); + std::fs::write(directory.path().join(".git/HEAD"), format!("{tag}\n")).unwrap(); + let surface = directory + .path() + .canonicalize() + .unwrap() + .to_str() + .unwrap() + .to_owned(); + let spec = RunSpec::parse(&json!({"steps":[{"id":"a","type":"agent","instruction":"edit","surfaces":{"workspace":[{"surface":surface}]}}]})).unwrap(); + assert_eq!( + LocalWorker.starting_pins(&spec.steps[0]).unwrap().workspace[0].revision_id, + commit + ); + let tree = git(&["rev-parse", "HEAD^{tree}"]); + std::fs::write(directory.path().join(".git/HEAD"), format!("{tree}\n")).unwrap(); + assert!(LocalWorker.starting_pins(&spec.steps[0]).is_err()); +} From 3736b0bce90d646bf5357e90520838c4bb9230b4 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Tue, 8 Sep 2026 06:25:11 +0200 Subject: [PATCH 6/6] test(placement): a resumed attempt keeps its pin when the worktree HEAD moves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the open finding on #227. The concern was that a declared local run resuming after its worktree HEAD changed would record a different source pin for the same durable route. Nothing tested it: `placement_pins` only called `starting_pins` directly (which does re-read HEAD, correctly — that is the worker's job), and `crash_resume/workspace_identity` covers aliases and canonical subtrees, not a moving HEAD. The test starts an agent step over a git worktree, lets the dispatcher take the lease so the run parks, commits again to move HEAD, then resumes under a fresh Engine — a new boot id, so the leased attempt reads as dead and the step is retried. It asserts both attempts carry the elected revision, and that a recording dispatcher was asked for pins exactly once. Mutation-verified rather than trusted green: forcing the `covered` branch off in `resolve_agent_pins` makes it fail (pin_requests 2, expected 1). Worth recording what that mutation also showed. Under it the *revision* assertion still held, because the projection reads `carried.workspace.iter().chain(worker.workspace.iter())` and takes the first match. So carried-first is what actually protects the pin; the `covered` short-circuit only avoids the needless question. The test now pins both. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR --- kernel/relayflowd/tests/placement_pins.rs | 115 ++++++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/kernel/relayflowd/tests/placement_pins.rs b/kernel/relayflowd/tests/placement_pins.rs index d96f8be6b..b141be70d 100644 --- a/kernel/relayflowd/tests/placement_pins.rs +++ b/kernel/relayflowd/tests/placement_pins.rs @@ -84,3 +84,118 @@ fn unsupported_local_pty_is_refused_before_an_earlier_step_can_run() { assert!(!marker.exists()); assert!(!directory.path().join("data/runs").exists()); } + +/// A worker that records every time the engine asks it to resolve starting +/// pins. `Dispatched` hands the lease off and parks the run, which is what +/// lets a second Engine (new boot id) replace the attempt on resume. +#[derive(Default)] +struct RecordingWorker { + pin_requests: std::sync::atomic::AtomicUsize, +} + +impl StepDispatcher for RecordingWorker { + fn executor(&self, _: StepType) -> Option { + Some("recording-worker".into()) + } + fn available(&self, _: StepType) -> bool { + true + } + fn reserved_starting_pins( + &self, + _run_id: &str, + step: &relayflowd_core::StepSpec, + _attempt: u32, + ) -> anyhow::Result { + self.pin_requests + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + self.starting_pins(step) + } + fn dispatch(&self, _: StepDispatch) -> anyhow::Result { + Ok(DispatchOutcome::Dispatched) + } +} + +struct SilentObserver; +impl relayflowd::worker::JournalObserver for SilentObserver { + fn appended(&self, _entry: &relayflowd_core::JournalEntry) {} +} + +fn started_pin_revisions(engine: &Engine, run_id: &str) -> Vec { + engine + .journal_entries(run_id, 0, 1024) + .unwrap() + .into_iter() + .filter(|entry| entry.entry_type == relayflowd_core::EntryType::StepAttemptStarted) + .map(|entry| { + let payload: relayflowd_core::AttemptStartedPayload = + serde_json::from_value(entry.payload).unwrap(); + payload.pins.workspace[0].revision_id.clone() + }) + .collect() +} + +/// A resumed attempt must reuse the revision the run was already pinned to, +/// even when the worktree HEAD has moved underneath it. The carried pin is +/// what the step was elected against; re-reading HEAD would silently hand the +/// retry a different starting state for the same durable route. +#[test] +fn a_resumed_attempt_keeps_the_original_pin_after_the_worktree_head_moves() { + let tree = tempdir().unwrap(); + let data = tempdir().unwrap(); + let commit = |message: &str| { + git( + tree.path(), + &[ + "-c", "user.name=Test", "-c", "user.email=test@example.com", + "commit", "--allow-empty", "-qm", message, + ], + ) + }; + git(tree.path(), &["init", "-q"]); + commit("base"); + let elected = git(tree.path(), &["rev-parse", "HEAD"]); + let surface = tree.path().canonicalize().unwrap().to_str().unwrap().to_owned(); + let spec = RunSpec::parse( + &json!({"steps":[{"id":"edit","type":"agent","instruction":"edit", + "surfaces":{"workspace":[{"surface":surface}]}}]}), + ) + .unwrap(); + + let worker = std::sync::Arc::new(RecordingWorker::default()); + let engine = Engine::with_runtime( + data.path(), + worker.clone(), + std::sync::Arc::new(SilentObserver), + ); + let run_id = engine.start(spec, "test", None).unwrap().run_id; + assert_eq!(started_pin_revisions(&engine, &run_id), vec![elected.clone()]); + let asked_once = worker.pin_requests.load(std::sync::atomic::Ordering::SeqCst); + assert_eq!(asked_once, 1, "the first attempt has nothing to carry"); + + // HEAD moves under the parked run, exactly as an operator committing in + // their worktree would move it. + commit("moved"); + let moved = git(tree.path(), &["rev-parse", "HEAD"]); + assert_ne!(moved, elected); + + // A fresh Engine means a fresh boot id, so the leased attempt reads as + // dead and the step is retried. + let resumed = Engine::with_runtime( + data.path(), + worker.clone(), + std::sync::Arc::new(SilentObserver), + ); + resumed.resume(&run_id, None).unwrap(); + + let revisions = started_pin_revisions(&resumed, &run_id); + assert!(revisions.len() >= 2, "expected a second attempt, got {revisions:?}"); + assert!( + revisions.iter().all(|revision| revision == &elected), + "a retry re-read HEAD instead of carrying the elected pin: {revisions:?} (moved to {moved})", + ); + assert_eq!( + worker.pin_requests.load(std::sync::atomic::Ordering::SeqCst), + asked_once, + "a covered surface must not be resolved again", + ); +}