Skip to content

feat(kernel): durable channels with acknowledged delivery and crash replay - #215

Merged
kjgbot merged 2 commits into
mainfrom
feat/durable-channels-212
Sep 7, 2026
Merged

feat(kernel): durable channels with acknowledged delivery and crash replay#215
kjgbot merged 2 commits into
mainfrom
feat/durable-channels-212

Conversation

@kjgbot

@kjgbot kjgbot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Closes #212.

An agent message previously had no durable consumer state. This adds run-local channel.append, channel.receive, and channel.ack journal protocol operations: receiving commits a delivery fact, and only acknowledged consumption advances the step's consumer offset. Retrying an unacknowledged receive returns the same message with another recorded delivery; retrying a send deduplicates by channel, producer step, and message id.

The crash-resume test drives two stub agents through the real daemon socket, SIGKILLs the daemon after append / delivery / effect confirmation / acknowledgement, reconnects workers, and resumes with the real CLI. It asserts redelivery only before acknowledgement, one effect per agent across those cuts, and equality between received deliveries and replayed journal facts. It also retries the producer send and acknowledgement across recovery.

Channel decisions are pure core logic. SQLite performs actor validation, state reconstruction, selection, and persistence in an IMMEDIATE transaction, including across independent connections. Channel writes obey the existing writable stream declarations; reads do not reserve another agent's outgoing surface. There are no authoring schema, SDK, or gate-definition changes. New implementation modules are at most 331 lines; the existing larger engine and server files only gain module declarations and routing.

Current limit: channel operations scan retained journal segments, like the existing stream reader. Cross-segment consumption works, but bounded channel snapshots for removing archived segments are not implemented. The effect assertion covers the named coordination cuts using the existing effect election/confirmation protocol, not arbitrary provider crashes between a provider call and confirmation.

Implementation: 4c87d10. The failing test and its original transcript were committed before implementation in 38dcef9. The final test also checks writable stream admission using the existing surface field.

Verification transcripts are committed under kernel/evidence/212/; full captured outputs follow. No mutation-verification claim.

Test first: expected missing-capability failure (exit 101)
cd kernel && cargo test -p relayflowd --test crash_resume channels_sigkill_resume -- --nocapture
   Compiling relayflowd v0.1.0 (/Users/khaliqgant/AgentWorkforce/flows-212-channels-wt/kernel/relayflowd)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1.68s
     Running tests/crash_resume.rs (target/debug/deps/crash_resume-e6635a3f0d48512c)

running 1 test

thread 'channels::channels_sigkill_resume_redelivers_unacked_messages_with_exactly_once_effects' (72743573) panicked at relayflowd/tests/crash_resume/channels.rs:25:41:
durable channel append must exist: unsupported_verb: unknown journal protocol verb channel.append
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
test channels::channels_sigkill_resume_redelivers_unacked_messages_with_exactly_once_effects ... FAILED

failures:

failures:
    channels::channels_sigkill_resume_redelivers_unacked_messages_with_exactly_once_effects

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 34 filtered out; finished in 0.49s

error: test failed, to rerun pass `-p relayflowd --test crash_resume`
Required workspace gate (exit 0)
cd kernel && cargo test --workspace
   Compiling relayflowd-core v0.1.0 (/Users/khaliqgant/AgentWorkforce/flows-212-channels-wt/kernel/relayflowd-core)
   Compiling relayflowd-journal v0.1.0 (/Users/khaliqgant/AgentWorkforce/flows-212-channels-wt/kernel/relayflowd-journal)
   Compiling relayflowd v0.1.0 (/Users/khaliqgant/AgentWorkforce/flows-212-channels-wt/kernel/relayflowd)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 3.18s
     Running unittests src/lib.rs (target/debug/deps/relayflowd-399037c915557fdb)

running 35 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 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 engine::boot_identity_tests::every_engine_in_this_process_shares_one_boot_id ... ok
test engine::remote::worker_failure_detail_tests::truncation_does_not_split_a_multi_byte_char ... 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 server::tests::agent::contract::an_agent_worker_attaching_without_pins_is_refused_at_attach ... ok
test engine::wake::claim_guard_tests::a_disarmed_guard_leaves_the_claim_alone ... 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 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 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::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::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_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::run_resume_adopts_a_real_journal_whose_registry_row_is_missing ... ok
test server::tests::run_resume_refuses_a_valid_journal_that_belongs_to_another_run ... ok
test server::tests::agent::pins::consecutive_agent_steps_on_different_surfaces_each_start_from_their_own_pins ... 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. 35 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.56s

     Running unittests src/main.rs (target/debug/deps/relayflowd-9e21fa47745f4fb0)

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-e6635a3f0d48512c)

running 36 tests
test channels::channels_reject_foreign_workers_stale_attempts_and_invalid_acknowledgements ... ok
test concurrency::cancel_closes_the_lease_and_rejects_a_late_completion ... ok
test concurrency::cancel_and_completion_race_has_one_terminal_fact ... ok
test agent::resume_without_a_worker_parks_immediately_instead_of_timing_out ... ok
test agent::rung_c_sigkill_between_agent_completion_and_final_effect_memoizes_the_agent ... ok
test agent::rung_c_sigkill_after_final_effect_replays_results_without_redispatch ... 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_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::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 llm::serve_plumbs_watch_events_and_replayable_stream_verbs ... ok
test agent::rung_c_sigkill_boundaries_resume_only_unfinished_steps_via_real_cli ... 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 concurrency::server_restart_recovers_every_parallel_lease_without_duplicate_success ... 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 llm::sigkill_under_serve_mid_llm_releases_the_lease_and_finishes_via_cli_resume ... 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::worker_killed_while_holding_a_lease_is_explained_and_released_on_cli_resume ... ok
test parallel_lifecycle::overlapping_agent_conflict_survives_server_crash_and_resume ... 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 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 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 worker_capacity::default_capacity_one_reopens_only_after_durable_completion_or_crash ... ok
test sigkill_sweep_covers_every_hello_step_boundary ... 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. 36 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 37.87s

     Running tests/event_wake.rs (target/debug/deps/event_wake-68806edfe23e584d)

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.03s

     Running tests/hn_monitor_integration.rs (target/debug/deps/hn_monitor_integration-503c7fe71dfd86ee)

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-bb9367af18e074b7)

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 6.96s

     Running tests/parallel_driver.rs (target/debug/deps/parallel_driver-f38ce1eaf4ecefc1)

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/subscription_liveness.rs (target/debug/deps/subscription_liveness-f71f7e88f8d163dc)

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 56 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::delivery_replay_and_independent_acknowledged_offsets ... ok
test machine::parallel_tests::machine_starts_every_runnable_step_in_authored_order ... 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::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::parallel_tests::external_ancestor_and_descendant_paths_conflict_but_siblings_do_not ... ok
test machine::parallel_tests::workspace_ancestor_and_descendant_paths_conflict_but_siblings_do_not ... ok
test machine::parallel_tests::crash_resume_preserves_each_parallel_lease_exactly_once ... ok
test machine::parallel_tests::failed_run_drains_open_siblings_before_terminal_entry ... ok
test machine::tests::cancel_request_closes_the_active_lease_before_the_terminal_fact ... ok
test machine::tests::durable_cancel_request_outranks_crash_recovery ... ok
test machine::tests::every_reason_label_matches_its_serialized_form ... ok
test machine::tests::repeated_cancel_request_is_idempotent ... ok
test machine::tests::crashed_attempt_does_not_consume_an_iteration ... 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::parallel_tests::disjoint_agent_lanes_merge_pins_in_either_completion_order ... ok
test machine::tests::verification_failure_schedules_a_durable_retry ... ok
test machine::tests::machine_starts_runnable_step_with_stable_effect_key ... ok
test machine::tests::reset_recovery_dispatches_the_original_pinned_revision ... ok
test machine::tests::worker_reported_failure_without_detail_still_records_a_verification ... ok
test retry::tests::jitter_is_repeatable_and_bounded ... ok
test machine::tests::inspect_recovery_injects_the_dirty_pin_completion_reason_and_tail ... ok
test machine::tests::every_failed_run_terminates_with_declared_completion_reasons ... 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::preflight_data_is_fail_closed ... ok
test spec::tests::cycles_are_rejected ... ok
test spec::tests::spec_version_is_semver_and_gated ... ok
test spec::tests::external_surface_paths_must_have_one_canonical_spelling ... 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::workspace_mounts_and_worktrees_must_have_one_canonical_spelling ... ok
test spec::tests::zero_agent_flow_is_valid ... 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 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 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. 56 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.68s

     Running tests/spec_parity.rs (target/debug/deps/spec_parity-bbda6cf1e1cf1c19)

running 5 tests
test the_kernel_parses_the_event_triggered_spec_and_stamps_the_same_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 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. 5 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::registry_is_a_rebuildable_run_locator ... 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::releasing_is_scoped_to_the_claiming_run ... ok
test registry::tests::a_pre_migration_registry_gains_boot_id_and_its_claims_are_repairable ... ok
test subscriptions::tests::detect_without_latch_stays_available_for_the_next_sweep ... 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::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_election_gives_the_first_caller_the_result_and_second_gets_empty ... ok
test channel::tests::channels_cross_segment_boundaries_and_terminal_runs_reject_mutations ... ok
test subscriptions::tests::prune_sweep_claims_deletes_only_rows_older_than_cutoff ... 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_marks_row_stale_when_silence_exceeds_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::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 tests::effects_are_deduplicated_at_the_journal_boundary ... ok
test tests::append_is_durable_and_monotonic_after_reopen ... 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.18s

   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

@coderabbitai

coderabbitai Bot commented Sep 7, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: 1b078c90-6d1f-47b6-ad3e-bd8b9c8a614f


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 19 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="kernel/relayflowd-core/src/channel.rs">

<violation number="1" location="kernel/relayflowd-core/src/channel.rs:118">
P2: When an invalid append targets a previously unseen channel, `apply` returns an error after inserting an empty channel into `self.messages`. Validate using a non-mutating lookup first, then insert and push only after all checks pass so failed applications remain state-atomic.</violation>
</file>

<file name="kernel/relayflowd/tests/crash_resume/channels.rs">

<violation number="1" location="kernel/relayflowd/tests/crash_resume/channels.rs:142">
P2: This new crash-resume test blocks indefinitely on `resume.wait_with_output()` with no timeout and no stalled-run diagnostics. The repo already ships `describe_stalled_resume` in `crash_resume/support.rs`, written specifically for the failure mode documented against issue #174 (a resume that stalls or dies without dispatching, leaving the journal never read and the child output discarded on unwind). If this resumed run stalls at any cut, `wait_with_output` hangs to the CI timeout or the test unwinds with no journal/stdout evidence, costing the team the same debugging time the predecessor tests addressed. Wrap the wait in a timeout and capture the journal and child output (e.g. via `describe_stalled_resume`) on stall.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment on lines +118 to +133
let messages = self.messages.entry(p.channel).or_default();
require(
p.offset == messages.len() as u64 + 1,
"append offset is not consecutive",
)?;
require(
!messages.iter().any(|e| {
e.payload["producer"] == p.producer
&& e.payload["message_id"] == p.message_id
}),
"duplicate message id",
)?;
messages.push(entry.clone());
}
EntryType::ChannelDelivered => {
let p: ChannelDeliveredPayload = serde_json::from_value(entry.payload.clone())?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When an invalid append targets a previously unseen channel, apply returns an error after inserting an empty channel into self.messages. Validate using a non-mutating lookup first, then insert and push only after all checks pass so failed applications remain state-atomic.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At kernel/relayflowd-core/src/channel.rs, line 118:

<comment>When an invalid append targets a previously unseen channel, `apply` returns an error after inserting an empty channel into `self.messages`. Validate using a non-mutating lookup first, then insert and push only after all checks pass so failed applications remain state-atomic.</comment>

<file context>
@@ -0,0 +1,331 @@
+                    entry.step_id.as_deref() == Some(&p.producer),
+                    "producer does not match step",
+                )?;
+                let messages = self.messages.entry(p.channel).or_default();
+                require(
+                    p.offset == messages.len() as u64 + 1,
</file context>
Suggested change
let messages = self.messages.entry(p.channel).or_default();
require(
p.offset == messages.len() as u64 + 1,
"append offset is not consecutive",
)?;
require(
!messages.iter().any(|e| {
e.payload["producer"] == p.producer
&& e.payload["message_id"] == p.message_id
}),
"duplicate message id",
)?;
messages.push(entry.clone());
}
EntryType::ChannelDelivered => {
let p: ChannelDeliveredPayload = serde_json::from_value(entry.payload.clone())?;
let messages = self
.messages
.get(&p.channel)
.map(Vec::as_slice)
.unwrap_or_default();
require(
p.offset == messages.len() as u64 + 1,
"append offset is not consecutive",
)?;
require(
!messages.iter().any(|e| {
e.payload["producer"] == p.producer
&& e.payload["message_id"] == p.message_id
}),
"duplicate message id",
)?;
self.messages
.entry(p.channel)
.or_default()
.push(entry.clone());

deliveries.push(reply);
finish(&mut alice, &a2);
finish(&mut bob, &b2);
let output = resume.wait_with_output().unwrap();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This new crash-resume test blocks indefinitely on resume.wait_with_output() with no timeout and no stalled-run diagnostics. The repo already ships describe_stalled_resume in crash_resume/support.rs, written specifically for the failure mode documented against issue #174 (a resume that stalls or dies without dispatching, leaving the journal never read and the child output discarded on unwind). If this resumed run stalls at any cut, wait_with_output hangs to the CI timeout or the test unwinds with no journal/stdout evidence, costing the team the same debugging time the predecessor tests addressed. Wrap the wait in a timeout and capture the journal and child output (e.g. via describe_stalled_resume) on stall.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At kernel/relayflowd/tests/crash_resume/channels.rs, line 142:

<comment>This new crash-resume test blocks indefinitely on `resume.wait_with_output()` with no timeout and no stalled-run diagnostics. The repo already ships `describe_stalled_resume` in `crash_resume/support.rs`, written specifically for the failure mode documented against issue #174 (a resume that stalls or dies without dispatching, leaving the journal never read and the child output discarded on unwind). If this resumed run stalls at any cut, `wait_with_output` hangs to the CI timeout or the test unwinds with no journal/stdout evidence, costing the team the same debugging time the predecessor tests addressed. Wrap the wait in a timeout and capture the journal and child output (e.g. via `describe_stalled_resume`) on stall.</comment>

<file context>
@@ -0,0 +1,303 @@
+        deliveries.push(reply);
+        finish(&mut alice, &a2);
+        finish(&mut bob, &b2);
+        let output = resume.wait_with_output().unwrap();
+        assert!(output.status.success(), "resume failed: {output:?}");
+        assert_eq!(
</file context>

@kjgbot

kjgbot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

maintainability lens — PASS

Not applicable — this is a short, single-turn code review. Producing the review now.

Maintainability review — PR #215 (durable channels)

Blockers

None. The pure ChannelState, the SQLite bridge, and the protocol handler all interlock cleanly, and the two-place validation (actor + state fold) is a deliberate defense-in-depth pattern.

Concerns

  1. Full-journal rescan on every channel op. relayflowd-journal/src/channel.rs:38-50 reads all entries and folds a fresh RunState + ChannelState for each append/receive/ack. Combined with insert_entry's new unconditional validate_entry call (append.rs:96 + channel.rs:79-92) doing another full rescan, one channel op is O(journal_size) twice. The doc file (DURABLE-CHANNELS.md:41-48) admits "epoch compaction remains scaffolding" — but nothing in code points a future reader at where to bound this. Gate 4's "resident harness" is the whole target of durable channels; a stranger tuning perf in six months will not know whether the O(n) fold is scaffolding or contract.

  2. Silent panics through .expect on payload shape. channel.rs:74 (entry.payload["offset"].as_u64().expect("validated acknowledgement")) and channel.rs:194 (.expect("validated delivery")) trust an invariant enforced elsewhere. If any future path appends a ChannelAcknowledged entry without going through insert_entry's validate_entry hook (a test bypass, a migration tool, a repair script), the kernel panics on read. The invariant should either return an error or be surfaced as a comment naming the enforcement point.

  3. Verb-parameter validation duplicated three ways. server/channels.rs:8-49 uses #[serde(deny_unknown_fields)] on Params, a hand-rolled per-verb allowlist, and an Option-unwrap-and-reject-if-None block. Renaming message_id requires edits in three places, and there's no comment explaining why the allowlist exists alongside serde. Splitting into three per-verb param structs would collapse the branching.

  4. Table-driven crash test hides which cut failed. tests/crash_resume/channels.rs:60-190 loops for cut in ["append", "delivery", "effect", "ack"] inside a single #[test]. Failure names one test; the stranger then reads 130 lines of branching on cut to locate the boundary. Four #[test]s (or #[test_case]) would isolate reproduction. The 303-line file with dense per-cut branching is at the size-smell threshold called out in AGENTS.md §1.

Notes

  • Contract for decide() — "returns proposed entry with seq == 0; caller MUST persist under the same lock" (channel.rs:203-206) — is documented on the method but not asserted anywhere. A debug_assert!(entry.seq == 0) on the proposal path would keep the contract self-checking.
  • Integration test asserts errors by substring: error.to_string().contains("injected channel write failure") (journal/src/channel/tests.rs:78). Fine, but the ChannelError::Invalid(String) free-form messages become de-facto API for callers; consider a typed reason enum before other consumers grow.
  • channel.rs:132 messages.len() as u64 + 1 — unbounded stream, unchecked cast. Practically fine, worth a note if channels are ever expected to run for months.
  • DURABLE-CHANNELS.md honestly scopes the exactly-once claim to the tested cuts and cites 38dcef9 for the red transcript — matches AGENTS.md §"Evidence is captured, not narrated."

REVIEW_PASSED

@kjgbot

kjgbot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

history lens — PASS

Blockers: none. The supplied diff does not meet any of the three HISTORY rejection criteria.

Notes:

  • The implementation follows settled decisions Close Gate 1 deterministic crash-resume rung #2 and flow/drive f59e279 08271341 #7: delivery and acknowledgement are journal facts, and replay reads recorded deliveries without executing consumer code (kernel/relayflowd-core/src/channel.rs:77–85, 99–104). SQLite commits before returning a delivery (kernel/relayflowd-journal/src/channel.rs:24–60); observers are notified afterward (kernel/relayflowd/src/engine/channels.rs:17–23). This does not restore the ephemeral chat-bus coordination the RFC retired.
  • Writable channel admission uses existing stream declarations (kernel/relayflowd-core/src/channel.rs:301–322), consistent with Appendix A rule 1. Socket handling retains worker-lease admission and durable attempt validation (kernel/relayflowd/src/server/channels.rs:74–84). I found no reintroduction of DRIVE-LOG’s previously removed behavior.
  • Commit subjects 38dcef9 (“pin durable channel crash-resume contract”) and 4c87d10 (“journal durable channel delivery and acknowledged offsets”) accurately describe their changes. The first commit contains the original test and red transcript; the implementation follows separately. kernel/evidence/212/README.md:3–6 discloses the subsequent fixture change and explicitly distinguishes test-first evidence from mutation verification, addressing DRIVE-LOG’s recurring evidence-provenance problem.

Concerns, nonblocking:

  • Channel operations require retained historical segments (kernel/relayflowd-journal/src/channel.rs:27–46, 64–71). Bounded snapshots remain necessary before archived segments can be removed. This limitation is expressly documented in the PR body and kernel/DURABLE-CHANNELS.md:47–53; it is an allowed scaffolding deferral.
  • The crash evidence covers named coordination windows, not arbitrary provider-call/confirmation failures. kernel/DURABLE-CHANNELS.md:57–64 states that boundary accurately.

I reviewed the required history, repository instructions, RFC, charter, and operational records. I did not rerun tests; this verdict assesses historical consistency and claim accuracy.

REVIEW_PASSED

@kjgbot

kjgbot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

structure lens — PASS

$ cat docs/RFC-0001-everything-is-a-relayflow.md 2>/dev/null | head -200; echo "---WC---"; wc -l docs/RFC-0001-everything-is-a-relayflow.md 2>/dev/null

RFC-0001: Everything is a Relayflow

  • Status: Draft for review
  • Author: Khaliq (drafted with Claude)
  • Date: 2026-08-27
  • Supersedes/extends: ../relayflows-rewrite-0825/REWRITE-CHARTER.md (2026-08-25) — the charter's settled decisions carry forward unchanged; this RFC replaces its phase list with use-case gates and adds the dogfood rule.
  • Prior art it builds on: the "Six Repos, One Engine" consolidation survey; the sandbox-program runs in .workflow-artifacts/.

1. Thesis

A Relayflow is a deterministic script that composes agentic primitives — an LLM call, an agent, a virtual filesystem, memory, identity, and authorization — into anything from a one-shot pipeline to a resident harness to an entire application. The product thesis in one line: we are taking prompting and making it reliable, with natural rails and gates.

The primitives form a ladder, and every rung is a legal relayflow:

deterministic step          # a pure script — no LLM anywhere (legal; today's validator wrongly rejects zero-agent flows)
  + llm step                # a bare model call — prompt in, verified output out; no PTY, no sandbox
    + agent step            # a harnessed agent in a workspace — artifact + diff + trajectory
      + memory / identity   # context packs in, trajectories out; scoped credentials
        + resident triggers # a proactive agent, a garden, a harness, an application

llm is a kernel-level step type distinct from agent: it has no workspace, its output is a value, and its verification is the rail that makes a prompt reliable. Most flows a customer writes on day one are deterministic + llm steps; agents are the rung you climb to when the step needs hands.

The three covenants

Every gate, surface, and SDK is bound by three covenants, born from real cofounder friction with the current engine:

Covenant 1 — easy to write, easy to read. A relayflow's spec reads like the plan it came from. The measure is the cofounder test: a technical founder writes their first working relayflow in under ten minutes without reading engine docs, and can read a stranger's flow aloud and say what it does. Error messages name the author's mistake in the author's vocabulary, never engine internals. Sage is the zero-syntax on-ramp (conversation → spec). Authoring friction is a gate-blocking defect, not a docs problem.

Covenant 2 — no unexpected failures. A relayflow may fail only in ways it declared. Two mechanisms enforce this:

  • Preflight. At submit time the engine proves everything provable — spec validity, CLI existence and auth health, credential scopes, integration mounts, a worker existing to execute every trigger — and refuses or warns before the run starts on anything it cannot prove. Nothing may fail at minute 27 that was checkable at minute 0. (Evidence from the first dogfood run, 2026-08-27: an unknown cli: grok passed --dry-run and killed the run 27 minutes in; gemini's auth was dead and was discovered mid-run; a cron trigger reported succeeded into a void with no worker enrolled.)
  • Typed failure. At runtime every failure is one of a closed set of declared kinds (gate_failed, verification_failed, budget_exceeded, needs_human, environment_lost, …), journaled with its completionReason. A raw stack trace, a silent wrong-workspace run, or a "succeeded" that did nothing is by definition a kernel bug. A flow with unprovable assumptions starts only after stating them to its author.

Covenant 3 — goals, not babysitting. A flow given a goal runs to completion or to a declared human gate — it never stops to ask permission for work inside its scope, and it never ends a report with "want me to start it?" (if the next step is in scope, it is already started). Human approval exists only where the flow declared it (f.human, merge gates, customer-visible actions, budget ceilings), and when such a gate is reached the ask is delivered, not displayed: routed to the human's channels — Slack, WhatsApp, Telegram, iMessage — carrying the evidence, the exact question, and a one-tap answer, while the run parks durably and every run not blocked on that answer keeps driving. Ten, twenty, thirty concurrent flows must generate approximately zero questions and a short, well-contexted approval queue — or the system has failed this covenant.

The engine underneath must be competitive with Temporal and Inngest as durable execution, and agentic-leading where those engines are structurally blind:

Capability Temporal Inngest Relayflows target
Durability mechanism deterministic code replay step journal + memoization step journal + memoization (replay is semantically wrong for agents — settled decision #2)
Retry semantics transient (same call, same result expected) transient semantic — verification gates + bounded iteration, because an agent's failure mode is wrong output, not no output
Step output JSON return value JSON return value artifact + diff + trajectory — the workspace is part of run state
Resource accounting CPU/memory none tokens + dollars, enforced by the kernel
Human-in-the-loop signals (DIY) waitForEvent (DIY) first-class durable await (needs_human)
Cross-step communication activities are hermetic steps are hermetic durable channels — journaled streams; agents coordinate mid-flight and the coordination survives resume
Memory across runs amnesiac by design amnesiac relayhistory-backed — script-level and per-agent
Integrations activities you write step.run you write relayfile mount — a SaaS is a directory, not an API
Execution placement your workers their infra routed sandboxes — cost/latency/capability-ranked

The kernel remains what the charter's phase 4 specified: step journal, idempotency keys, one lease primitive, durable timers, retry with backoff + jitter, built against a simulated clock, with completionReason on every journal entry and an explicit starting-state contract for agent steps — specified in full in Appendix A.

2. The method: rewrite relayflows using relayflows

The rewrite is not a project about relayflows; it is a program of relayflows. Every capability below ships as a relayflow, and the acceptance gate for each relayflow is that it supports the use case it exists to achieve — not that its tests pass, not that a demo runs once, but that the real consumer (a persona, the garden, chief) runs on it.

Rules of the program:

  1. Each gate is a relayflow in this repo (workflows/gates/gate-N-*.yaml or .ts), runnable by the previous generation of the engine until the new kernel can host it — the same way a compiler bootstraps.
  2. A gate is green only when the real workload runs on it. "hn-monitor runs as a relayflow" means the deployed hn-monitor, not a fixture that resembles it.
  3. Gate runs are journaled and pushed to relayhistory — the rewrite's own trajectory is the first data the memory system serves (gate 5 eats gate 1's output).
  4. No gate may weaken another's invariant. The sandbox-program runs already proved why: a repair agent must never be able to edit the gate that judges it (charter phase 1b). Gate definitions are owned outside the mutating agent's write scope.
  5. The rulebook is alive. The repo runs ../workflows-style maintenance flows continuously (maintain-agent-rules is the template): standards rules are added when a review surfaces a new failure class and pruned when they stop firing — the rulebook grows and shrinks with evidence, never by accretion.
  6. Features solidify into the catalog. As each relayflows feature lands it is solidified three ways (feature-catalog-guardian-audit is the template): tests pin the deterministic code, live runs exercise the agentic product features continuously against the real codebase (a feature that stops working in a real run is a red gate, not a stale demo), and evals score the agentic behavior that tests can't pin.
  7. Every PR is met by a review swarm — our own, not a vendor's. External
    review bots are not review signal: on PR WP-4 — flows check preflight (covenant 2) #8 both reported SUCCESS while
    neither had reviewed (one rate-limited into skipping, one on an expired
    trial). A merge bar that counts a green vendor check is measuring quota,
    not quality. workflows/review-swarm.yaml is the answer: Several proactive review agents fire on each PR — distinct lenses, minimally: maintainability, git history (does this change fit the story of the code), and code structure — the pattern already run on hoopsheet. Each reviewer is itself a relayflow (a gate-2 proactive agent triggered by the PR event), so the review system is built out of the thing it reviews.

The Relayflow Lead

Yes — immediately, and it is the first consumer of this document. The Relayflow Lead is a chief-shaped system fully dedicated to relayflows: it encodes RFC-0001 as its constitution, runs long-lived in the cloud, and Khaliq speaks to it directly. It coordinates the entire product lifecycle — sequencing the gates, dispatching gate work to the Garden/factory machinery that exists today, running the review swarm and the rulebook flows, tracking design-partner acceptance evidence, and reporting state honestly. Per gate 4 it is not a long-running agent but a system: a loop of ephemeral agents over durable state (this RFC, the journal, the repo, its memory). It bootstraps now on the existing persona/chief machinery — the 0825 charter already appointed a relayflows-rewrite-lead; this promotes that role to a resident system — and migrates onto the kernel as gates land, becoming gate 4's first live proof. Two hard rails carried over from the charter, and one of them has since been narrowed rather than removed. Merging is now settled decision #16 — originally "it never merges", amended by Khaliq on 2026-09-05 after the rail and the practice had diverged for a full night of merges before anyone noticed. Gates are unchanged and unconditional: it cannot edit the gates that judge its work (decision #6).

Gate dependency order

1 run ──► 2 proactive ──► 3 garden ──► 4 chief/harness
   │           │
   ├──► 6 integrations (relayfile)      9 self-improving agents
   ├──► 7 sandbox routing                       ▲
   ├──► 8 identity/credentials                  │
   └──► 5 memory ───────────────────────────────┘

Gates 5–8 are horizontal capabilities that start as soon as gate 1 holds and are consumed by 2–4. Gate 9 closes the loop and depends on 5 + 8.


3. The nine gates

Gate 1 — a relayflow can run

Proves: the kernel. Journal + memoization, resume without re-execution of completed steps, deterministic and agent steps, verification as control flow.

Forces into existence: @relayflows/kernel (charter phase 4 + 5): append-only fsync'd journal that fails the step when the write fails (fail-closed, no homeFallback silently leaving the relayfile mount), idempotency keys, leases, durable timers, completionReason, out-of-band step completion — a step an external worker finishes asynchronously (Native's render workers), journaled with the same completionReason discipline as in-process steps — and durable channels: an inter-agent message is a journal append with consumer offsets, at-least-once and replayable, so coordination in flight survives kill -9 like every other kind of state.

Done when: the canonical hello ladder — (a) a pure deterministic flow with zero agents (legalizing what today's validator rejects), (b) the same flow plus a bare llm step with a verification gate, (c) the same flow plus an agent step — each survives kill -9 at every step boundary and between them, resumes completing only unfinished work, and its journal replays results, not code. Budget accounting is exact: the resumed run's token spend equals one execution of each step. Preflight holds (covenant 2): flows check refuses the ladder flows when a declared CLI is missing or unauthenticated or a trigger has no executor, warns on unprovable assumptions before starting, and the failure taxonomy is closed — every failed run's journal terminates in a declared failure kind, never a raw error.

Exists today: runner.ts (11,560 lines, no checkpoint, no backoff) — the thing being replaced. The YAML/TS/Python authoring surface survives as compilers targeting the journal protocol.

Gate 2 — a relayflow can power a proactive agent

Proves: triggers are entry conditions, not schedulers. Webhook (EventFrameV1 via relayfile's webhook server) + agent definition + persona import.

Persona import is first-class: agents: entries already accept persona: resolved through @agentworkforce/persona-registry (packages/core/src/persona-runtime.ts). The gate deepens this: a persona.ts from ../agents or ../internal-agents imports directly — its triggers become the flow's entry conditions, its handler becomes agent steps with ctx.step() boundaries (charter phase 6). A persona is sugar for a relayflow.

Done when: hn-monitor (or linear) runs as a relayflow in production — triggered by its real events, with zero bespoke persistence functions (its current twelve are the measure), retried at step granularity, deduped by idempotency key. The trigger plane is liveness-checked: a schedule or subscription that stops firing is detected and swept (RelayCron's deterministic-id claim + stale_after reconciliation), because a flow that is never triggered is silently zero — Native's silent-death problem.

Exists today: cloud webhook router binds EventFrameV1 matchers to personas but not to workflows (charter phase 3 — scheduleType: "event"); watch/subscriptions fields in the schema.

Gate 3 — a relayflow can power a factory → Software Garden

Proves: the flagship DAG. Discover → implement → review → merge-gate → close, on kernel leases instead of factory's ~10 hand-rolled claim protocols (leaseUntilMs ×71, heartbeat ×490).

The rebrand is part of the gate: Software Garden is the presentation layer a customer authors against without ever meeting a lease, a journal, an attempt counter, or a dedupe key (charter phase 8). Factory's FactoryLoop (~16,900 lines) dies by migration, one claim family per PR (charter phase 7).

Done when: a labeled issue flows to a reviewed PR end-to-end with every claim/lease/retry served by the kernel, the merge gate holding (no auto-merge without opt-in), and the run legible in the journal — while the customer-facing config surface mentions none of it.

Gate 4 — a relayflow can run chief (a relayflow can be a harness)

Proves: resident runs, not resident processes. Chief is not a single long-running agent — it is a system: a loop of many agents, none of them long-running, over durable state. No agent outlives its step; what persists is the run — the journal, the backed filesystem (the relayfile mount), and memory (gate 5). "Chief" names the loop, not a process. That is how it runs for months or years: there is nothing to keep alive, only state to keep consistent. waitFor gates on surfaces, dispatch to the garden, checkpoint back, human approval as a durable await; journal segmentation keeps the unbounded run's journal bounded.

Done when: chief's loop — surface intent → dispatch → checkpoint → approval — runs for a week of real use (design target: indefinitely) with every participating agent ephemeral, waking on triggers and sleeping between them, and the whole system restartable at any moment from journal + mount + memory alone: kill every process, resume, no lost or duplicated dispatches. Skip attaches as a client of the run/event API, proving harness = relayflow + renderer.

The context answer. A chief-like entity does not have a context problem, because it does not have a session. History and context are different things: history is the append-only journal (complete, auditable, never fed wholesale to a model); context is a view assembled per wake — the current epoch summary (structural compaction: everything still live, with the full segment archived losslessly), the triggering event and its surface thread (relayfile), and task-relevant memory packs retrieved from relayhistory, token-budgeted and charged to the step. The model's window bounds the view, never what the system knows. The hard part moves rather than vanishes — from "impossible: window limit" to "tractable: retrieval quality" — which is gate 5's acceptance test and why evals are first-class.

The corollary is a product: what the market sells as "an agent" — Viktor, Tembo, Tasklet, Warp — is in relayflows terms a small system: triggers (gate 2) + ephemeral agent steps + a backed filesystem + memory (gate 5) + identity (gate 8) + performance review (gate 9). It self-improves and never dies because it was never alive. Once gate 4 holds, "build an agent" is an afternoon of authoring, not a product category we have to chase.

Gate 5 — a relayflow has memory: for the script, and per agent

Proves: memory is a kernel-adjacent concept with two scopes:

  • Script memory — the flow's own durable state across runs: prior run outcomes, learned parameters, "what happened last time." Backed by the journal + relayhistory trajectories.
  • Agent memory — per-agent identity-scoped context: before a step, the agent receives a context pack (ai-hist pack / why_for_task); after, its trajectory (decisions, retrospectives) is distilled back (ai-hist learn), and pair serves cited warnings mid-session.

Done when: a step can declare memory: (scope: script | agent, query, budget) and the injected pack demonstrably changes behavior — the acceptance test is an agent avoiding a mistake recorded in a previous run's trajectory, with the citation in its output. Every relayflow run pushes trajectories to relayhistory without opt-in code.

Exists today: relayhistory (Rust, SQLite/FTS5, MCP server, pack/learn/pair) — promoted from tool to core component, consumed over its serialization contract, not rewritten.

Gate 6 — integrations are first-class via relayfile, with no integration primitive

Proves: settled decision #1, taken to its conclusion. The type: integration step and @relayflows/slack-primitive / github-primitive are deleted (browser-primitive stays — nothing covers it). An integration step is a file operation on the relayfile mount, served by @relayfile/adapter-* (50 providers): create a PR by writing a file, read an issue with cat, react to Slack by writing into the tree. Writeback, auth, retry semantics live in the adapter — where they already exist.

Done when: every integration step in the existing example flows (github create-pr, linear update, slack post) expresses as mount reads/writes; the 3,185 transport lines leave runner.ts; and a new provider becomes available to every relayflow by existing as a relayfile adapter, with zero relayflows code.

Gate 7 — a relayflow routes to the right sandbox under the hood

Proves: execution placement is the engine's job. A step declares requirements — interactive PTY vs batch, expected duration, network needs, cost sensitivity — and ../sandbox-router selects from provider pools (../sandbox runtimes: local, daytona, e2b, modal, agent37, …) by its deterministic cost / latency / reliability / balanced ranking. Long-running agents route to agent37 per the 2026-08-23 ruling (~25× cheaper per running-hour); the author writes none of this.

Done when: the same flow YAML runs locally and in cloud with no placement config; the routing decision (profile matched, provider chosen, fallbacks attempted) is a journal entry; and killing a sandbox mid-step resumes per gate 1's contract with the workspace pinned by relayfile revision.

Gate 8 — agent identity, scoped credentials, traceable work

Proves: every agent in a flow is a principal. Stable identity per agent (not per process), credentials resolved through the proxy (AgentCredentialConfig exists; the gate makes it the only path — no ambient env inheritance), scoped by the flow's permissions model (file globs, network allowlists, access presets) and relayfile ACLs, revocable mid-run.

Done when: for any side effect of any run — a file write, a PR, a Slack message — the journal answers which agent, under which credential scope, in which step, why (completionReason + identity attribution). An agent given readonly provably cannot write through any path: direct fs, mount writeback, or exec.

Gate 9 — agents that continuously improve, as relayflow steps

Proves: the loop closes with no new machinery. Performance review is just steps: a reviewer agent scores a run's trajectory against its verification record, writes findings to relayhistory (learn), and the next run's memory injection (gate 5) carries them. Model/prompt/persona adjustments proposed by review are themselves gated relayflows (a persona change is a PR through the garden — gate 3 — approved by a human — gate 4's approval primitive).

Self-authoring is the strong form. Because the composable unit is a spec — data, not code — writing a relayflow is just a step whose output is a spec. A relayflow system improves by authoring relayflows for itself on the fly, the way ../ricky already sketches at product level: monitor a run → diagnose the failure or quality gap → author a new or amended flow → ship it through the Garden as a gated change → resume. Ricky's entire feature list (debug, fix, restart safely, analyze quality over time, suggest improvements, generate workflows) dissolves into relayflows over the journal. The rails hold precisely here: a self-authored flow passes the same verification gates and human approvals as a human-authored one, and it can never widen its own permissions or edit the gates that judge it (settled decision #6). The system builds and enhances itself; the gates decide what ships.

Done when: two chains are demonstrated in journals. Learning: run N+1 measurably outperforms run N on its own verification metrics because of an injected learning from N's review step, over a multi-week window. Self-authoring: in response to an observed failure or quality signal, the system authors a flow change, ships it through the Garden with the required approval, and the change measurably resolves the signal — ricky's monitor → diagnose → fix → resume loop, rebuilt as relayflow steps, with every link (trajectory → diagnosis → authored spec → gated deploy → improved outcome) visible.


4. The language decision

We are starting from scratch, so this is decided here, not inherited:

The kernel and control plane are Rust. Everything a user or product touches is TypeScript-first.

  • relayflowd (Rust): the journal, scheduler, leases, durable timers, and event router ship as one static binary on the same SQLite substrate relayhistory already owns — journal and memory become one storage engine, and gate 5 stops being an integration and becomes a table. It runs embedded under the CLI for local dev and hosted for cloud, and the same binary is the self-host story for design partners with compliance requirements. The kernel never holds provider SDKs — LLM calls and agent execution happen SDK-side or in routed sandboxes.
  • SDKs and surfaces (TypeScript, then Python): the authoring builder, YAML compiler, personas, Garden, chief, sage, nightcto — the entire estate is TS and stays TS. Authoring never requires Rust.
  • The journal protocol is the boundary. SDKs speak it over local socket/HTTP; Skip (Swift) and any future surface are clients of the same contract.

Why not TypeScript all the way down, given the velocity argument: the kernel is the component that must never lose data and runs for years, and we have already measured where "engine written in the app language" ends — an 11,560-line runner whose largest concern is resolving Slack channel IDs. A binary you call over a protocol cannot absorb product logic; the language boundary enforces the architectural boundary. The cost — slower initial kernel velocity — is bounded because the kernel is deliberately small (§1) and built against a simulated clock with no I/O.

5. Consumers and the sales motion

The gates exist to be sold, not admired. The consumer list, in order of proof value:

  • Native (../customer-agents/native) — the first and most important design partner, and the prime pipeline use case: Autopilot is a per-brand daily tick restoring one invariant — the next 14 days must contain N posts per week. The POC already runs as a relayflow, and it teaches the engine four things the gates must absorb:

    1. Reconciliation over retries — failed work releases its slot, the gap reappears in the planner, the next tick fills it. There is no retry queue. The kernel's retry policy (gate 1) must be optional machinery, not the only shape of self-healing; invariant-restoring loops are a first-class flow pattern.
    2. Deterministic gates around untrusted agents — the invariant is a pure function at the front and a deterministic verify-invariant gate at the back; no agent is ever trusted to assert the calendar is full. This is the "rails and gates" thesis running at a customer.
    3. Out-of-band step completion — nothing awaits an image; render workers complete posts asynchronously and a later step picks up whatever became ready. The journal needs a step state completable by an external worker, not only by the step's own process.
    4. Trigger liveness — Native's sibling-engine story: built, allowlisted, never provisioned, silently zero for weeks. A flow that is never triggered reports nothing. RelayCron's deterministic-id single-winner claim + stale_after sweep is the answer, and gate 2's trigger plane inherits it as a requirement, not an option.

    Autopilot's automationSignature consent model — every automated action attributable and withdrawable, nothing a human touched ever revoked — is gate 8's evidence at a customer, alongside the SOC 2 plan below.

  • Sage (../sage) — PDERO's Plan phase already "produces structured plans that become relay workflow definitions." That makes sage the natural authoring frontend: conversation → plan → relayflow spec. Sage is both powered by relayflows (its own loop — research, clarify, remember, plan — is a resident relayflow: gates 2 + 4 + 5) and its output is relayflows. Rewriting sage on relayflows is the proof that an application is a relayflow.

  • NightCTO (../nightcto) — rewritten by relayflows and running on relayflows: the Software Garden (gate 3) performs the rewrite as its own gated program, and the result — per-client resident personas over WhatsApp/Slack/Telegram/Signal, webhook-driven monitoring, sandbox agents that sleep and wake — is gates 2 + 4 + 7 as a $149/mo product. Dogfood squared: the engine rebuilds a product onto itself.

  • Ricky (../ricky) — dissolves into the platform: workflow reliability, coordination, and authoring become relayflows over the journal, and its monitor → diagnose → fix → resume loop is gate 9's self-authoring chain. Ricky the product becomes the first resident consumer of the kernel's own observability.

  • The "agent" category — the competitive answer to Viktor / Tembo / Tasklet / Warp falls out of gate 4's corollary: an agent is a named identity + trigger set + backed filesystem + memory, executed as ephemeral steps and improved by gate 9. We don't build an agent product; we make agents an afternoon of authoring on the platform — with rails and gates the incumbents don't have.

  • Design partners — Julian (Nabis) and John (SecLock) and everyone in ../sales. Julian's certification run (sales/nabis/julian-fann/RELAYFLOWS-DEFECTS.md) is the acceptance evidence the gates must retire: partially-scoped credentials silently swallowing writebacks (gate 8: fail-closed credential resolution), a failing lane's output never surfaced (gate 1: completionReason + journal legibility), gates failing open (settled decision regressions: relaycast workspace-key repair answers an untyped 500 #6). A gate isn't sellable until the defect class it covers can't recur by construction. The SOC 2 traceability plan in the same folder is gate 8's commercial spec.

---WC---
245 docs/RFC-0001-everything-is-a-relayflow.md

Structure review — PR #215 (durable channels)

Verdict context. The RFC explicitly names durable channels a kernel primitive (Gate 1: "an inter-agent message is a journal append with consumer offsets"). So the three new EntryType variants and three verbs are not a gratuitous primitive-vs-helper violation — they are mandated. The shape is mostly sound.

Boundaries that hold. The split is clean three ways: relayflowd-core::channel (pure fold/decide, no I/O — matches "no I/O in core logic"), relayflowd-journal::channel (SQLite IMMEDIATE transaction + validation), relayflowd::engine/server::channels (lease-checking protocol surface). state.rs deliberately ignores channel facts in the run/step state machine (channel.rs:183 region) — correct: they never affect run/step state, same treatment as StreamAppended/EffectRecorded. Fail-closed is preserved: validate_entry rejects forged/out-of-order appends inside the journal transaction, and channel_command refuses terminal runs before mutating.

Concerns.

  1. Product/admission logic in the kernel's pure core. validate_channel_actor (core/channel.rs:296-330) reaches into StepKind::Agent, surfaces.streams write-declaration, and StepState::Running { attempt, idempotency_key }. RFC §4 is blunt: "a binary you call over a protocol cannot absorb product logic." Surface-declaration auth is arguably Appendix-A starting-state territory and core already owns spec.rs surface canonicalization, so this is borderline — but the lease/attempt matching is session logic sitting beside a projection that has "no I/O, timers, or session state" in its doc comment (channel.rs:~66). At minimum the validate_channel_actor doc comment ("The socket additionally verifies…") is a misleading label — it lives in core, not the socket.

  2. Two enforcement paths with divergent cost. channel_command (journal/channel.rs) and the validate_entry hook both fold full ChannelState; the hook re-scans all retained entries (read_entries, ORDER BY seq) on every raw append. Combined with RunSpec::parse + RunState::fold per channel op, each message is O(journal) in spec-parse and fold work. The docs admit boundedness is scaffolding — acceptable for gate 1, but the journal layer parsing the SDK spec (RunSpec::parse(&payload.spec)) crosses the journal→authoring boundary.

Notes. Sizes are compliant (largest is core/channel.rs at 331 lines). decide() returning seq == 0 proposals whose persistence is caller-locked is a sharp but undocumented-there contract; worth a helper or a TypedEntry wrapper later.

REVIEW_PASSED

@kjgbot

kjgbot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

🎯 review-swarm: PASSED (M:pass H:pass S:pass)

Lens transcripts posted as sibling comments above.

@kjgbot
kjgbot merged commit b5896a8 into main Sep 7, 2026
3 of 4 checks passed
@kjgbot

kjgbot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

Independent 3-lens review: 2 pass, 1 fail — not signed off

I did not author this change, so I can serve as the independent reviewer. Ran the repo's own gate (ops/preswarm-check/lens-runner.sh) against origin/main..HEAD:

PRESWARM_structure:       REVIEW_PASSED
PRESWARM_history:         REVIEW_PASSED
PRESWARM_maintainability: REVIEW_FAILED   <-- blocks

CI's real gate is green — linux-x64-artifact passes, which is the job running cargo test --workspace, so the new crash-resume test genuinely passes. The red review check is the known empty CLOUD_API_KEY and affects every PR in this repo equally.

What blocks (maintainability lens)

Two of these fail silently, which is why they block rather than annotate:

  1. channel.receive collapses into a _ => fallthrough (server/channels.rs:71). A future fourth verb reaching that dispatch is silently interpreted as receive. Wants an explicit "channel.receive" arm.
  2. JournalStoreError::Channel recovered via error.downcast_ref (server/channels.rs:84-88). If anyone adds .context(...) to engine.channel_command, the downcast misses and every conflict becomes internal_error. Return a typed error rather than round-tripping through anyhow.
  3. Typed payloads defeated by string-literal access (channel.rs:222-227,146-163,194,259-261). ChannelAppendedPayload/ChannelDeliveredPayload are declared directly above, then the fold re-reads entry.payload["producer"]. Renaming a field breaks dedup and offset logic with no compiler help.
  4. Double journal fold per call (relayflowd-journal/src/channel.rs:39-49) — disclosed as scaffolding, but wants a TODO marker so the next reader doesn't compound it.

Worth resolving even though it didn't block (structure lens)

The kernel now has two overlapping message-stream vocabularies. The existing stream.* primitive (stream.appended, append_stream/read_stream, next_stream_offset) is alive alongside the new channel.* family. RFC-0001 settled decision #7 says "channels are kernel streams… agents move to a new stream API" — i.e. stream is the channel. The durable delivered/acknowledged offsets arguably should have extended stream, or stream should retire, rather than landing a parallel sibling in a vocabulary RFC #13 declares closed.

That is a design call above my pay grade as reviewer, but it is much cheaper to settle now than after both primitives harden.

Credit where due

The lanes agreed on the good parts: test-first red/green evidence captured honestly, DURABLE-CHANNELS.md states plainly what is not proved rather than overclaiming, and failed_channel_writes_never_expose_delivery_or_advance_acknowledged_offset would fail loudly if someone stripped the fail-closed behaviour.

Sending back to kernel-channels-0907. Not merging — the gate failed, and I do not hold the merge gate regardless.

kjgbot pushed a commit that referenced this pull request Sep 7, 2026
…gged for Khaliq

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR
kjgbot pushed a commit that referenced this pull request Sep 7, 2026
… filed #217

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR
kjgbot pushed a commit that referenced this pull request Sep 7, 2026
… on CI

Explains #221 merging red and #215 merging over a failed lens: the loop checks a
review-swarm marker, mergeability and a commenter allowlist, with zero CI
references. kjgbot is allowlisted, so the lead's own objection cannot block.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR
kjgbot added a commit that referenced this pull request Sep 8, 2026
…on (#229)

* fix(review-gate): derive the lens verdict from its own Blockers section

Two failures in one day, both from the same root: the verdict token is a
separate judgement from the findings, and the two consumers ask different
questions.

**1. A verdict that contradicted its own review.** On #227 the maintainability
lens printed:

    ### Blockers
    None. The invariants that could break silently do fail closed ...
    REVIEW_FAILED

No blockers, and REVIEW_FAILED. A caller cannot appeal that: lens-runner.sh
makes the exit code authoritative on purpose, because a substring gate would be
fail-open. So a broken review blocks finished work with no recourse.

The prompt now makes the token DERIVED rather than chosen: head a section
exactly `### Blockers`, write None when there are none, and the token follows
from that section. Concerns and notes are explicitly not blockers and must not
change it.

The runner also detects the contradiction and labels it:

    PRESWARM_<lens>: CONTRADICTION — review says 'Blockers: None' but emitted
    REVIEW_FAILED; treating as NO_VERDICT (gate defect, not a finding)

This NEVER upgrades a verdict. Exit stays 1. Turning a failure into a pass on a
substring is exactly the fail-open the classifier refuses; relabelling one so a
branch is not blamed for a gate defect is not.

**2. Prompt drift between the two consumers.** `lens-runner.sh` carried detailed
prompts while `review-swarm.yaml` carried one-line summaries with every specific
instruction stripped — and auto-merge acts on the swarm, the weaker of the two.
That is how #215 merged with defects the local run had named. The three roles now
carry the same clauses as the runner, including the Blockers-derivation rule.

Verified the detector against six shapes, including the two that matter:

    "### Blockers\nNone. The invariants..."          -> NONE   (the real #227 text)
    "### Blockers\n1. real\n### Concerns\nNone."     -> HAS    (not fooled by a later None)
    no Blockers section at all                        -> HAS    (fail-closed)
    "### Blockers\n\nNone."                          -> NONE   (blank line tolerated)
    "**None** — nothing blocking"                     -> NONE   (bold tolerated)
    two numbered blockers                             -> HAS

`bash -n` clean; review-swarm.yaml still parses.

Does not consolidate the prompts into one file both consumers read — that is the
end state #218 proposes and needs the swarm spec to load role text from disk.
This makes them agree and adds the derivation rule; the single source of truth
is still open.

Refs #218, #227, #215

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR

* fix(review-gate): a PASS that lists blockers is also a contradiction

An independent spec review found a fail-open in my own fix. The REVIEW_PASSED
arm checked only the CLI exit code and never consulted the Blockers section, so
a review that enumerated blockers -- unauthorized writes among them -- and
ended in REVIEW_PASSED exited 0.

The comment above that arm claims the classifier "NEVER upgrades a verdict",
and it does not. That was the wrong safety property to reason about. One-
directional safety guards fail->pass, which fails CLOSED anyway, and leaves the
fail-OPEN direction unguarded, which is the only direction a gate cannot afford
to get wrong. I wrote that comment as a proof of safety; it was a proof about
the harmless half.

`blockers_are_listed` is deliberately NOT the negation of `blockers_say_none`:
an ABSENT Blockers section returns false, so a review that never emitted the
section keeps its previous behaviour rather than newly failing. That closes the
unambiguous hole without changing the blast radius for non-conforming lenses.

Verified across all five arms:

    blockers listed + PASSED   -> CONTRADICTION (exit 1)   was: exit 0
    Blockers: None  + PASSED   -> REVIEW_PASSED  (exit 0)
    no section      + PASSED   -> REVIEW_PASSED  (exit 0)  unchanged
    Blockers: None  + FAILED   -> CONTRADICTION (exit 1)
    blockers listed + FAILED   -> REVIEW_FAILED  (exit 1)

Direction of the change is strictly tightening: it can only turn a pass into a
non-verdict, never a failure into a pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR

* fix(review-gate): read the LAST Blockers section, not the first

The P2 from the same spec review, and it defeated the P1 fix I shipped an hour
ago. Both helpers used:

    awk '/^#+[[:space:]]*Blockers[[:space:]]*$/{f=1;next} f&&NF{print;exit}'

which flags on the FIRST matching heading and exits at its first body line. A
review with an early "Blockers: None" summary and a later real section is read
as "None":

    first-match awk -> None                       (guard passes the review)
    last-match awk  -> - unauthorized write       (guard blocks it)

So the fail-open I closed was still reachable through a differently-shaped
review, and `blockers_are_listed` inherited the flaw the moment I wrote it on
top of the same pattern.

The comment above these helpers has said "the LAST `### Blockers` heading"
since the original change. The code never did that. A comment describing
intent rather than behaviour is worse than no comment: I read it twice while
fixing P1 and took it as a description of what the code did.

Both helpers now accumulate to the last matching section. Verified across seven
arms, including the two multi-section cases that motivated this:

    early None + LATER real blockers + PASSED -> CONTRADICTION (exit 1)
    early real + LATER None          + FAILED -> CONTRADICTION (exit 1)
    blockers listed + PASSED                  -> CONTRADICTION (exit 1)
    Blockers: None  + PASSED                  -> REVIEW_PASSED  (exit 0)
    no section      + PASSED                  -> REVIEW_PASSED  (exit 0)
    Blockers: None  + FAILED                  -> CONTRADICTION (exit 1)
    blockers listed + FAILED                  -> REVIEW_FAILED  (exit 1)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR

* fix(review-gate): a missing Blockers section is NO_VERDICT, not a pass

cubic P1 on #229. A lens that emits REVIEW_PASSED with no `### Blockers`
section cleared the gate, because `blockers_are_listed` returns false for an
absent section and the PASSED arm read that as "no blockers".

My own comment defended this: an absent section should "keep its previous
behaviour instead of newly failing", to avoid widening the blast radius. That
was protecting a case the prompt already forbids — it requires the heading and
says the first word under it must be `None` when there are none. A review
without it has not answered the question the gate asks.

Add `blockers_section_present` as a separate guard rather than overloading the
existing boolean, so the log distinguishes "the lens contradicted itself" from
"the lens ignored the output contract"; those need different fixes.

Verified against all four cases: no section + PASSED -> NO_VERDICT; None +
PASSED -> PASSED; blockers listed + PASSED -> CONTRADICTION; and a second
Blockers section listing one still reads the LAST section.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR

* fix(review-gate): require the exact `### Blockers` heading level

cubic P2 on #229, on the guard I added an hour earlier. `blockers_section_present`
matched `^#+`, so a review headed `# Blockers` or `#### Blockers` satisfied the
missing-section guard and could still pass. A review at the wrong heading level
has not followed the output contract the prompt states.

Deliberately stricter than `blockers_are_listed` and `blockers_say_none`, which
keep matching `^#+`. That asymmetry is the point: this function decides whether
a section COUNTS, so it must fail closed on a wrong level, while those two only
DETECT blockers, where being permissive also fails closed.

Verified: `###` + None passes; `#`, `##`, `####` and no section are all
NO_VERDICT; `###` + a listed blocker is still CONTRADICTION.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR

---------

Co-authored-by: kjgbot <kjgbot@agentrelay.dev>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

kernel: durable channels — the last named gate 1 capability

1 participant