From 28e00de147cdfcd8287e53b8f435e4a4a8b37ce9 Mon Sep 17 00:00:00 2001 From: Miya Date: Fri, 11 Sep 2026 20:33:50 +0200 Subject: [PATCH 1/2] feat(sdk,kernel): agent sidechannel byte-stream proof (#334) Session-Id: 01a091b3-c030-73f3-8f0f-3ff532718e9d Session-Id: 01a091de-4529-7e13-9a6e-dee6d30020dc Session-Id: efeda5df-9b7c-48d4-b2ce-957f5bef0a82 --- docs/SURFACE.md | 36 ++ docs/evidence/spec-Q/README.md | 54 +++ docs/evidence/spec-Q/build.txt | 4 + docs/evidence/spec-Q/resume-cli.txt | 10 + docs/evidence/spec-Q/rust-memoization.txt | 38 ++ docs/evidence/spec-Q/rust-release.txt | 127 ++++++ docs/evidence/spec-Q/rust-workspace.txt | 391 +++++++++++++++++ docs/evidence/spec-Q/sdk-final.txt | 199 +++++++++ docs/evidence/spec-Q/sdk-focused.txt | 31 ++ docs/evidence/spec-Q/sdk-initial.txt | 405 ++++++++++++++++++ docs/evidence/spec-Q/typecheck-tests.txt | 4 + docs/evidence/spec-Q/typecheck.txt | 4 + kernel/relayflowd-core/src/entry.rs | 2 + kernel/relayflowd-core/src/machine.rs | 3 + kernel/relayflowd-core/src/machine/cancel.rs | 1 + .../src/machine/parallel_tests.rs | 1 + .../relayflowd-core/src/machine/recovery.rs | 1 + kernel/relayflowd-core/src/machine/tests.rs | 3 + kernel/relayflowd-core/src/memoization.rs | 3 +- kernel/relayflowd-core/src/state/tests.rs | 1 + kernel/relayflowd-core/tests/memoization.rs | 3 + kernel/relayflowd/src/engine.rs | 32 ++ kernel/relayflowd/src/engine/remote.rs | 22 +- kernel/relayflowd/src/exec_det.rs | 2 + kernel/relayflowd/src/main.rs | 1 + kernel/relayflowd/src/server.rs | 20 +- .../src/server/tests/agent/contract.rs | 69 +++ kernel/relayflowd/src/server/wire.rs | 10 + kernel/relayflowd/tests/budget_gate.rs | 1 + kernel/relayflowd/tests/parallel_driver.rs | 1 + packages/sdk/src/cli.ts | 22 +- packages/sdk/src/cli/direct-run.ts | 2 +- packages/sdk/src/cli/replay.ts | 13 +- packages/sdk/src/cli/run.ts | 20 +- packages/sdk/src/failure-kinds.ts | 1 + packages/sdk/src/journal-client.ts | 5 +- packages/sdk/src/local-agent.ts | 3 +- packages/sdk/src/protocol.ts | 2 + packages/sdk/src/pty-sidechannel.ts | 81 ++++ packages/sdk/src/worker-cli.ts | 20 +- packages/sdk/src/worker.ts | 9 +- packages/sdk/tests/cli-replay.test.ts | 10 + packages/sdk/tests/cli.test.ts | 18 + packages/sdk/tests/pty-sidechannel.test.ts | 72 ++++ 44 files changed, 1725 insertions(+), 32 deletions(-) create mode 100644 docs/evidence/spec-Q/README.md create mode 100644 docs/evidence/spec-Q/build.txt create mode 100644 docs/evidence/spec-Q/resume-cli.txt create mode 100644 docs/evidence/spec-Q/rust-memoization.txt create mode 100644 docs/evidence/spec-Q/rust-release.txt create mode 100644 docs/evidence/spec-Q/rust-workspace.txt create mode 100644 docs/evidence/spec-Q/sdk-final.txt create mode 100644 docs/evidence/spec-Q/sdk-focused.txt create mode 100644 docs/evidence/spec-Q/sdk-initial.txt create mode 100644 docs/evidence/spec-Q/typecheck-tests.txt create mode 100644 docs/evidence/spec-Q/typecheck.txt create mode 100644 packages/sdk/src/pty-sidechannel.ts create mode 100644 packages/sdk/tests/pty-sidechannel.test.ts diff --git a/docs/SURFACE.md b/docs/SURFACE.md index 460f1d27f..655a8cbe5 100644 --- a/docs/SURFACE.md +++ b/docs/SURFACE.md @@ -561,6 +561,42 @@ flows resume [--json] [--no-spawn] [--data-dir ] flows observer [--data-dir ] ``` +### Agent sidechannel (initial byte-stream slice) + +Local agent workers (`flows run --local-agent`) open +`/runs//steps//pty.sock` for the raw Claude/Codex +adapters and print `PTY ` on stderr. SDK workers opt in with `dataDir` +and can receive the path through `onPtyReady`. Run and step IDs come from the +kernel dispatch, including for authored `f.agent` calls. + +A subscriber sends `HELLO view\n`, `HELLO drive\n`, or +`HELLO passthrough\n`, then receives live stdout/stderr bytes. View and +passthrough are passive. Only drive forwards subsequent bytes to child stdin. +There is no backlog, terminal resize, or framing after the greeting. Socket +access is restricted to the worker's OS user. Slow, malformed, excess, and +broken subscribers are disconnected independently; absent subscribers or an +unavailable socket do not fail a step or change its lease/deadline handling. + +Operator input stays off journal. Influenced output follows the usual worker +output decoding and completion path. Any accepted drive greeting marks +`step.completed.human_intervention: true`, even without input. Passive and +unattached completions omit the field. The marker persists on failed attempts +as well, and marked completions are excluded from cross-run memoization. + +`flows resume` refuses marked runs with exit 2 and +`REFUSED [human_influenced_run] step ""` before recovery, unless passed +`--allow-human-influenced`. The same flag permits `flows replay` to cross a +marked completion; replay may already have printed earlier entries when it +refuses. `--at` can still inspect a prefix before that boundary. The flag is +per invocation and does not clear the journal marker. + +**Follow-up scope:** this minimal slice forwards the existing process pipes; +it does not yet allocate an actual terminal. True PTY/resize support, +wrapper-session attachment, crash-safe intervention recording before a worker +completion, and the companion `agent-relay attach --external-pty` client are +follow-ups. Long UNIX socket paths and stale socket files disable this optional +channel; they do not prevent agent execution. + `flows observer` prints a single `https://agentrelay.com/observer?key=` URL to stdout using the same mint used by `flows run`. It is daemon-free: no socket is opened, no `relayflowd` binary is invoked, the data dir is not diff --git a/docs/evidence/spec-Q/README.md b/docs/evidence/spec-Q/README.md new file mode 100644 index 000000000..5cd83af12 --- /dev/null +++ b/docs/evidence/spec-Q/README.md @@ -0,0 +1,54 @@ +# Slice Q verification + +This is a minimal byte-stream proof for #334, with completion markers and +resume/replay protection. Actual PTY allocation, wrapper-session attachment, +crash-safe intervention recording before completion, and the companion +agent-relay external-pty client remain follow-ups (see SURFACE.md §5). + +Run/step IDs already arrive in AgentWorker's kernel dispatch; no redundant +identity plumbing was added to authored-worker-step.ts. This tree has no +separate resume.ts: resume lives in cli/run.ts and argument parsing in cli.ts. + +All commands ran in this worktree on 2026-09-11. Each linked file is captured +command output. Exit statuses below refer to the completed tool processes. + +## SDK + +From packages/sdk: + +- `npm run typecheck` — exit 0, [output](typecheck.txt). +- `npm run typecheck:tests` — exit 0, [output](typecheck-tests.txt). +- `npm run build` — exit 0, [output](build.txt). +- `npx vitest run tests/pty-sidechannel.test.ts tests/worker-cli.test.ts tests/worker-cli-abort.test.ts tests/cli-replay.test.ts` + — exit 0, 57 passed, [output](sdk-focused.txt). +- `npx vitest run tests/cli.test.ts -t 'human-influenced resume'` + — exit 0, 1 passed / 63 excluded by filter, [output](resume-cli.txt). +- `RELAYFLOWD_BIN=/Users/khaliqgant/.relayflows-toolchain/target/3923540030/debug/relayflowd npx vitest run` + — exit 1, [initial output](sdk-initial.txt). Bun was absent from PATH, + the live Claude analyzer was unavailable, and one live-kernel test timed out. +- `PATH=/Users/khaliqgant/.bun/bin:$PATH RELAYFLOWS_ALLOW_ANALYZER_SKIP=1 RELAYFLOWD_BIN=/Users/khaliqgant/.relayflows-toolchain/target/3923540030/debug/relayflowd npx vitest run` + — exit 0, 1215 passed / 4 skipped, [final output](sdk-final.txt). + This is not gate-2/live-analyzer acceptance evidence. The analyzer skip is + explicit; the initial unskipped failure is retained above. + +Dependencies: SDK `npm ci --ignore-scripts`, then root +`npm install ./packages/surface --prefix packages/sdk --no-save --ignore-scripts`. +The local surface needed a build; its pre-existing lockfile was out of sync, +so it was installed with `npm install --ignore-scripts --package-lock=false` +and built with `npm run build`. No dependency manifests/locks changed. + +## Rust + +From the repository root unless otherwise noted: + +- `PATH=/Users/khaliqgant/.cargo/bin:$PATH ops/cargo.sh test --manifest-path kernel/Cargo.toml --workspace` + — exit 0, [output](rust-workspace.txt), including the new durable marker, + refusal, unchanged-journal-on-refusal, and explicit-override test. +- From kernel: `PATH=/Users/khaliqgant/.cargo/bin:$PATH ../ops/cargo.sh build --locked --release -p relayflowd` + — exit 0, [output](rust-release.txt). +- `PATH=/Users/khaliqgant/.cargo/bin:$PATH ops/cargo.sh test --manifest-path kernel/Cargo.toml -p relayflowd-core --test memoization` + — exit 0, [output](rust-memoization.txt). This final focused run includes + the added assertion excluding human-influenced completions from reuse. + +The initial cargo invocation lacked rustc on PATH; the commands above use the +existing toolchain. `git diff --check` also exited 0. diff --git a/docs/evidence/spec-Q/build.txt b/docs/evidence/spec-Q/build.txt new file mode 100644 index 000000000..d4aad672d --- /dev/null +++ b/docs/evidence/spec-Q/build.txt @@ -0,0 +1,4 @@ + +> @relayflows/sdk@2.0.8 build +> tsc && node scripts/make-cli-executable.mjs + diff --git a/docs/evidence/spec-Q/resume-cli.txt b/docs/evidence/spec-Q/resume-cli.txt new file mode 100644 index 000000000..9b8eea726 --- /dev/null +++ b/docs/evidence/spec-Q/resume-cli.txt @@ -0,0 +1,10 @@ + + RUN v2.1.9 /Users/khaliqgant/flows-spec-Q-sidechannel/packages/sdk + + ✓ tests/cli.test.ts (64 tests | 63 skipped) 8ms + + Test Files 1 passed (1) + Tests 1 passed | 63 skipped (64) + Start at 20:32:05 + Duration 1.18s (transform 450ms, setup 0ms, collect 792ms, tests 8ms, environment 0ms, prepare 71ms) + diff --git a/docs/evidence/spec-Q/rust-memoization.txt b/docs/evidence/spec-Q/rust-memoization.txt new file mode 100644 index 000000000..cf949166c --- /dev/null +++ b/docs/evidence/spec-Q/rust-memoization.txt @@ -0,0 +1,38 @@ + Compiling syn v3.0.4 + 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 thiserror-impl v2.0.20 + Compiling ref-cast v1.0.27 + Compiling zerovec v0.11.8 + Compiling zerotrie v0.2.5 + Compiling thiserror v2.0.20 + Compiling serde v1.0.229 + Compiling tinystr v0.8.4 + Compiling potential_utf v0.1.6 + Compiling icu_collections v2.3.0 + Compiling icu_locale_core v2.3.0 + Compiling icu_provider v2.3.1 + Compiling ahash v0.8.12 + Compiling fluent-uri v0.3.2 + Compiling email_address v0.2.9 + Compiling ulid v1.2.1 + Compiling icu_normalizer v2.3.0 + Compiling icu_properties v2.3.0 + Compiling referencing v0.33.0 + Compiling idna_adapter v1.2.2 + Compiling idna v1.1.0 + Compiling jsonschema v0.33.0 + Compiling relayflowd-core v0.1.0 (/Users/khaliqgant/flows-spec-Q-sidechannel/kernel/relayflowd-core) + Finished `test` profile [unoptimized + debuginfo] target(s) in 7.60s + Running tests/memoization.rs (/Users/khaliqgant/.relayflows-toolchain/target/3923540030/debug/deps/memoization-3560adb32830fb1c) + +running 4 tests +test distinct_large_kernel_integers_do_not_alias_through_float_rounding ... ok +test match_reuses_output_with_provenance_and_zero_cost_without_dispatch ... ok +test changed_spec_or_input_dispatches_and_legacy_or_failed_records_miss ... ok +test canonical_corpus_agrees_with_typescript_and_key_permutations ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + diff --git a/docs/evidence/spec-Q/rust-release.txt b/docs/evidence/spec-Q/rust-release.txt new file mode 100644 index 000000000..53a1c1bf0 --- /dev/null +++ b/docs/evidence/spec-Q/rust-release.txt @@ -0,0 +1,127 @@ + Compiling proc-macro2 v1.0.107 + Compiling unicode-ident v1.0.24 + Compiling quote v1.0.47 + Compiling libc v0.2.189 + Compiling stable_deref_trait v1.2.1 + Compiling version_check v0.9.5 + Compiling autocfg v1.5.1 + Compiling cfg-if v1.0.4 + Compiling serde_core v1.0.229 + Compiling getrandom v0.3.4 + Compiling zerocopy v0.8.56 + Compiling smallvec v1.15.2 + Compiling serde v1.0.229 + Compiling litemap v0.8.3 + Compiling num-traits v0.2.19 + Compiling memchr v2.8.3 + Compiling writeable v0.6.4 + Compiling generic-array v0.14.7 + Compiling utf8_iter v1.0.4 + Compiling icu_normalizer_data v2.3.0 + Compiling icu_properties_data v2.3.0 + Compiling parking_lot_core v0.9.12 + Compiling syn v3.0.4 + Compiling syn v2.0.119 + Compiling typenum v1.20.1 + Compiling zmij v1.0.23 + Compiling ref-cast v1.0.27 + Compiling synstructure v0.13.2 + Compiling zerofrom-derive v0.1.7 + Compiling yoke-derive v0.8.2 + Compiling num-integer v0.1.47 + Compiling zerofrom v0.1.8 + Compiling num-bigint v0.4.8 + Compiling aho-corasick v1.1.5 + Compiling ahash v0.8.12 + 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 scopeguard v1.2.0 + Compiling find-msvc-tools v0.1.11 + Compiling shlex v2.0.1 + Compiling regex-syntax v0.8.11 + Compiling num-rational v0.4.2 + Compiling cc v1.4.4 + Compiling lock_api v0.4.14 + Compiling ppv-lite86 v0.2.21 + Compiling yoke v0.8.3 + Compiling num-iter v0.1.46 + Compiling num-complex v0.4.6 + Compiling rand_core v0.9.5 + Compiling pkg-config v0.3.34 + Compiling once_cell v1.21.4 + Compiling regex-automata v0.4.18 + Compiling bit-vec v0.8.0 + Compiling borrow-or-share v0.2.4 + Compiling vcpkg v0.2.15 + Compiling itoa v1.0.18 + Compiling bit-set v0.8.0 + Compiling num v0.4.3 + Compiling rand_chacha v0.9.0 + Compiling parking_lot v0.12.5 + Compiling libsqlite3-sys v0.35.0 + Compiling crypto-common v0.1.7 + Compiling block-buffer v0.10.4 + Compiling percent-encoding v2.3.2 + Compiling zerovec v0.11.8 + Compiling zerotrie v0.2.5 + Compiling uuid v1.26.0 + Compiling fluent-uri v0.3.2 + Compiling vsimd v0.8.0 + Compiling foldhash v0.1.5 + Compiling thiserror v2.0.20 + Compiling outref v0.5.2 + Compiling lazy_static v1.5.0 + Compiling utf8parse v0.2.2 + Compiling fraction v0.15.4 + Compiling anstyle-parse v1.0.0 + Compiling uuid-simd v0.8.0 + Compiling referencing v0.33.0 + Compiling tinystr v0.8.4 + Compiling potential_utf v0.1.6 + Compiling icu_locale_core v2.3.0 + Compiling icu_collections v2.3.0 + Compiling hashbrown v0.15.5 + Compiling email_address v0.2.9 + Compiling digest v0.10.7 + Compiling fancy-regex v0.16.2 + Compiling regex v1.13.1 + Compiling rand v0.9.5 + Compiling thiserror-impl v2.0.20 + Compiling cpufeatures v0.2.17 + Compiling is_terminal_polyfill v1.70.2 + Compiling colorchoice v1.0.5 + Compiling icu_provider v2.3.1 + Compiling icu_normalizer v2.3.0 + Compiling icu_properties v2.3.0 + Compiling num-cmp v0.1.0 + Compiling base64 v0.22.1 + Compiling anstyle v1.0.14 + Compiling bytecount v0.6.9 + Compiling anstyle-query v1.1.5 + Compiling anstream v1.0.0 + Compiling ulid v1.2.1 + Compiling idna_adapter v1.2.2 + Compiling sha2 v0.10.9 + Compiling idna v1.1.0 + Compiling jsonschema v0.33.0 + Compiling hashlink v0.10.0 + Compiling fallible-iterator v0.3.0 + Compiling bitflags v2.13.1 + Compiling clap_lex v1.1.0 + Compiling fallible-streaming-iterator v0.1.9 + Compiling ryu-js v1.0.3 + Compiling anyhow v1.0.104 + Compiling strsim v0.11.1 + Compiling heck v0.5.0 + Compiling clap_builder v4.6.6 + Compiling clap_derive v4.6.4 + Compiling wait-timeout v0.2.1 + Compiling relayflowd-core v0.1.0 (/Users/khaliqgant/flows-spec-Q-sidechannel/kernel/relayflowd-core) + Compiling clap v4.6.6 + Compiling rusqlite v0.37.0 + Compiling relayflowd-journal v0.1.0 (/Users/khaliqgant/flows-spec-Q-sidechannel/kernel/relayflowd-journal) + Compiling relayflowd v0.1.0 (/Users/khaliqgant/flows-spec-Q-sidechannel/kernel/relayflowd) + Finished `release` profile [optimized] target(s) in 34.01s diff --git a/docs/evidence/spec-Q/rust-workspace.txt b/docs/evidence/spec-Q/rust-workspace.txt new file mode 100644 index 000000000..60b6dd604 --- /dev/null +++ b/docs/evidence/spec-Q/rust-workspace.txt @@ -0,0 +1,391 @@ + Compiling relayflowd-core v0.1.0 (/Users/khaliqgant/flows-spec-Q-sidechannel/kernel/relayflowd-core) + Compiling relayflowd-journal v0.1.0 (/Users/khaliqgant/flows-spec-Q-sidechannel/kernel/relayflowd-journal) + Compiling relayflowd v0.1.0 (/Users/khaliqgant/flows-spec-Q-sidechannel/kernel/relayflowd) + Finished `test` profile [unoptimized + debuginfo] target(s) in 5.36s + Running unittests src/lib.rs (/Users/khaliqgant/.relayflows-toolchain/target/3923540030/debug/deps/relayflowd-331e4e97bba410b4) + +running 41 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 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 server::liveness::tests::sweep_pass_healthy_subscription_is_a_noop ... 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 engine::wake::claim_guard_tests::a_disarmed_guard_leaves_the_claim_alone ... 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::an_armed_guard_releases_the_claim_when_dropped ... ok +test engine::wake::claim_guard_tests::a_guard_only_releases_its_own_run ... ok +test server::tests::agent::contract::agent_without_a_compatible_worker_parks_without_starting ... 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::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::a_failed_disconnect_journal_append_is_retained_and_retried_not_dropped ... 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 socket_path::tests::deep_data_dir_produces_short_socket_path ... ok +test socket_path::tests::different_data_dirs_yield_different_sockets ... ok +test socket_path::tests::relative_and_absolute_data_dirs_agree ... ok +test socket_path::tests::same_data_dir_yields_same_socket ... ok +test server::tests::run_resume_adopts_a_real_journal_whose_registry_row_is_missing ... ok +test server::tests::run_resume_refuses_a_journal_that_never_recorded_its_run ... ok +test server::tests::agent::contract::human_intervention_is_durable_and_resume_requires_explicit_override ... 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. 41 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.56s + + Running unittests src/main.rs (/Users/khaliqgant/.relayflows-toolchain/target/3923540030/debug/deps/relayflowd-85d3a2820e0dfd5d) + +running 0 tests + +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + Running tests/budget_gate.rs (/Users/khaliqgant/.relayflows-toolchain/target/3923540030/debug/deps/budget_gate-e9bdf0830b0a7d74) + +running 3 tests +test daily_windows_reset_and_exact_limits_do_not_refuse ... ok +test crossing_completion_is_durable_and_next_step_is_refused ... ok +test deterministic_spend_and_wallclock_limit_gate_parallel_batch_starts ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s + + Running tests/crash_resume.rs (/Users/khaliqgant/.relayflows-toolchain/target/3923540030/debug/deps/crash_resume-f00a2c4b4059afb2) + +running 40 tests +test agent::resume_without_a_worker_parks_immediately_instead_of_timing_out ... ok +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 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_reset_sigkill_mid_edit_restores_pins_dedupes_effect_and_explains_attempts ... ok +test agent::rung_c_sigkill_between_agent_completion_and_final_effect_memoizes_the_agent ... ok +test agent::rung_c_crash_between_effect_election_and_the_provider_call_performs_it_exactly_once ... 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 llm::serve_plumbs_watch_events_and_replayable_stream_verbs ... 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 agent::rung_c_sigkill_boundaries_resume_only_unfinished_steps_via_real_cli ... 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 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::terminal_failure_drains_or_explains_every_live_sibling ... ok +test llm::sigkill_sweep_covers_before_and_between_the_rung_b_steps ... ok +test sigkill_mid_step_replaces_and_explains_the_dead_attempt ... ok +test sigkill_after_cancel_request_resumes_to_one_canceled_fact ... 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 sigkill_under_serve_resumes_the_socket_started_run ... ok +test parallel_lifecycle::overlapping_agent_lanes_serialize_while_disjoint_lanes_merge_in_either_order ... 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 workspace_identity::workspace_aliases_are_refused_and_canonical_subtrees_serialize_over_real_sockets ... ok +test channels::channels_sigkill_resume_redelivers_unacked_messages_with_exactly_once_effects ... ok +test placement::declared_placement_keeps_one_source_tree_across_resume ... ok +test sigkill_sweep_covers_every_hello_step_boundary ... 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::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.37s + + Running tests/daemon_lifecycle.rs (/Users/khaliqgant/.relayflows-toolchain/target/3923540030/debug/deps/daemon_lifecycle-ba84c0d831e365f3) + +running 6 tests +test a_second_serve_on_a_served_data_dir_refuses_and_the_first_keeps_serving ... ok +test connection_file_is_published_only_after_the_socket_is_live ... ok +test clean_shutdown_removes_advertisement_and_socket ... ok +test sigkill_leaves_a_stale_file_with_a_dead_pid ... ok +test deep_data_dir_still_binds ... ok +test a_sigkilled_daemons_successor_starts_cleanly ... ok + +test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.10s + + Running tests/event_wake.rs (/Users/khaliqgant/.relayflows-toolchain/target/3923540030/debug/deps/event_wake-e0c98dffa0a59265) + +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.06s + + Running tests/hn_monitor_integration.rs (/Users/khaliqgant/.relayflows-toolchain/target/3923540030/debug/deps/hn_monitor_integration-19a18ad56a4e0130) + +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/input_binding.rs (/Users/khaliqgant/.relayflows-toolchain/target/3923540030/debug/deps/input_binding-e5900c2c958c1bff) + +running 2 tests +test binding_schema_is_additive_and_fails_closed ... ok +test sigkill_before_consumer_resolves_original_journal_output_without_reexecuting_source ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.12s + + Running tests/invalid_schema_preflight.rs (/Users/khaliqgant/.relayflows-toolchain/target/3923540030/debug/deps/invalid_schema_preflight-fb4088ae31bca2af) + +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 3.89s + + Running tests/memoization.rs (/Users/khaliqgant/.relayflows-toolchain/target/3923540030/debug/deps/memoization-b8378bf177dc4caa) + +running 3 tests +test refuses_missing_wrong_flow_and_unreadable_journal_before_creating_run ... ok +test reused_prefix_survives_restart_without_source_and_never_mutates_prior ... ok +test actual_changed_input_invalidates_consumer_even_with_identical_consumer_spec ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.08s + + Running tests/memory.rs (/Users/khaliqgant/.relayflows-toolchain/target/3923540030/debug/deps/memory-2dd999428343e3d3) + +running 5 tests +test rejected_journal_fact_releases_reservation_and_never_dispatches ... ok +test over_budget_and_provider_errors_fail_without_dispatch_or_charge ... 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 result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.07s + + Running tests/memory_epoch.rs (/Users/khaliqgant/.relayflows-toolchain/target/3923540030/debug/deps/memory_epoch-1e598c87f73b4d4d) + +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 (/Users/khaliqgant/.relayflows-toolchain/target/3923540030/debug/deps/parallel_driver-09d57fda35fc3047) + +running 4 tests +test stop_after_one_holds_for_an_independent_deterministic_batch ... ok +test pause_before_second_independent_step_holds_the_driver_boundary ... 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 result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.08s + + Running tests/placement_pins.rs (/Users/khaliqgant/.relayflows-toolchain/target/3923540030/debug/deps/placement_pins-d1876318727129de) + +running 3 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 a_resumed_attempt_keeps_the_original_pin_after_the_worktree_head_moves ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.51s + + Running tests/placement_routing.rs (/Users/khaliqgant/.relayflows-toolchain/target/3923540030/debug/deps/placement_routing-a5decc4af0d1cbe8) + +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 (/Users/khaliqgant/.relayflows-toolchain/target/3923540030/debug/deps/routing_diagnostics-3fd00989f6b06778) + +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/spec_review_routing.rs (/Users/khaliqgant/.relayflows-toolchain/target/3923540030/debug/deps/spec_review_routing-48229baf3a3fa052) + +running 4 tests +test attempt_scoped_route_is_rejected_at_append_and_replay ... ok +test epoch_cannot_drop_or_replace_a_durable_route ... ok +test malformed_epoch_routes_are_rejected_before_commit ... 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.41s + + Running tests/subscription_liveness.rs (/Users/khaliqgant/.relayflows-toolchain/target/3923540030/debug/deps/subscription_liveness-b2bd7d0fb2d4da77) + +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 tests/trigger_watcher.rs (/Users/khaliqgant/.relayflows-toolchain/target/3923540030/debug/deps/trigger_watcher-dcffffe2d25ba6f1) + +running 3 tests +test retains_bad_and_unregistered_events_while_consuming_filter_nonmatches ... ok +test failed_archive_retries_the_same_durable_run ... ok +test journals_payload_and_filename_key_then_archives_and_dedupes_replay ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.06s + + Running unittests src/lib.rs (/Users/khaliqgant/.relayflows-toolchain/target/3923540030/debug/deps/relayflowd_core-315e59794aff65b0) + +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::every_declared_mutable_surface_participates_in_conflict_selection ... ok +test machine::parallel_tests::external_ancestor_and_descendant_paths_conflict_but_siblings_do_not ... 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::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::every_reason_label_matches_its_serialized_form ... ok +test machine::parallel_tests::parallel_lanes_do_not_cross_the_dependency_barrier_early ... ok +test machine::tests::durable_cancel_request_outranks_crash_recovery ... ok +test machine::parallel_tests::workspace_ancestor_and_descendant_paths_conflict_but_siblings_do_not ... ok +test machine::parallel_tests::overlapping_agent_surfaces_are_serialized_in_authored_order ... 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::successful_memo_is_never_scheduled_again ... ok +test machine::tests::machine_starts_runnable_step_with_stable_effect_key ... ok +test machine::parallel_tests::crash_resume_preserves_each_parallel_lease_exactly_once ... 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::crashed_attempt_does_not_consume_an_iteration ... ok +test machine::tests::reset_recovery_dispatches_the_original_pinned_revision ... ok +test memory::tests::caps_compare_exact_decimals_and_each_token_dimension ... 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::manual_recovery_parks_needs_human_and_never_redispatches ... 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 spec::tests::a_misspelled_step_level_key_is_a_parse_error ... ok +test schema::tests::refusal_names_the_cycle_it_found ... 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 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 schema::tests::references_the_bound_leaves_opaque_are_refused_by_the_engine ... 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 state::tests::end_pin_chain_is_enforced_and_a_broken_chain_is_a_hard_error ... 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 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.60s + + Running tests/memoization.rs (/Users/khaliqgant/.relayflows-toolchain/target/3923540030/debug/deps/memoization-5083c536bd91f99b) + +running 4 tests +test distinct_large_kernel_integers_do_not_alias_through_float_rounding ... ok +test match_reuses_output_with_provenance_and_zero_cost_without_dispatch ... ok +test changed_spec_or_input_dispatches_and_legacy_or_failed_records_miss ... ok +test canonical_corpus_agrees_with_typescript_and_key_permutations ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + Running tests/spec_parity.rs (/Users/khaliqgant/.relayflows-toolchain/target/3923540030/debug/deps/spec_parity-fe4dc0a4eb6ad67f) + +running 9 tests +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 placement_requirements_have_identical_canonical_bytes_and_hash ... ok +test memory_declaration_acceptance_matches_the_sdk_corpus ... ok +test the_kernel_parses_the_rung_c_agent_spec_and_stamps_the_same_hash ... 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 (/Users/khaliqgant/.relayflows-toolchain/target/3923540030/debug/deps/relayflowd_journal-1220f3d7dcad415d) + +running 28 tests +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 registry::tests::releasing_a_claim_lets_the_same_boot_retry ... ok +test registry::tests::a_previous_boots_claim_with_no_run_is_repaired ... 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::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 channel::tests::channels_cross_segment_boundaries_and_terminal_runs_reject_mutations ... 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_the_lex_greatest_ulid_regardless_of_insertion ... 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::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::upsert_after_stale_re_arms_and_next_silence_can_re_emit ... ok +test tests::failed_commit_is_returned_not_swallowed ... ok +test tests::append_is_durable_and_monotonic_after_reopen ... 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::effects_are_deduplicated_at_the_journal_boundary ... ok +test tests::terminal_run_refuses_every_later_entry_atomically ... 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 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.13s + + 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 + diff --git a/docs/evidence/spec-Q/sdk-final.txt b/docs/evidence/spec-Q/sdk-final.txt new file mode 100644 index 000000000..37729d723 --- /dev/null +++ b/docs/evidence/spec-Q/sdk-final.txt @@ -0,0 +1,199 @@ + + RUN v2.1.9 /Users/khaliqgant/flows-spec-Q-sidechannel/packages/sdk + +stdout | tests/live-kernel.test.ts +LIVE_KERNEL relayflowd=/Users/khaliqgant/.relayflows-toolchain/target/3923540030/debug/relayflowd +LIVE_KERNEL flows=/Users/khaliqgant/flows-spec-Q-sidechannel/packages/sdk/dist/cli.js + + ✓ tests/daemon-lifecycle.test.ts (42 tests) 30ms + ✓ tests/validate.test.ts (68 tests) 15ms + ✓ tests/preflight.test.ts (27 tests) 52ms + ✓ tests/observer-link.test.ts (39 tests) 241ms + ✓ tests/tick-source.test.ts (33 tests) 19ms + ✓ tests/journal-client.test.ts (14 tests) 79ms + ✓ tests/authored-flow.test.ts (25 tests) 675ms + ✓ tests/verb-field-lint.test.ts (78 tests) 393ms + ✓ tests/cloud-run.test.ts (47 tests) 333ms + ✓ tests/authored-flow-lifecycle-executor.test.ts (27 tests) 544ms + ✓ tests/gate-contract.test.ts (20 tests) 89ms + ✓ tests/cli-replay.test.ts (37 tests) 749ms + ✓ flows replay > --json is byte-identical across two CLI invocations (diff) 513ms + ✓ tests/cli-hn-monitor.test.ts (16 tests) 100ms + ✓ tests/tick-runner.test.ts (22 tests) 1520ms + ✓ tests/authored-flow-slack.test.ts (6 tests) 1079ms + ✓ authored Slack helper effects > replays after SIGKILL before confirm with the same token and one successful completion 354ms + ✓ authored Slack helper effects > replays after SIGKILL before complete with the same token and one successful completion 317ms + ✓ tests/backlog-picker.test.ts (14 tests) 60ms + ✓ tests/backlog-picker-flow.test.ts (6 tests) 497ms + ✓ tests/bundle.test.ts (21 tests) 3909ms + ✓ immutable bundles > builds and verifies the canonical YAML fixture through the compiled CLI 629ms + ✓ immutable bundles > emits the ephemeral warning on CLI stderr and uses the default output directory 395ms + ✓ immutable bundles > builds a standalone TS fixture twice with identical executable hashes 1190ms + ✓ tests/authored-flow-operation.test.ts (23 tests) 270ms + ✓ tests/mcp.test.ts (30 tests) 7068ms + ✓ MCP preflight and transports > flows check refuses an undeclared server with exit 2 and no daemon 323ms + ✓ MCP preflight and transports > flows check reports a refusing server and leaves no PID 552ms + ✓ MCP preflight and transports > kills a SIGTERM-resistant silent child after a parent-owned handshake deadline 1310ms + ✓ MCP preflight and transports > reaps a SIGTERM-resistant descendant with inherit stdio before cleanup finishes 1183ms + ✓ MCP preflight and transports > reaps a SIGTERM-resistant descendant with ignore stdio before cleanup finishes 2058ms + ✓ authored MCP effects against the real kernel > reports a dropped tool connection as a failed CLI run 362ms + ✓ tests/work-package-consumer.test.ts (13 tests) 184ms + ✓ tests/spec-parity.test.ts (31 tests) 178ms + ✓ tests/stop-process-group.test.ts (6 tests) 6726ms + ✓ every stop reaches the process group, not just the direct child > exits the run after an execution-timeout stop 1025ms + ✓ every stop reaches the process group, not just the direct child > exits the run after a protocol terminate stop 434ms + ✓ every stop reaches the process group, not just the direct child > kills a SIGTERM-deaf grandchild after a protocol terminate stop 1667ms + ✓ every stop reaches the process group, not just the direct child > kills a SIGTERM-deaf grandchild after an execution-timeout stop 2135ms + ✓ every stop reaches the process group, not just the direct child > holds the loop open long enough for the escalation to run 1155ms + ✓ every stop reaches the process group, not just the direct child > terminate() forces a group that outlives SIGTERM 309ms + ✓ tests/worker-lease.test.ts (7 tests) 8ms + ✓ tests/typed-output.test.ts (14 tests) 105ms + ✓ tests/json-schema-bound.test.ts (71 tests) 1474ms + ✓ JSON Schema termination bound > walks a deep schema with an explicit stack rather than recursion 1247ms + ✓ tests/mcp-lifecycle.test.ts (4 tests) 6ms + ✓ tests/model-selection.test.ts (10 tests) 9ms + ✓ tests/flow-executor-chain.test.ts (12 tests) 8369ms + ✓ flow executor LLM and output-binding chain > runs f.llm -> f.agent -> f.run with schema-verified journal output and the exact allowed model 930ms + ✓ flow executor LLM and output-binding chain > fails invalid LLM output before the next step: not JSON 540ms + ✓ flow executor LLM and output-binding chain > fails invalid LLM output before the next step: {"message":7} 535ms + ✓ flow executor LLM and output-binding chain > retains tagged-template text output 628ms + ✓ flow executor LLM and output-binding chain > preserves JSON values without promoting them to process wrappers: null 549ms + ✓ flow executor LLM and output-binding chain > preserves JSON values without promoting them to process wrappers: [1,2] 596ms + ✓ flow executor LLM and output-binding chain > preserves JSON values without promoting them to process wrappers: "hello" 586ms + ✓ flow executor LLM and output-binding chain > runs the authored LLM path through the built flows CLI 982ms + ✓ flow executor LLM and output-binding chain > passes a declarative verified value through an agent into a deterministic artifact 617ms + ✓ flow executor LLM and output-binding chain > journals a missing optional field as a failure before the consuming command executes 567ms + ✓ flow executor LLM and output-binding chain > flows run consumes YAML bindings and resume reuses the original journal output 1806ms + ✓ tests/relayflowd-path.test.ts (10 tests) 2ms + ✓ tests/f-memory.test.ts (7 tests) 738ms + ✓ tests/webhook.test.ts (6 tests) 52ms + ✓ tests/direct-input.test.ts (4 tests) 5334ms + ✓ direct .flow.ts input through the built CLI and live runtime > executes inline and file JSON input through relayflowd 2166ms + ✓ direct .flow.ts input through the built CLI and live runtime > refuses missing and malformed input before contacting relayflowd 1739ms + ✓ direct .flow.ts input through the built CLI and live runtime > does not run the authored body before daemon availability 780ms + ✓ direct .flow.ts input through the built CLI and live runtime > refuses oversized file input before contacting relayflowd 648ms + ✓ tests/local-dev-ux.test.ts (8 tests) 16ms + ✓ tests/daemon-lifecycle-live.test.ts (9 tests) 10185ms + ✓ flows run against a data dir with no daemon (§6 test 7) > cold start spawns exactly one daemon, the run succeeds, and the daemon outlives the CLI 635ms + ✓ flows run against a data dir with no daemon (§6 test 7) > polls, bounded, for a daemon that holds the lock before it binds 1416ms + ✓ flows run against a data dir with no daemon (§6 test 7) > attaches to a serving daemon that has not published a connection file 631ms + ✓ flows run against a data dir with no daemon (§6 test 7) > a second run attaches to the daemon the first one started, spawning nothing 1936ms + ✓ flows run against a data dir with no daemon (§6 test 7) > detects a stale connection file left by a hard kill and starts a fresh daemon 2037ms + ✓ concurrent invocations against one empty data dir (§6 test 15) > ends with exactly one daemon owning the socket, and both runs succeed 1162ms + ✓ refusals from a spawn that cannot produce a daemon > names relayflowd_not_found rather than falling through to PATH 1068ms + ✓ refusals from a spawn that cannot produce a daemon > names daemon_start_failed and quotes the daemon log when startup dies 648ms + ✓ refusals from a spawn that cannot produce a daemon > refuses a daemon speaking another protocol version instead of binding over it 652ms + ✓ tests/dependency-validation.test.ts (6 tests) 288ms + ✓ tests/cli.test.ts (64 tests) 12650ms + ✓ flows check CLI > binds a checked relative wrapper to the flow directory for worker execution 417ms + ✓ flows check CLI > uses Codex login status and reports a rejected model as unavailable, not unauthenticated 334ms + ✓ flows check CLI > checks the same named-agent contract from declarative JSON 508ms + ✓ flows check CLI > refuses a typo model before probing or contacting relayflowd 1340ms + ✓ flows check CLI > maps every input refusal path to its declared kind without raw exceptions 1706ms + ✓ flows run/resume CLI over the journal protocol > parses run options, submits the kernel dialect, and exits 0 on success 710ms + ✓ flows run/resume CLI over the journal protocol > exits 1 and emits the declared completionReason for a failed run 323ms + ✓ flows run/resume CLI over the journal protocol > exits 3 and names the parked llm step 329ms + ✓ flows run/resume CLI over the journal protocol > classifies a typed hello refusal as a protocol error, not an unreachable daemon 716ms + ✓ flows run/resume CLI over the journal protocol > follows a dispatched worker step instead of reporting a protocol error 1215ms + ✓ flows run/resume CLI over the journal protocol > bounds a worker wait by its lease and reports what it is waiting for 355ms + ✓ flows run/resume CLI over the journal protocol > resumes a parked run from snapshot step types without reading journal sequence one 300ms + ✓ flows run/resume CLI over the journal protocol > maps only run_not_found resumes to exit 2 2537ms + ✓ tests/deterministic-llm.test.ts (5 tests) 41ms + ✓ tests/webhook-live.test.ts (3 tests) 4872ms + ✓ flows serve-webhook writes JSON before the daemon starts, then journals and archives exactly once 1299ms + ✓ resumes the same journal after SIGKILL after spawn and before acknowledgement 3289ms + ✓ tests/input-binding.test.ts (12 tests) 136ms + ✓ tests/scope-compiler.test.ts (25 tests) 9ms + ✓ tests/budget-attribution.test.ts (4 tests) 2ms + ✓ tests/hn-poller.test.ts (6 tests) 4ms + ✓ tests/dir-watcher-poller.test.ts (6 tests) 5ms + ✓ tests/hello-deterministic.test.ts (5 tests) 9ms + ✓ tests/budget-preflight.test.ts (16 tests) 5ms + ✓ tests/work-package-validator.test.ts (7 tests) 3ms + ↓ tests/real-cli-adapters.test.ts (3 tests | 3 skipped) + ✓ tests/model-pricing.test.ts (8 tests) 2ms + ✓ tests/authored-use-loader.test.ts (5 tests) 40ms + ✓ tests/bin.test.ts (7 tests) 1259ms + ✓ tests/parse-json-output.test.ts (7 tests) 1ms + ✓ tests/cli-adapter.test.ts (3 tests) 2ms + ✓ tests/cli-watch.test.ts (10 tests) 10228ms + ✓ flows check --watch > rechecks syntax errors, clears once, and returns the last refusal on Ctrl-C 747ms + ✓ flows check --watch > streams JSON lines without ANSI, recovers after atomic saves, and exits zero after repair 1098ms + ✓ flows check --watch > coalesces 20 concurrent saves into at most two rechecks 1300ms + ✓ flows check --watch > watches transitive relative use imports, cycles, and nearest config changes 1495ms + ✓ flows check --watch > refreshes the import graph and notices missing imports being created 1552ms + ✓ flows check --watch > reloads authored TypeScript instead of reusing the first imported definition 836ms + ✓ flows check --watch > detects a nearer config appearing and falls back after it is deleted 1170ms + ✓ flows check --watch > keeps watching after the target is deleted and recreated 1253ms + ✓ flows check --watch > queues changes during a slow check without overlapping checks 776ms + ✓ tests/journal-client-completion.test.ts (4 tests) 99ms + ✓ tests/direct-run-failure.test.ts (6 tests) 3ms + ✓ tests/memoization.test.ts (57 tests) 697ms + ✓ refuses invalid reuse invocation "run" 655ms + ✓ tests/yaml-local-agent-live.test.ts (7 tests) 3218ms + ✓ YAML --local-agent through the built CLI and real daemon > runs with the checked step CLI and model and journals done 511ms + ✓ YAML --local-agent through the built CLI and real daemon > runs with the checked named CLI and model and journals done 510ms + ✓ YAML --local-agent through the built CLI and real daemon > runs with the checked flow CLI and model and journals done 548ms + ✓ YAML --local-agent through the built CLI and real daemon > runs with the checked project CLI and model and journals done 495ms + ✓ YAML --local-agent through the built CLI and real daemon > still parks without --local-agent 359ms + ✓ YAML --local-agent through the built CLI and real daemon > reports the agent process failure 452ms + ✓ YAML --local-agent through the built CLI and real daemon > preserves declared workspace surfaces that the local worker cannot pin 343ms + ✓ tests/budget-authored-live.test.ts (2 tests) 130ms + ✓ tests/pty-sidechannel.test.ts (5 tests) 2068ms + ✓ view attach preserves worker completion and marks only drive 636ms + ✓ passthrough attach preserves worker completion and marks only drive 619ms + ✓ none attach preserves worker completion and marks only drive 627ms + ✓ tests/placement.test.ts (54 tests) 7ms + ✓ tests/memory.test.ts (18 tests) 3ms + ✓ tests/slack-writeback.test.ts (1 test) 257ms + ✓ tests/worker-platform.test.ts (1 test) 1ms + ✓ tests/classify-outcome.test.ts (2 tests) 2231ms + ✓ classifyOutcome > gives up and reports when a running run never becomes classifiable 2074ms + ✓ tests/cli-progress-wait.test.ts (2 tests) 1503ms + ✓ run starts the wait clock on its first observed lease 769ms + ✓ resume starts the wait clock on its first observed lease 733ms + ✓ tests/worker-cli-abort.test.ts (2 tests) 2635ms + ✓ stops claude and its process group when lease ownership is lost 1330ms + ✓ stops wrapper.mjs and its process group when lease ownership is lost 1305ms + ✓ tests/worker-cli.test.ts (13 tests) 21026ms + ✓ custom wrapper execution identity > passes an explicit safe environment at identification and execution 341ms + ✓ custom wrapper execution identity > refuses a wrapper symlink retarget before delivering private values 321ms + ✓ custom wrapper execution identity > bounds wrapper execution after acknowledgement 533ms + ✓ custom wrapper execution bounds are reader-owned > resolves when a conforming wrapper leaks a stdio pipe to a background helper 2076ms + ✓ custom wrapper execution bounds are reader-owned > resolves when the leaked helper inherits stderr only 1850ms + ✓ custom wrapper execution bounds are reader-owned > resolves when a wrapper leaks a stdio pipe and exits before identifying 3255ms + ✓ custom wrapper execution bounds are reader-owned > journals a completionReason at the default bound when a wrapper leaks a stdio pipe 11259ms + ✓ custom wrapper execution bounds are reader-owned > accepts the same over-8KiB payload whether or not it coalesces with the execute token 455ms + ✓ tests/local-agent-live.test.ts (5 tests) 39666ms + ✓ built CLI local agent against a real daemon > dispatches through the wrapper and keeps --json stdout report-shaped 802ms + ✓ built CLI local agent against a real daemon > runs beyond the initial 30-second lease without a second invocation 35836ms + ✓ built CLI local agent against a real daemon > renders actual agent completion in text output 898ms + ✓ built CLI local agent against a real daemon > returns a failed run when the agent process fails 1060ms + ✓ built CLI local agent against a real daemon > refuses a workspace it cannot pin before invoking the agent 1069ms +stderr | tests/live-kernel.test.ts > built flows CLI against live relayflowd > hn-monitor analyze-story reaches done through the real Claude analyzer CLI +LIVE_ANALYZER_UNAVAILABLE: "/Users/khaliqgant/flows-spec-Q-sidechannel/testdata/preflight/analyze-story-claude-cli auth status" exited 1: analyze-story-claude-cli: "claude -p --model claude-haiku-4-5-20251001" exited 1: — SKIPPING. This skip is diagnostics, not gate-2 acceptance evidence. + +stdout | tests/live-kernel.test.ts > surface resume after a real daemon kill > resumes a three-step run with each successful completion exactly once +LIVE_KERNEL kill -9 pid=47317 run=01M28VX4DQFP16NCJ68N01S0WN while step=two state=Running + + ✓ tests/live-kernel.test.ts (31 tests | 1 skipped) 57575ms + ✓ built flows CLI against live relayflowd > twenty-six-step reuses 25 durable completions after editing the failed final step 2203ms + ✓ built flows CLI against live relayflowd > runs rung (a), parks rung (b), and keeps JSON report-shaped 5090ms + ✓ built flows CLI against live relayflowd > allows a deterministic run to exceed the bounded request timeout 32295ms + ✓ built flows CLI against live relayflowd > follows a live worker dispatch through flows run 1221ms + ✓ built flows CLI against live relayflowd > f.agent lowers to a real agent step and dispatches through a live worker 359ms + ✓ built flows CLI against live relayflowd > f.agent's default flowPath anchors on cwd, not cwd's parent 319ms + ✓ built flows CLI against live relayflowd > can always get a parked run to a late-attaching worker 5575ms + ✓ built flows CLI against live relayflowd > reports a real manual-recovery NeedsHuman state as parked 929ms + ✓ built flows CLI against live relayflowd > AgentWorker passes a declared model to an identified wrapper as RELAYFLOW_MODEL 375ms + ✓ built flows CLI against live relayflowd > AgentWorker executes the raw claude adapter with its real model flag 415ms + ✓ built flows CLI against live relayflowd > AgentWorker executes the raw codex adapter with its real model flag 1190ms + ✓ built flows CLI against live relayflowd > preflights before journaling and names an unreachable socket 1775ms + ✓ built flows CLI against live relayflowd > starts exactly one daemon when two runs race for one empty data dir 884ms + ✓ surface resume after a real daemon kill > resumes a three-step run with each successful completion exactly once 1814ms + + Test Files 69 passed | 1 skipped (70) + Tests 1215 passed | 4 skipped (1219) + Start at 20:32:04 + Duration 58.17s (transform 1.21s, setup 0ms, collect 8.46s, tests 211.79s, environment 6ms, prepare 1.98s) + diff --git a/docs/evidence/spec-Q/sdk-focused.txt b/docs/evidence/spec-Q/sdk-focused.txt new file mode 100644 index 000000000..8374c54ac --- /dev/null +++ b/docs/evidence/spec-Q/sdk-focused.txt @@ -0,0 +1,31 @@ + + RUN v2.1.9 /Users/khaliqgant/flows-spec-Q-sidechannel/packages/sdk + + ✓ tests/cli-replay.test.ts (37 tests) 638ms + ✓ flows replay > --json is byte-identical across two CLI invocations (diff) 501ms + ✓ tests/worker-cli-abort.test.ts (2 tests) 2912ms + ✓ stops claude and its process group when lease ownership is lost 1525ms + ✓ stops wrapper.mjs and its process group when lease ownership is lost 1386ms + ✓ tests/pty-sidechannel.test.ts (5 tests) 3152ms + ✓ view attach preserves worker completion and marks only drive 1390ms + ✓ passthrough attach preserves worker completion and marks only drive 948ms + ✓ none attach preserves worker completion and marks only drive 655ms + ✓ tests/worker-cli.test.ts (13 tests) 25865ms + ✓ custom wrapper execution identity > passes an explicit safe environment at identification and execution 704ms + ✓ custom wrapper execution identity > refuses a wrapper symlink retarget before delivering private values 485ms + ✓ custom wrapper execution identity > bounds captured wrapper output 310ms + ✓ custom wrapper execution identity > refuses a duplicate execute protocol frame 514ms + ✓ custom wrapper execution bounds are reader-owned > resolves when a conforming wrapper leaks a stdio pipe to a background helper 1717ms + ✓ custom wrapper execution bounds are reader-owned > resolves when the leaked helper inherits stderr only 1759ms + ✓ custom wrapper execution bounds are reader-owned > resolves when a wrapper leaks a stdio pipe and exits before identifying 3256ms + ✓ custom wrapper execution bounds are reader-owned > journals a completionReason at the default bound when a wrapper leaks a stdio pipe 11257ms + ✓ custom wrapper execution bounds are reader-owned > accepts an execute token and an over-8KiB payload flushed in one write 1958ms + ✓ custom wrapper execution bounds are reader-owned > accepts the same over-8KiB payload whether or not it coalesces with the execute token 1971ms + ✓ custom wrapper execution bounds are reader-owned > still bounds an un-terminated handshake buffer and names the bound 792ms + ✓ delivers the journaled memory pack to the real wrapper and excludes its charge from completion usage 917ms + + Test Files 4 passed (4) + Tests 57 passed (57) + Start at 20:29:40 + Duration 26.18s (transform 404ms, setup 0ms, collect 999ms, tests 32.57s, environment 0ms, prepare 180ms) + diff --git a/docs/evidence/spec-Q/sdk-initial.txt b/docs/evidence/spec-Q/sdk-initial.txt new file mode 100644 index 000000000..f183215fb --- /dev/null +++ b/docs/evidence/spec-Q/sdk-initial.txt @@ -0,0 +1,405 @@ + + RUN v2.1.9 /Users/khaliqgant/flows-spec-Q-sidechannel/packages/sdk + +stdout | tests/live-kernel.test.ts +LIVE_KERNEL relayflowd=/Users/khaliqgant/.relayflows-toolchain/target/3923540030/debug/relayflowd +LIVE_KERNEL flows=/Users/khaliqgant/flows-spec-Q-sidechannel/packages/sdk/dist/cli.js + + ✓ tests/daemon-lifecycle.test.ts (42 tests) 45ms + ✓ tests/preflight.test.ts (27 tests) 79ms + ✓ tests/validate.test.ts (68 tests) 88ms + ✓ tests/observer-link.test.ts (39 tests) 275ms + ✓ tests/authored-flow.test.ts (25 tests) 813ms + ✓ tests/journal-client.test.ts (14 tests) 103ms + ✓ tests/tick-source.test.ts (33 tests) 66ms + ✓ tests/cloud-run.test.ts (47 tests) 124ms + ✓ tests/authored-flow-lifecycle-executor.test.ts (27 tests) 542ms + ✓ tests/cli-replay.test.ts (37 tests) 1123ms + ✓ flows replay > --json is byte-identical across two CLI invocations (diff) 780ms + ✓ tests/gate-contract.test.ts (20 tests) 206ms + ✓ tests/verb-field-lint.test.ts (78 tests) 1998ms + ✓ closed per-verb step fields > carries the llm/agent `output` sugar through every path > flows check accepts output on llm 1329ms + ✓ tests/cli-hn-monitor.test.ts (16 tests) 136ms + ✓ tests/tick-runner.test.ts (22 tests) 2653ms + ✓ CLI argument parsing refuses coercion rather than accepting it > refuses --interval-ms fractional as an invocation error 575ms + ✓ CLI argument parsing refuses coercion rather than accepting it > refuses --interval-ms exponent notation as an invocation error 347ms + ✓ CLI argument parsing refuses coercion rather than accepting it > refuses --interval-ms trailing text as an invocation error 300ms + ✓ CLI argument parsing refuses coercion rather than accepting it > refuses --interval-ms empty as an invocation error 601ms + ✓ CLI argument parsing refuses coercion rather than accepting it > accepts an exact integer and proceeds past parsing 461ms + ✓ tests/authored-flow-slack.test.ts (6 tests) 1748ms + ✓ authored Slack helper effects > replays after SIGKILL before confirm with the same token and one successful completion 624ms + ✓ authored Slack helper effects > replays after SIGKILL before complete with the same token and one successful completion 496ms + ✓ authored Slack helper effects > writes two files for two calls and supports dm, reply, and react 401ms + ✓ tests/backlog-picker.test.ts (14 tests) 69ms + ✓ tests/backlog-picker-flow.test.ts (6 tests) 693ms + ✓ backlog-picker flow > does not emit a stale entry left by a previous run 378ms + ✓ tests/mcp.test.ts (30 tests) 8230ms + ✓ MCP preflight and transports > flows check refuses an undeclared server with exit 2 and no daemon 860ms + ✓ MCP preflight and transports > flows check reports a refusing server and leaves no PID 673ms + ✓ MCP preflight and transports > kills a SIGTERM-resistant silent child after a parent-owned handshake deadline 1310ms + ✓ MCP preflight and transports > reaps a SIGTERM-resistant descendant with inherit stdio before cleanup finishes 1208ms + ✓ MCP preflight and transports > reaps a SIGTERM-resistant descendant with ignore stdio before cleanup finishes 2062ms + ✓ MCP preflight and transports > reports malformed connection configuration as config_invalid 498ms + ✓ authored MCP effects against the real kernel > journals one MCP receipt per call with args, result, stable logical key, and a confirmed effect 339ms + ✓ authored MCP effects against the real kernel > reports a dropped tool connection as a failed CLI run 484ms + ✓ tests/authored-flow-operation.test.ts (23 tests) 271ms + ❯ tests/bundle.test.ts (21 tests | 1 failed) 5463ms + ✓ immutable bundles > returns exit 2 naming a byte-flipped payload and refuses to reuse corruption 383ms + ✓ immutable bundles > builds and verifies the canonical YAML fixture through the compiled CLI 1327ms + ✓ immutable bundles > emits the ephemeral warning on CLI stderr and uses the default output directory 456ms + ✓ immutable bundles > preserves quoted asset words and executable permissions 1539ms + × immutable bundles > builds a standalone TS fixture twice with identical executable hashes 293ms + → expected 'REFUSED [bundle_invalid] TypeScript b…' to be '' // Object.is equality + ✓ tests/work-package-consumer.test.ts (13 tests) 202ms + ✓ tests/spec-parity.test.ts (31 tests) 184ms + ✓ tests/stop-process-group.test.ts (6 tests) 10287ms + ✓ every stop reaches the process group, not just the direct child > exits the run after an execution-timeout stop 1658ms + ✓ every stop reaches the process group, not just the direct child > exits the run after a protocol terminate stop 1329ms + ✓ every stop reaches the process group, not just the direct child > kills a SIGTERM-deaf grandchild after a protocol terminate stop 3134ms + ✓ every stop reaches the process group, not just the direct child > kills a SIGTERM-deaf grandchild after an execution-timeout stop 2719ms + ✓ every stop reaches the process group, not just the direct child > holds the loop open long enough for the escalation to run 1152ms + ✓ tests/worker-lease.test.ts (7 tests) 5ms + ✓ tests/typed-output.test.ts (14 tests) 101ms + ✓ tests/json-schema-bound.test.ts (71 tests) 1450ms + ✓ JSON Schema termination bound > walks a deep schema with an explicit stack rather than recursion 1210ms + ✓ tests/mcp-lifecycle.test.ts (4 tests) 7ms + ✓ tests/model-selection.test.ts (10 tests) 9ms + ✓ tests/webhook-live.test.ts (3 tests) 4847ms + ✓ flows serve-webhook writes JSON before the daemon starts, then journals and archives exactly once 1299ms + ✓ resumes the same journal after SIGKILL after spawn and before acknowledgement 3286ms + ✓ tests/relayflowd-path.test.ts (10 tests) 2ms + ✓ tests/direct-input.test.ts (4 tests) 6706ms + ✓ direct .flow.ts input through the built CLI and live runtime > executes inline and file JSON input through relayflowd 2539ms + ✓ direct .flow.ts input through the built CLI and live runtime > refuses missing and malformed input before contacting relayflowd 2451ms + ✓ direct .flow.ts input through the built CLI and live runtime > does not run the authored body before daemon availability 843ms + ✓ direct .flow.ts input through the built CLI and live runtime > refuses oversized file input before contacting relayflowd 873ms + ✓ tests/f-memory.test.ts (7 tests) 757ms + ✓ tests/webhook.test.ts (6 tests) 55ms + ✓ tests/local-dev-ux.test.ts (8 tests) 14ms + ✓ tests/dependency-validation.test.ts (6 tests) 324ms + ✓ tests/input-binding.test.ts (12 tests) 103ms + ✓ tests/daemon-lifecycle-live.test.ts (9 tests) 13084ms + ✓ flows run against a data dir with no daemon (§6 test 7) > cold start spawns exactly one daemon, the run succeeds, and the daemon outlives the CLI 1357ms + ✓ flows run against a data dir with no daemon (§6 test 7) > polls, bounded, for a daemon that holds the lock before it binds 2432ms + ✓ flows run against a data dir with no daemon (§6 test 7) > attaches to a serving daemon that has not published a connection file 1181ms + ✓ flows run against a data dir with no daemon (§6 test 7) > a second run attaches to the daemon the first one started, spawning nothing 2090ms + ✓ flows run against a data dir with no daemon (§6 test 7) > detects a stale connection file left by a hard kill and starts a fresh daemon 1822ms + ✓ concurrent invocations against one empty data dir (§6 test 15) > ends with exactly one daemon owning the socket, and both runs succeed 1385ms + ✓ refusals from a spawn that cannot produce a daemon > names relayflowd_not_found rather than falling through to PATH 827ms + ✓ refusals from a spawn that cannot produce a daemon > names daemon_start_failed and quotes the daemon log when startup dies 936ms + ✓ refusals from a spawn that cannot produce a daemon > refuses a daemon speaking another protocol version instead of binding over it 1053ms + ✓ tests/deterministic-llm.test.ts (5 tests) 36ms + ✓ tests/scope-compiler.test.ts (25 tests) 5ms + ✓ tests/budget-attribution.test.ts (4 tests) 2ms + ✓ tests/hn-poller.test.ts (6 tests) 3ms + ✓ tests/dir-watcher-poller.test.ts (6 tests) 2ms + ✓ tests/flow-executor-chain.test.ts (12 tests) 13686ms + ✓ flow executor LLM and output-binding chain > runs f.llm -> f.agent -> f.run with schema-verified journal output and the exact allowed model 2334ms + ✓ flow executor LLM and output-binding chain > fails invalid LLM output before the next step: not JSON 1200ms + ✓ flow executor LLM and output-binding chain > fails invalid LLM output before the next step: {"message":7} 1372ms + ✓ flow executor LLM and output-binding chain > retains tagged-template text output 1246ms + ✓ flow executor LLM and output-binding chain > preserves JSON values without promoting them to process wrappers: null 1174ms + ✓ flow executor LLM and output-binding chain > preserves JSON values without promoting them to process wrappers: [1,2] 904ms + ✓ flow executor LLM and output-binding chain > preserves JSON values without promoting them to process wrappers: "hello" 833ms + ✓ flow executor LLM and output-binding chain > runs the authored LLM path through the built flows CLI 1216ms + ✓ flow executor LLM and output-binding chain > passes a declarative verified value through an agent into a deterministic artifact 619ms + ✓ flow executor LLM and output-binding chain > journals a missing optional field as a failure before the consuming command executes 537ms + ✓ flow executor LLM and output-binding chain > flows run consumes YAML bindings and resume reuses the original journal output 2217ms + ✓ tests/hello-deterministic.test.ts (5 tests) 10ms + ✓ tests/budget-preflight.test.ts (16 tests) 5ms + ✓ tests/work-package-validator.test.ts (7 tests) 3ms + ✓ tests/bin.test.ts (7 tests) 1921ms + ✓ built flows binary > refuses through a symlink to the built artifact 441ms + ✓ built flows binary > classifies a signal-terminated auth probe as probe_failed 468ms + ✓ built flows binary > runs one auth probe for three steps sharing a flow CLI 429ms + ✓ tests/yaml-local-agent-live.test.ts (7 tests) 3434ms + ✓ YAML --local-agent through the built CLI and real daemon > runs with the checked step CLI and model and journals done 837ms + ✓ YAML --local-agent through the built CLI and real daemon > runs with the checked named CLI and model and journals done 463ms + ✓ YAML --local-agent through the built CLI and real daemon > runs with the checked flow CLI and model and journals done 482ms + ✓ YAML --local-agent through the built CLI and real daemon > runs with the checked project CLI and model and journals done 472ms + ✓ YAML --local-agent through the built CLI and real daemon > still parks without --local-agent 343ms + ✓ YAML --local-agent through the built CLI and real daemon > reports the agent process failure 485ms + ✓ YAML --local-agent through the built CLI and real daemon > preserves declared workspace surfaces that the local worker cannot pin 351ms + ↓ tests/real-cli-adapters.test.ts (3 tests | 3 skipped) + ✓ tests/model-pricing.test.ts (8 tests) 2ms + ✓ tests/authored-use-loader.test.ts (5 tests) 31ms + ✓ tests/parse-json-output.test.ts (7 tests) 1ms + ✓ tests/cli-adapter.test.ts (3 tests) 2ms + ✓ tests/pty-sidechannel.test.ts (5 tests) 2105ms + ✓ view attach preserves worker completion and marks only drive 635ms + ✓ passthrough attach preserves worker completion and marks only drive 616ms + ✓ none attach preserves worker completion and marks only drive 626ms + ✓ tests/journal-client-completion.test.ts (4 tests) 98ms + ✓ tests/direct-run-failure.test.ts (6 tests) 3ms + ✓ tests/memoization.test.ts (57 tests) 697ms + ✓ refuses invalid reuse invocation "run" 670ms + ✓ tests/cli.test.ts (63 tests) 20258ms + ✓ flows check CLI > binds a checked relative wrapper to the flow directory for worker execution 1068ms + ✓ flows check CLI > uses the raw Claude adapter model flag instead of accepting auth status as model proof 770ms + ✓ flows check CLI > uses Codex login status and reports a rejected model as unavailable, not unauthenticated 1502ms + ✓ flows check CLI > refuses a nonconforming custom wrapper without calling it an authentication failure 910ms + ✓ flows check CLI > accepts an exact allowlisted named-agent model and probes that model 1459ms + ✓ flows check CLI > checks the same named-agent contract from declarative JSON 1445ms + ✓ flows check CLI > refuses a typo model before probing or contacting relayflowd 909ms + ✓ flows check CLI > distinguishes an allowlisted but inaccessible model from broken auth 1044ms + ✓ flows check CLI > refuses cli-unauthenticated.flow.yaml with typed kind cli_unauthenticated and exit 2 870ms + ✓ flows check CLI > resolves a project CLI path relative to the flows.json that declares it 901ms + ✓ flows check CLI > maps every input refusal path to its declared kind without raw exceptions 1856ms + ✓ flows run/resume CLI over the journal protocol > exits 1 and emits the declared completionReason for a failed run 821ms + ✓ flows run/resume CLI over the journal protocol > exits 3 and names the parked llm step 634ms + ✓ flows run/resume CLI over the journal protocol > reports a needs_human agent step as parked for human recovery 631ms + ✓ flows run/resume CLI over the journal protocol > classifies a typed hello refusal as a protocol error, not an unreachable daemon 670ms + ✓ flows run/resume CLI over the journal protocol > follows a dispatched worker step instead of reporting a protocol error 394ms + ✓ flows run/resume CLI over the journal protocol > bounds a worker wait by its lease and reports what it is waiting for 781ms + ✓ flows run/resume CLI over the journal protocol > resumes a parked run from snapshot step types without reading journal sequence one 699ms + ✓ flows run/resume CLI over the journal protocol > maps only run_not_found resumes to exit 2 2254ms + ✓ tests/budget-authored-live.test.ts (2 tests) 127ms + ✓ tests/slack-writeback.test.ts (1 test) 258ms + ✓ tests/placement.test.ts (54 tests) 7ms + ✓ tests/memory.test.ts (18 tests) 4ms + ✓ tests/worker-platform.test.ts (1 test) 2ms + ✓ tests/classify-outcome.test.ts (2 tests) 2224ms + ✓ classifyOutcome > gives up and reports when a running run never becomes classifiable 2066ms + ✓ tests/cli-progress-wait.test.ts (2 tests) 1310ms + ✓ run starts the wait clock on its first observed lease 611ms + ✓ resume starts the wait clock on its first observed lease 699ms + ✓ tests/worker-cli-abort.test.ts (2 tests) 2525ms + ✓ stops claude and its process group when lease ownership is lost 1267ms + ✓ stops wrapper.mjs and its process group when lease ownership is lost 1258ms + ✓ tests/worker-cli.test.ts (13 tests) 26003ms + ✓ custom wrapper execution identity > passes an explicit safe environment at identification and execution 496ms + ✓ custom wrapper execution identity > refuses a wrapper symlink retarget before delivering private values 1072ms + ✓ custom wrapper execution identity > bounds wrapper execution after acknowledgement 986ms + ✓ custom wrapper execution identity > bounds captured wrapper output 1456ms + ✓ custom wrapper execution identity > refuses a duplicate execute protocol frame 1149ms + ✓ custom wrapper execution bounds are reader-owned > resolves when a conforming wrapper leaks a stdio pipe to a background helper 2982ms + ✓ custom wrapper execution bounds are reader-owned > resolves when the leaked helper inherits stderr only 2388ms + ✓ custom wrapper execution bounds are reader-owned > resolves when a wrapper leaks a stdio pipe and exits before identifying 3256ms + ✓ custom wrapper execution bounds are reader-owned > journals a completionReason at the default bound when a wrapper leaks a stdio pipe 11256ms + ✓ custom wrapper execution bounds are reader-owned > accepts the same over-8KiB payload whether or not it coalesces with the execute token 413ms + ❯ tests/cli-watch.test.ts (10 tests | 8 failed) 40824ms + × flows check --watch > rechecks syntax errors, clears once, and returns the last refusal on Ctrl-C 5005ms + → Test timed out in 5000ms. +If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". + × flows check --watch > streams JSON lines without ANSI, recovers after atomic saves, and exits zero after repair 5002ms + → Test timed out in 5000ms. +If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". + × flows check --watch > coalesces 20 concurrent saves into at most two rechecks 5003ms + → Test timed out in 5000ms. +If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". + × flows check --watch > watches transitive relative use imports, cycles, and nearest config changes 5003ms + → Test timed out in 5000ms. +If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". + × flows check --watch > refreshes the import graph and notices missing imports being created 5003ms + → Test timed out in 5000ms. +If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". + × flows check --watch > reloads authored TypeScript instead of reusing the first imported definition 5004ms + → Test timed out in 5000ms. +If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". + × flows check --watch > detects a nearer config appearing and falls back after it is deleted 5002ms + → Test timed out in 5000ms. +If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". + × flows check --watch > keeps watching after the target is deleted and recreated 5003ms + → Test timed out in 5000ms. +If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". + ✓ flows check --watch > queues changes during a slow check without overlapping checks 794ms + ✓ tests/local-agent-live.test.ts (5 tests) 39681ms + ✓ built CLI local agent against a real daemon > dispatches through the wrapper and keeps --json stdout report-shaped 917ms + ✓ built CLI local agent against a real daemon > runs beyond the initial 30-second lease without a second invocation 35883ms + ✓ built CLI local agent against a real daemon > renders actual agent completion in text output 824ms + ✓ built CLI local agent against a real daemon > returns a failed run when the agent process fails 973ms + ✓ built CLI local agent against a real daemon > refuses a workspace it cannot pin before invoking the agent 1082ms +stdout | tests/live-kernel.test.ts > surface resume after a real daemon kill > resumes a three-step run with each successful completion exactly once +LIVE_KERNEL kill -9 pid=42107 run=01M28VTDC2V93DVMKY33GJ0H6H while step=two state=Running + + ❯ tests/live-kernel.test.ts (31 tests | 2 failed) 67786ms + ✓ built flows CLI against live relayflowd > twenty-six-step reuses 25 durable completions after editing the failed final step 3140ms + ✓ built flows CLI against live relayflowd > runs rung (a), parks rung (b), and keeps JSON report-shaped 6433ms + ✓ built flows CLI against live relayflowd > allows a deterministic run to exceed the bounded request timeout 32287ms + ✓ built flows CLI against live relayflowd > follows a live worker dispatch through flows run 1412ms + ✓ built flows CLI against live relayflowd > runs an agent CLI end to end through the SDK worker 378ms + ✓ built flows CLI against live relayflowd > f.agent lowers to a real agent step and dispatches through a live worker 450ms + ✓ built flows CLI against live relayflowd > f.agent's default flowPath anchors on cwd, not cwd's parent 464ms + ✓ built flows CLI against live relayflowd > can always get a parked run to a late-attaching worker 5564ms + × built flows CLI against live relayflowd > reports a real manual-recovery NeedsHuman state as parked 5007ms + → Test timed out in 5000ms. +If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". + ✓ built flows CLI against live relayflowd > runs hn-monitor analyze-story end-to-end via a stub agent CLI (gate 2 clause 2 demo) 377ms + ✓ built flows CLI against live relayflowd > hn-monitor analyze-story FAILS verification when the CLI omits required schema fields 371ms + ✓ built flows CLI against live relayflowd > agent step preserves the CliResult wrapper as output when the CLI emits non-JSON text 364ms + ✓ built flows CLI against live relayflowd > AgentWorker exposes wake_context to the CLI via RELAYFLOW_WAKE_CONTEXT env var (real analyzer prerequisite) 420ms + ✓ built flows CLI against live relayflowd > AgentWorker leaves RELAYFLOW_WAKE_CONTEXT UNSET when the run has no wake_context (undefined-vs-null pin) 413ms + ✓ built flows CLI against live relayflowd > AgentWorker passes a declared model to an identified wrapper as RELAYFLOW_MODEL 515ms + ✓ built flows CLI against live relayflowd > AgentWorker refuses a nonconforming journal-submitted wrapper before exposing RELAYFLOW_MODEL 398ms + ✓ built flows CLI against live relayflowd > AgentWorker executes the raw claude adapter with its real model flag 346ms + ✓ built flows CLI against live relayflowd > AgentWorker executes the raw codex adapter with its real model flag 393ms + ✓ built flows CLI against live relayflowd > AgentWorker leaves RELAYFLOW_MODEL UNSET when the step declares no model 489ms + × built flows CLI against live relayflowd > hn-monitor analyze-story reaches done through the real Claude analyzer CLI 1796ms + → LIVE_ANALYZER_UNAVAILABLE: "/Users/khaliqgant/flows-spec-Q-sidechannel/testdata/preflight/analyze-story-claude-cli auth status" exited 1: analyze-story-claude-cli: "claude -p --model claude-haiku-4-5-20251001" exited 1: — failing because gate-2 acceptance requires the real analyzer to execute. Set RELAYFLOWS_ALLOW_ANALYZER_SKIP=1 only if this run is not gate evidence. + ✓ built flows CLI against live relayflowd > preflights before journaling and names an unreachable socket 2191ms + ✓ built flows CLI against live relayflowd > starts exactly one daemon when two runs race for one empty data dir 1088ms + ✓ surface resume after a real daemon kill > resumes a three-step run with each successful completion exactly once 2745ms + ✓ a relayflow can be scheduled: tick source against live relayflowd > a tick spawns a real run whose step reports the SCHEDULED instant 379ms + +⎯⎯⎯⎯⎯⎯ Failed Tests 11 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL tests/bundle.test.ts > immutable bundles > builds a standalone TS fixture twice with identical executable hashes +AssertionError: expected 'REFUSED [bundle_invalid] TypeScript b…' to be '' // Object.is equality + +- Expected ++ Received + ++ REFUSED [bundle_invalid] TypeScript build requires Bun: spawnSync bun ENOENT ++ + + ❯ tests/bundle.test.ts:210:27 + 208| it('builds a standalone TS fixture twice with identical executable h… + 209| const result = invoke(['--out', await temp(), 'packages/sdk/tests/… + 210| expect(result.stderr).toBe(''); expect(result.status).toBe(0); + | ^ + 211| const bundle = result.stdout.trim(); + 212| const second = invoke(['--out', await temp(), 'packages/sdk/tests/… + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/11]⎯ + + FAIL tests/cli-watch.test.ts > flows check --watch > rechecks syntax errors, clears once, and returns the last refusal on Ctrl-C + FAIL tests/cli-watch.test.ts > flows check --watch > streams JSON lines without ANSI, recovers after atomic saves, and exits zero after repair + FAIL tests/cli-watch.test.ts > flows check --watch > coalesces 20 concurrent saves into at most two rechecks + FAIL tests/cli-watch.test.ts > flows check --watch > watches transitive relative use imports, cycles, and nearest config changes + FAIL tests/cli-watch.test.ts > flows check --watch > refreshes the import graph and notices missing imports being created + FAIL tests/cli-watch.test.ts > flows check --watch > reloads authored TypeScript instead of reusing the first imported definition + FAIL tests/cli-watch.test.ts > flows check --watch > detects a nearer config appearing and falls back after it is deleted + FAIL tests/cli-watch.test.ts > flows check --watch > keeps watching after the target is deleted and recreated + FAIL tests/live-kernel.test.ts > built flows CLI against live relayflowd > reports a real manual-recovery NeedsHuman state as parked +Error: Test timed out in 5000ms. +If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/11]⎯ + + FAIL tests/live-kernel.test.ts > built flows CLI against live relayflowd > hn-monitor analyze-story reaches done through the real Claude analyzer CLI +Error: LIVE_ANALYZER_UNAVAILABLE: "/Users/khaliqgant/flows-spec-Q-sidechannel/testdata/preflight/analyze-story-claude-cli auth status" exited 1: analyze-story-claude-cli: "claude -p --model claude-haiku-4-5-20251001" exited 1: — failing because gate-2 acceptance requires the real analyzer to execute. Set RELAYFLOWS_ALLOW_ANALYZER_SKIP=1 only if this run is not gate evidence. + ❯ tests/live-kernel.test.ts:1223:15 + 1221| const notice = `LIVE_ANALYZER_UNAVAILABLE: ${readiness.detail}`; + 1222| if (process.env['RELAYFLOWS_ALLOW_ANALYZER_SKIP'] !== '1') { + 1223| throw new Error( + | ^ + 1224| `${notice} — failing because gate-2 acceptance requires the … + 1225| + 'Set RELAYFLOWS_ALLOW_ANALYZER_SKIP=1 only if this run is … + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/11]⎯ + +⎯⎯⎯⎯⎯⎯ Unhandled Errors ⎯⎯⎯⎯⎯⎯ + +Vitest caught 8 unhandled errors during the test run. +This might cause false positive tests. Resolve unhandled errors to make sure your tests are not affected. + +⎯⎯⎯⎯⎯ Uncaught Exception ⎯⎯⎯⎯⎯ +Error: spawn bun ENOENT + ❯ Process.ChildProcess._handle.onexit node:internal/child_process:286:19 + ❯ onErrorNT node:internal/child_process:507:16 + ❯ processTicksAndRejections node:internal/process/task_queues:90:21 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ +Serialized Error: { errno: -2, code: 'ENOENT', syscall: 'spawn bun', path: 'bun', spawnargs: [ 'run', 'flows', 'check', '--watch', '/var/folders/_z/f_fpl8j533g_r63706k2xvp00000gn/T/flows-watch-T0QOru/fixture.yaml' ] } +This error originated in "tests/cli-watch.test.ts" test file. It doesn't mean the error was thrown inside the file itself, but while it was running. +The latest test that might've caused the error is "rechecks syntax errors, clears once, and returns the last refusal on Ctrl-C". It might mean one of the following: +- The error was thrown, while Vitest was running this test. +- If the error occurred after the test had been completed, this was the last documented test before it was thrown. + +⎯⎯⎯⎯⎯ Uncaught Exception ⎯⎯⎯⎯⎯ +Error: spawn bun ENOENT + ❯ Process.ChildProcess._handle.onexit node:internal/child_process:286:19 + ❯ onErrorNT node:internal/child_process:507:16 + ❯ processTicksAndRejections node:internal/process/task_queues:90:21 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ +Serialized Error: { errno: -2, code: 'ENOENT', syscall: 'spawn bun', path: 'bun', spawnargs: [ 'run', 'flows', 'check', '--watch', '--json', '/var/folders/_z/f_fpl8j533g_r63706k2xvp00000gn/T/flows-watch-zyTD6U/fixture.yaml' ] } +This error originated in "tests/cli-watch.test.ts" test file. It doesn't mean the error was thrown inside the file itself, but while it was running. +The latest test that might've caused the error is "streams JSON lines without ANSI, recovers after atomic saves, and exits zero after repair". It might mean one of the following: +- The error was thrown, while Vitest was running this test. +- If the error occurred after the test had been completed, this was the last documented test before it was thrown. + +⎯⎯⎯⎯⎯ Uncaught Exception ⎯⎯⎯⎯⎯ +Error: spawn bun ENOENT + ❯ Process.ChildProcess._handle.onexit node:internal/child_process:286:19 + ❯ onErrorNT node:internal/child_process:507:16 + ❯ processTicksAndRejections node:internal/process/task_queues:90:21 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ +Serialized Error: { errno: -2, code: 'ENOENT', syscall: 'spawn bun', path: 'bun', spawnargs: [ 'run', 'flows', 'check', '--watch', '--json', '/var/folders/_z/f_fpl8j533g_r63706k2xvp00000gn/T/flows-watch-hEvfm5/fixture.yaml' ] } +This error originated in "tests/cli-watch.test.ts" test file. It doesn't mean the error was thrown inside the file itself, but while it was running. +The latest test that might've caused the error is "coalesces 20 concurrent saves into at most two rechecks". It might mean one of the following: +- The error was thrown, while Vitest was running this test. +- If the error occurred after the test had been completed, this was the last documented test before it was thrown. + +⎯⎯⎯⎯⎯ Uncaught Exception ⎯⎯⎯⎯⎯ +Error: spawn bun ENOENT + ❯ Process.ChildProcess._handle.onexit node:internal/child_process:286:19 + ❯ onErrorNT node:internal/child_process:507:16 + ❯ processTicksAndRejections node:internal/process/task_queues:90:21 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ +Serialized Error: { errno: -2, code: 'ENOENT', syscall: 'spawn bun', path: 'bun', spawnargs: [ 'run', 'flows', 'check', '--watch', '--json', '/var/folders/_z/f_fpl8j533g_r63706k2xvp00000gn/T/flows-watch-FFk1D6/fixture.yaml' ] } +This error originated in "tests/cli-watch.test.ts" test file. It doesn't mean the error was thrown inside the file itself, but while it was running. +The latest test that might've caused the error is "watches transitive relative use imports, cycles, and nearest config changes". It might mean one of the following: +- The error was thrown, while Vitest was running this test. +- If the error occurred after the test had been completed, this was the last documented test before it was thrown. + +⎯⎯⎯⎯⎯ Uncaught Exception ⎯⎯⎯⎯⎯ +Error: spawn bun ENOENT + ❯ Process.ChildProcess._handle.onexit node:internal/child_process:286:19 + ❯ onErrorNT node:internal/child_process:507:16 + ❯ processTicksAndRejections node:internal/process/task_queues:90:21 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ +Serialized Error: { errno: -2, code: 'ENOENT', syscall: 'spawn bun', path: 'bun', spawnargs: [ 'run', 'flows', 'check', '--watch', '--json', '/var/folders/_z/f_fpl8j533g_r63706k2xvp00000gn/T/flows-watch-UI7Vqu/fixture.yaml' ] } +This error originated in "tests/cli-watch.test.ts" test file. It doesn't mean the error was thrown inside the file itself, but while it was running. +The latest test that might've caused the error is "refreshes the import graph and notices missing imports being created". It might mean one of the following: +- The error was thrown, while Vitest was running this test. +- If the error occurred after the test had been completed, this was the last documented test before it was thrown. + +⎯⎯⎯⎯⎯ Uncaught Exception ⎯⎯⎯⎯⎯ +Error: spawn bun ENOENT + ❯ Process.ChildProcess._handle.onexit node:internal/child_process:286:19 + ❯ onErrorNT node:internal/child_process:507:16 + ❯ processTicksAndRejections node:internal/process/task_queues:90:21 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ +Serialized Error: { errno: -2, code: 'ENOENT', syscall: 'spawn bun', path: 'bun', spawnargs: [ 'run', 'flows', 'check', '--watch', '--json', '/var/folders/_z/f_fpl8j533g_r63706k2xvp00000gn/T/flows-watch-JWzNP0/authored.flow.ts' ] } +This error originated in "tests/cli-watch.test.ts" test file. It doesn't mean the error was thrown inside the file itself, but while it was running. +The latest test that might've caused the error is "reloads authored TypeScript instead of reusing the first imported definition". It might mean one of the following: +- The error was thrown, while Vitest was running this test. +- If the error occurred after the test had been completed, this was the last documented test before it was thrown. + +⎯⎯⎯⎯⎯ Uncaught Exception ⎯⎯⎯⎯⎯ +Error: spawn bun ENOENT + ❯ Process.ChildProcess._handle.onexit node:internal/child_process:286:19 + ❯ onErrorNT node:internal/child_process:507:16 + ❯ processTicksAndRejections node:internal/process/task_queues:90:21 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ +Serialized Error: { errno: -2, code: 'ENOENT', syscall: 'spawn bun', path: 'bun', spawnargs: [ 'run', 'flows', 'check', '--watch', '--json', '/var/folders/_z/f_fpl8j533g_r63706k2xvp00000gn/T/flows-watch-K2OPSl/sub/fixture.yaml' ] } +This error originated in "tests/cli-watch.test.ts" test file. It doesn't mean the error was thrown inside the file itself, but while it was running. +The latest test that might've caused the error is "detects a nearer config appearing and falls back after it is deleted". It might mean one of the following: +- The error was thrown, while Vitest was running this test. +- If the error occurred after the test had been completed, this was the last documented test before it was thrown. + +⎯⎯⎯⎯⎯ Uncaught Exception ⎯⎯⎯⎯⎯ +Error: spawn bun ENOENT + ❯ Process.ChildProcess._handle.onexit node:internal/child_process:286:19 + ❯ onErrorNT node:internal/child_process:507:16 + ❯ processTicksAndRejections node:internal/process/task_queues:90:21 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ +Serialized Error: { errno: -2, code: 'ENOENT', syscall: 'spawn bun', path: 'bun', spawnargs: [ 'run', 'flows', 'check', '--watch', '--json', '/var/folders/_z/f_fpl8j533g_r63706k2xvp00000gn/T/flows-watch-RUVTus/fixture.yaml' ] } +This error originated in "tests/cli-watch.test.ts" test file. It doesn't mean the error was thrown inside the file itself, but while it was running. +The latest test that might've caused the error is "keeps watching after the target is deleted and recreated". It might mean one of the following: +- The error was thrown, while Vitest was running this test. +- If the error occurred after the test had been completed, this was the last documented test before it was thrown. +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ + + Test Files 3 failed | 66 passed | 1 skipped (70) + Tests 11 failed | 1204 passed | 3 skipped (1218) + Errors 8 errors + Start at 20:30:25 + Duration 68.83s (transform 1.68s, setup 0ms, collect 13.49s, tests 285.92s, environment 6ms, prepare 2.86s) + diff --git a/docs/evidence/spec-Q/typecheck-tests.txt b/docs/evidence/spec-Q/typecheck-tests.txt new file mode 100644 index 000000000..daf02e077 --- /dev/null +++ b/docs/evidence/spec-Q/typecheck-tests.txt @@ -0,0 +1,4 @@ + +> @relayflows/sdk@2.0.8 typecheck:tests +> tsc -p tsconfig.tests.json + diff --git a/docs/evidence/spec-Q/typecheck.txt b/docs/evidence/spec-Q/typecheck.txt new file mode 100644 index 000000000..326089087 --- /dev/null +++ b/docs/evidence/spec-Q/typecheck.txt @@ -0,0 +1,4 @@ + +> @relayflows/sdk@2.0.8 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + diff --git a/kernel/relayflowd-core/src/entry.rs b/kernel/relayflowd-core/src/entry.rs index 645f0cf62..6875ad7ce 100644 --- a/kernel/relayflowd-core/src/entry.rs +++ b/kernel/relayflowd-core/src/entry.rs @@ -251,6 +251,8 @@ pub enum Disposition { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct StepCompletedPayload { + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub human_intervention: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub step_spec_hash: Option, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/kernel/relayflowd-core/src/machine.rs b/kernel/relayflowd-core/src/machine.rs index 0a0282814..0d41a2561 100644 --- a/kernel/relayflowd-core/src/machine.rs +++ b/kernel/relayflowd-core/src/machine.rs @@ -50,6 +50,7 @@ pub enum Action { #[derive(Debug, Clone, PartialEq)] pub struct AttemptResult { + pub human_intervention: bool, pub output: Value, pub budget: Budget, pub completed_by: String, @@ -67,6 +68,7 @@ pub struct AttemptResult { impl AttemptResult { pub fn successful(output: Value, completed_by: impl Into) -> Self { Self { + human_intervention: false, output, budget: Budget::default(), completed_by: completed_by.into(), @@ -415,6 +417,7 @@ pub fn completion_actions( Some(attempt), now_ms, StepCompletedPayload { + human_intervention: result.human_intervention, step_spec_hash: None, input_hash: None, reused_from: None, diff --git a/kernel/relayflowd-core/src/machine/cancel.rs b/kernel/relayflowd-core/src/machine/cancel.rs index 32770ec2e..728d540bd 100644 --- a/kernel/relayflowd-core/src/machine/cancel.rs +++ b/kernel/relayflowd-core/src/machine/cancel.rs @@ -43,6 +43,7 @@ pub(super) fn cancel_run_actions(state: &RunState, now_ms: i64) -> Vec { Some(*attempt), now_ms, StepCompletedPayload { + human_intervention: false, step_spec_hash: None, input_hash: None, reused_from: None, diff --git a/kernel/relayflowd-core/src/machine/parallel_tests.rs b/kernel/relayflowd-core/src/machine/parallel_tests.rs index a85b3fe1e..755bdb3d1 100644 --- a/kernel/relayflowd-core/src/machine/parallel_tests.rs +++ b/kernel/relayflowd-core/src/machine/parallel_tests.rs @@ -436,6 +436,7 @@ fn failed_run_drains_open_siblings_before_terminal_entry() { Some(1), 22, StepCompletedPayload { + human_intervention: false, step_spec_hash: None, input_hash: None, reused_from: None, diff --git a/kernel/relayflowd-core/src/machine/recovery.rs b/kernel/relayflowd-core/src/machine/recovery.rs index 984dc547d..e59c236c4 100644 --- a/kernel/relayflowd-core/src/machine/recovery.rs +++ b/kernel/relayflowd-core/src/machine/recovery.rs @@ -114,6 +114,7 @@ pub fn abandonment_actions( Some(attempt), now_ms, StepCompletedPayload { + human_intervention: false, step_spec_hash: None, input_hash: None, reused_from: None, diff --git a/kernel/relayflowd-core/src/machine/tests.rs b/kernel/relayflowd-core/src/machine/tests.rs index 8d27e322d..2115ccf84 100644 --- a/kernel/relayflowd-core/src/machine/tests.rs +++ b/kernel/relayflowd-core/src/machine/tests.rs @@ -288,6 +288,7 @@ fn all_backing_off_steps_return_timers() { })) .unwrap(); let result = AttemptResult { + human_intervention: false, output: Value::Null, budget: Budget::default(), completed_by: "kernel".to_owned(), @@ -398,6 +399,7 @@ fn inspect_recovery_injects_the_dirty_pin_completion_reason_and_tail() { let started = started_agent(&spec, clean); let running = RunState::fold("run", spec.clone(), std::slice::from_ref(&started)).unwrap(); let result = AttemptResult { + human_intervention: false, output: Value::Null, budget: Budget::default(), completed_by: "worker".to_owned(), @@ -515,6 +517,7 @@ fn worker_reported_failure_without_detail_still_records_a_verification() { })) .unwrap(); let result = AttemptResult { + human_intervention: false, output: Value::Null, budget: Budget::default(), completed_by: "worker".to_owned(), diff --git a/kernel/relayflowd-core/src/memoization.rs b/kernel/relayflowd-core/src/memoization.rs index d16c9732e..a9b174808 100644 --- a/kernel/relayflowd-core/src/memoization.rs +++ b/kernel/relayflowd-core/src/memoization.rs @@ -73,7 +73,8 @@ pub fn candidates(entries: &[JournalEntry]) -> Result, serde_j .filter(|e| e.entry_type == EntryType::StepCompleted) { let payload: StepCompletedPayload = serde_json::from_value(entry.payload.clone())?; - if payload.completion_reason == CompletionReason::Success + if !payload.human_intervention + && payload.completion_reason == CompletionReason::Success && payload.disposition == Disposition::StepDone && payload.step_spec_hash.is_some() && payload.input_hash.is_some() diff --git a/kernel/relayflowd-core/src/state/tests.rs b/kernel/relayflowd-core/src/state/tests.rs index e40c27229..c36d40984 100644 --- a/kernel/relayflowd-core/src/state/tests.rs +++ b/kernel/relayflowd-core/src/state/tests.rs @@ -22,6 +22,7 @@ fn journal_replays_data_gate_verdict_without_rerunning_completed_code() { Some(1), 5, StepCompletedPayload { + human_intervention: false, step_spec_hash: None, input_hash: None, reused_from: None, diff --git a/kernel/relayflowd-core/tests/memoization.rs b/kernel/relayflowd-core/tests/memoization.rs index 4021387cf..b1cfda4e5 100644 --- a/kernel/relayflowd-core/tests/memoization.rs +++ b/kernel/relayflowd-core/tests/memoization.rs @@ -99,6 +99,9 @@ fn changed_spec_or_input_dispatches_and_legacy_or_failed_records_miss() { .unwrap() .remove("step_spec_hash"); assert!(candidates(&[legacy]).unwrap().is_empty()); + let mut influenced = source(&state); + influenced.payload["human_intervention"] = true.into(); + assert!(candidates(&[influenced]).unwrap().is_empty()); let mut failed = source(&state); failed.payload["completionReason"] = "worker_error".into(); assert!(candidates(&[failed]).unwrap().is_empty()); diff --git a/kernel/relayflowd/src/engine.rs b/kernel/relayflowd/src/engine.rs index a005306c3..886b6ce55 100644 --- a/kernel/relayflowd/src/engine.rs +++ b/kernel/relayflowd/src/engine.rs @@ -12,6 +12,21 @@ use relayflowd_journal::{Registry, SqliteJournal}; use sha2::{Digest, Sha256}; use ulid::Ulid; +#[derive(Debug)] +pub struct HumanInfluencedRun { + pub step_id: String, +} +impl std::fmt::Display for HumanInfluencedRun { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "step {:?}; pass --allow-human-influenced to continue", + self.step_id + ) + } +} +impl std::error::Error for HumanInfluencedRun {} + #[derive(Debug)] pub struct RunTerminalError { pub run_id: String, @@ -52,6 +67,7 @@ pub use wake::EventSubmitOutcome; #[derive(Debug, Clone, Default)] #[doc(hidden)] pub struct DriveOptions { + pub allow_human_influenced: bool, pub stop_after: Option, /// Test/debug hook: pause immediately before this runnable step starts. pub pause_before_step: Option, @@ -243,6 +259,22 @@ impl Engine { lease_is_active: &dyn Fn(&str, u32) -> bool, ) -> Result { let mut journal = self.open_run(run_id)?; + if !options.allow_human_influenced { + for entry in journal.scan_all()? { + if entry.entry_type == EntryType::StepCompleted + && entry + .payload + .get("human_intervention") + .and_then(serde_json::Value::as_bool) + == Some(true) + { + return Err(HumanInfluencedRun { + step_id: entry.step_id.unwrap_or_default(), + } + .into()); + } + } + } let registry = self.registry()?; if registry.lookup(run_id)?.is_none() { registry diff --git a/kernel/relayflowd/src/engine/remote.rs b/kernel/relayflowd/src/engine/remote.rs index 3956b7251..ff36aa78c 100644 --- a/kernel/relayflowd/src/engine/remote.rs +++ b/kernel/relayflowd/src/engine/remote.rs @@ -17,6 +17,7 @@ use crate::worker::LeaseProbe; #[derive(Debug, Clone)] pub struct OutOfBandCompletion { + pub human_intervention: bool, pub attempt: u32, pub idempotency_key: String, pub completion_reason: CompletionReason, @@ -127,6 +128,7 @@ impl Engine { completion.end_pins }; let result = AttemptResult { + human_intervention: completion.human_intervention, output: completion.output, budget: completion.budget, completed_by: completion.completed_by, @@ -153,10 +155,24 @@ impl Engine { /// valid, heartbeating lease are left running; only genuinely dead /// attempts (worker detached, or lease deadline passed) are recovered. pub fn resume_live(&self, run_id: &str, leases: &dyn LeaseProbe) -> Result { + self.resume_live_with_human_influence(run_id, leases, false) + } + + pub fn resume_live_with_human_influence( + &self, + run_id: &str, + leases: &dyn LeaseProbe, + allow_human_influenced: bool, + ) -> Result { let now_ms = self.clock.now_ms(); - self.resume_filtered(run_id, DriveOptions::default(), &|step_id, attempt| { - leases.lease_active(run_id, step_id, attempt, now_ms) - }) + self.resume_filtered( + run_id, + DriveOptions { + allow_human_influenced, + ..DriveOptions::default() + }, + &|step_id, attempt| leases.lease_active(run_id, step_id, attempt, now_ms), + ) } /// Explain a dead leased attempt (worker disconnect or lease expiry), then diff --git a/kernel/relayflowd/src/exec_det.rs b/kernel/relayflowd/src/exec_det.rs index d8a541224..9a10a83b8 100644 --- a/kernel/relayflowd/src/exec_det.rs +++ b/kernel/relayflowd/src/exec_det.rs @@ -114,6 +114,7 @@ pub(crate) fn execute_placed_with_input( "stderr_tail": tail(&stderr), }); AttemptResult { + human_intervention: false, output, budget: Budget::default(), completed_by: "kernel".to_owned(), @@ -155,6 +156,7 @@ fn tail(bytes: &[u8]) -> String { fn worker_error(detail: &str) -> AttemptResult { AttemptResult { + human_intervention: false, output: json!({"error": detail}), budget: Budget::default(), completed_by: "kernel".to_owned(), diff --git a/kernel/relayflowd/src/main.rs b/kernel/relayflowd/src/main.rs index fc4274743..03c0050b8 100644 --- a/kernel/relayflowd/src/main.rs +++ b/kernel/relayflowd/src/main.rs @@ -82,6 +82,7 @@ fn main() -> Result<()> { stop_after, pause_before_step, pause_before_completion, + ..DriveOptions::default() }, )?; println!("{}", serde_json::to_string(&outcome)?); diff --git a/kernel/relayflowd/src/server.rs b/kernel/relayflowd/src/server.rs index 7bcbbb784..38bc7cb95 100644 --- a/kernel/relayflowd/src/server.rs +++ b/kernel/relayflowd/src/server.rs @@ -182,7 +182,7 @@ fn handle_request( ) } "run.resume" => { - let params: RunIdParams = decode_params(request.params)?; + let params: RunResumeParams = decode_params(request.params)?; let registry = relayflowd_journal::Registry::open(data_dir.join("relayflowd.sqlite3")) .map_err(|error| internal_error(error.into()))?; if registry @@ -257,8 +257,21 @@ fn handle_request( // Live resume: attempts with a valid, heartbeating lease on this // hub stay running; only genuinely dead attempts are recovered. let outcome = engine - .resume_live(¶ms.run_id, hub.as_ref()) - .map_err(internal_error)?; + .resume_live_with_human_influence( + ¶ms.run_id, + hub.as_ref(), + params.allow_human_influenced, + ) + .map_err(|error| { + if error + .downcast_ref::() + .is_some() + { + ("human_influenced_run", error.to_string()) + } else { + internal_error(error) + } + })?; if outcome.completion_reason.is_some() { hub.finish_run(¶ms.run_id); } @@ -371,6 +384,7 @@ fn handle_request( ¶ms.run_id, ¶ms.step_id, OutOfBandCompletion { + human_intervention: params.human_intervention, attempt: params.attempt, idempotency_key: params.idempotency_key, completion_reason: params.completion_reason, diff --git a/kernel/relayflowd/src/server/tests/agent/contract.rs b/kernel/relayflowd/src/server/tests/agent/contract.rs index 8c8a9e05b..92149ee0f 100644 --- a/kernel/relayflowd/src/server/tests/agent/contract.rs +++ b/kernel/relayflowd/src/server/tests/agent/contract.rs @@ -309,3 +309,72 @@ fn a_replacement_worker_that_never_reported_the_pinned_surface_is_not_dispatched "an undispatchable attempt parks; it must not be marked as leased" ); } + +#[test] +fn human_intervention_is_durable_and_resume_requires_explicit_override() { + let directory = tempdir().unwrap(); + let data_dir = directory.path(); + let hub = Arc::new(ProtocolHub::default()); + let worker_peer = attach_llm_worker(data_dir, &hub, 91); + let mut reader = BufReader::new(worker_peer); + let (writer, _peer) = shared_writer(); + let started = request( + data_dir, + &hub, + 92, + &writer, + &json!({ + "id":"start", "verb":"run.start", "params":{"spec":{"steps":[ + {"id":"influenced","type":"llm","prompt":"hi"} + ]}} + }) + .to_string(), + ); + assert!(started.ok, "{:?}", started.error); + let run_id = started.result.unwrap()["run_id"] + .as_str() + .unwrap() + .to_owned(); + let dispatch = read_frame(&mut reader)["data"].clone(); + let completed = request( + data_dir, + &hub, + 91, + &writer, + &json!({ + "id":"complete", "verb":"step.complete", "params":{ + "run_id":run_id, "step_id":"influenced", "attempt":1, + "idempotency_key":dispatch["idempotency_key"], "completionReason":"success", + "output":"operator-influenced result", "human_intervention":true + } + }) + .to_string(), + ); + assert!(completed.ok, "{:?}", completed.error); + assert!(step_completions(data_dir, &run_id)[0].human_intervention); + let engine = Engine::new(data_dir); + let before = engine.journal_entries(&run_id, 1, usize::MAX).unwrap(); + assert!(engine.resume(&run_id, None).is_err()); + let refused = request( + data_dir, + &hub, + 92, + &writer, + &json!({ + "id":"resume", "verb":"run.resume", "params":{"run_id":run_id} + }) + .to_string(), + ); + let error = refused.error.unwrap(); + assert_eq!(error.code, "human_influenced_run"); + assert!(error.message.contains("step \"influenced\"")); + assert_eq!( + engine.journal_entries(&run_id, 1, usize::MAX).unwrap(), + before + ); + let allowed = request(data_dir, &hub, 92, &writer, &json!({ + "id":"resume", "verb":"run.resume", "params":{"run_id":run_id,"allow_human_influenced":true} + }).to_string()); + assert!(allowed.ok, "{:?}", allowed.error); + assert_eq!(allowed.result.unwrap()["status"], "completed"); +} diff --git a/kernel/relayflowd/src/server/wire.rs b/kernel/relayflowd/src/server/wire.rs index 757d2d232..a19b443a3 100644 --- a/kernel/relayflowd/src/server/wire.rs +++ b/kernel/relayflowd/src/server/wire.rs @@ -42,6 +42,14 @@ pub(super) struct RunStartParams { pub reuse_from_run_id: Option, } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct RunResumeParams { + pub run_id: String, + #[serde(default)] + pub allow_human_influenced: bool, +} + #[derive(Deserialize)] #[serde(deny_unknown_fields)] pub(super) struct RunIdParams { @@ -75,6 +83,8 @@ pub(super) struct StepHeartbeatParams { #[derive(Deserialize)] #[serde(deny_unknown_fields)] pub(super) struct StepCompleteParams { + #[serde(default)] + pub human_intervention: bool, pub run_id: String, pub step_id: String, pub attempt: u32, diff --git a/kernel/relayflowd/tests/budget_gate.rs b/kernel/relayflowd/tests/budget_gate.rs index 4f810c849..f1eaacb4b 100644 --- a/kernel/relayflowd/tests/budget_gate.rs +++ b/kernel/relayflowd/tests/budget_gate.rs @@ -42,6 +42,7 @@ fn crossing_completion_is_durable_and_next_step_is_refused() { &started.run_id, "first", OutOfBandCompletion { + human_intervention: false, attempt: d.attempt, idempotency_key: d.idempotency_key, completion_reason: CompletionReason::Success, diff --git a/kernel/relayflowd/tests/parallel_driver.rs b/kernel/relayflowd/tests/parallel_driver.rs index b5299c1b0..af9f82ba3 100644 --- a/kernel/relayflowd/tests/parallel_driver.rs +++ b/kernel/relayflowd/tests/parallel_driver.rs @@ -166,6 +166,7 @@ fn complete(engine: &Engine, run_id: &str, dispatch: &StepDispatch) -> RunStatus run_id, &dispatch.step_id, OutOfBandCompletion { + human_intervention: false, attempt: dispatch.attempt, idempotency_key: dispatch.idempotency_key.clone(), completion_reason: CompletionReason::Success, diff --git a/packages/sdk/src/cli.ts b/packages/sdk/src/cli.ts index c6437bda9..8135dfc90 100644 --- a/packages/sdk/src/cli.ts +++ b/packages/sdk/src/cli.ts @@ -50,8 +50,8 @@ type ParsedArgs = | { command: 'serve-webhook'; dataDir: string; port: number } | { command: 'cloud-run'; value: string; json: boolean; wait: boolean } | { command: 'check'; json: boolean; watch: boolean; value: string } - | { command: 'run'; bucket: string | undefined; reuseFromRunId: string | undefined; localAgent: boolean; dataDir: string; input: string | undefined; json: boolean; spawn: boolean; noObserverLink: boolean; value: string } - | { command: 'resume'; dataDir: string; json: boolean; spawn: boolean; noObserverLink: boolean; value: string } + | { command: 'run'; bucket: string | undefined; reuseFromRunId: string | undefined; localAgent: boolean; dataDir: string; input: string | undefined; json: boolean; spawn: boolean; noObserverLink: boolean; allowHumanInfluenced: boolean; value: string } + | { command: 'resume'; dataDir: string; json: boolean; spawn: boolean; noObserverLink: boolean; allowHumanInfluenced: boolean; value: string } | { command: 'observer'; dataDir: string } | { command: 'hn-monitor'; sub: 'start'; dataDir: string; specPath: string; pollIntervalMs: number | undefined } | { command: 'tick'; sub: 'start'; dataDir: string; specPath: string; scheduleId: string; @@ -71,8 +71,8 @@ const USAGE = [ 'flows run --cloud [--json] [--wait] ', 'flows run [--json] [--no-spawn] [--no-observer-link] [--data-dir ] [--local-agent] --input ', 'flows tick start --schedule-id --interval-ms [--epoch-ms ] [--max-catch-up ] [--poll-interval-ms ] [--data-dir ] ', - 'flows resume [--json] [--no-spawn] [--no-observer-link] [--data-dir ] ', - 'flows replay [--json] [--data-dir ] [--at ]', + 'flows resume [--allow-human-influenced] [--json] [--no-spawn] [--no-observer-link] [--data-dir ] ', + 'flows replay [--allow-human-influenced] [--json] [--data-dir ] [--at ]', 'flows observer [--data-dir ]', 'flows hn-monitor start [--data-dir ] [--poll-interval-ms ] ', ].join('\n'); @@ -183,6 +183,8 @@ export async function runCli( }; const lifecycle = { ...(parsed.command === 'run' ? { bucket: parsed.bucket } : {}), + allowHumanInfluenced: parsed.allowHumanInfluenced, + onPtyReady: (path: string) => io.stderr(`PTY ${path}`), ...(parsed.command === 'run' && parsed.reuseFromRunId !== undefined ? { reuseFromRunId: parsed.reuseFromRunId } : {}), localAgent: parsed.command === 'run' && parsed.localAgent, onProgress: showProgress, @@ -423,6 +425,7 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined { let cloud = false; let wait = false; let localAgent = false; + let allowHumanInfluenced = false; let dataDir = DEFAULT_DATA_DIR; let sawDataDir = false; let spawn = true; @@ -440,6 +443,11 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined { else wait = true; continue; } + if (argument === '--allow-human-influenced') { + if (command === 'check' || allowHumanInfluenced) return undefined; + allowHumanInfluenced = true; + continue; + } if (argument === '--local-agent') { if (command !== 'run' || localAgent) return undefined; localAgent = true; @@ -509,7 +517,7 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined { // local run -- an inline input, a data dir, a suppressed daemon, a local // agent, a local observer-link opt-out -- describes nothing there and is // refused rather than ignored. - if (sawInput || sawDataDir || !spawn || localAgent || noObserverLink || reuseFromRunId !== undefined) return undefined; + if (allowHumanInfluenced || sawInput || sawDataDir || !spawn || localAgent || noObserverLink || reuseFromRunId !== undefined) return undefined; return { command: 'cloud-run', value: positionals[0]!, json, wait }; } if (wait) return undefined; @@ -519,8 +527,8 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined { return command === 'check' ? { command, json, watch, value: positionals[0]! } : command === 'run' - ? { command, bucket, reuseFromRunId, localAgent, dataDir, input, json, spawn, noObserverLink, value: positionals[0]! } - : { command, dataDir, json, spawn, noObserverLink, value: positionals[0]! }; + ? { command, bucket, reuseFromRunId, localAgent, dataDir, input, json, spawn, noObserverLink, allowHumanInfluenced, value: positionals[0]! } + : { command, dataDir, json, spawn, noObserverLink, allowHumanInfluenced, value: positionals[0]! }; } function parseHnMonitorArgs(rest: readonly string[]): ParsedArgs | undefined { diff --git a/packages/sdk/src/cli/direct-run.ts b/packages/sdk/src/cli/direct-run.ts index 80fbf7199..17c041750 100644 --- a/packages/sdk/src/cli/direct-run.ts +++ b/packages/sdk/src/cli/direct-run.ts @@ -64,7 +64,7 @@ export async function runDirectFlow( try { const { handle, getDefinition } = checked.loaded; if (options.localAgent) { - localAgent = await attachLocalAgent(client); + localAgent = await attachLocalAgent(client, dataDir, options.onPtyReady); // A session owns one worker registration. Keep the workspace-free LLM // worker on its own connection so it cannot replace the agent worker. llmClient = new JournalClient(socketPath); diff --git a/packages/sdk/src/cli/replay.ts b/packages/sdk/src/cli/replay.ts index 80c9a6128..d6924de34 100644 --- a/packages/sdk/src/cli/replay.ts +++ b/packages/sdk/src/cli/replay.ts @@ -8,16 +8,21 @@ export interface ReplayArgs { json: boolean; dataDir: string; at?: string; + allowHumanInfluenced?: boolean; } export function parseReplayArgs(args: readonly string[]): ReplayArgs | undefined { let json = false; + let allowHumanInfluenced = false; let dataDir: string | undefined; let at: string | undefined; const positionals: string[] = []; for (let index = 0; index < args.length; index += 1) { const argument = args[index]!; - if (argument === '--json') { + if (argument === '--allow-human-influenced') { + if (allowHumanInfluenced) return undefined; + allowHumanInfluenced = true; + } else if (argument === '--json') { if (json) return undefined; json = true; } else if (argument === '--data-dir' || argument === '--at') { @@ -37,7 +42,7 @@ export function parseReplayArgs(args: readonly string[]): ReplayArgs | undefined } } if (positionals.length !== 1) return undefined; - return { command: 'replay', value: positionals[0]!, json, dataDir: dataDir ?? '.relayflowd', at }; + return { allowHumanInfluenced, command: 'replay', value: positionals[0]!, json, dataDir: dataDir ?? '.relayflowd', at }; } export async function replayJournal(args: ReplayArgs, io: CliIo): Promise<0 | 1 | 2> { @@ -46,6 +51,10 @@ export async function replayJournal(args: ReplayArgs, io: CliIo): Promise<0 | 1 for await (const event of walkJournal(args.value, args.dataDir, { at: args.at })) { const payload = event.payload !== null && typeof event.payload === 'object' && !Array.isArray(event.payload) ? event.payload as Record : {}; + if (event.entry_type === 'step.completed' && payload['human_intervention'] === true && !args.allowHumanInfluenced) { + io.stderr(`REFUSED [human_influenced_run] step ${JSON.stringify(event.step_id)}; pass --allow-human-influenced to continue`); + return 2; + } io.stdout(args.json ? canonicalize({ step_id: event.step_id, kind: event.entry_type, diff --git a/packages/sdk/src/cli/run.ts b/packages/sdk/src/cli/run.ts index 86a29d78d..c68bbd227 100644 --- a/packages/sdk/src/cli/run.ts +++ b/packages/sdk/src/cli/run.ts @@ -68,6 +68,8 @@ export interface RunProgress { export interface RunLifecycleOptions { bucket?: string; + allowHumanInfluenced?: boolean; + onPtyReady?: (path: string) => void; reuseFromRunId?: string; onProgress?: (event: ProgressEvent) => void; localAgent?: boolean; @@ -114,7 +116,7 @@ async function executeCheckedFlow( const spec = toKernelSpec(checked.flow!); // Use the checked CLI/model and declared surfaces unchanged. The worker // advertises its existing pins; the daemon still owns surface matching. - if (options.localAgent) localAgent = await attachLocalAgent(client); + if (options.localAgent) localAgent = await attachLocalAgent(client, dataDir, options.onPtyReady); const outcome = await client.runStart(spec, options.reuseFromRunId); const execution = await classifyOutcome(client, 'run', outcome, base, socketPath, options); if (options.reuseFromRunId !== undefined) { @@ -149,10 +151,16 @@ export async function resumeFlow( if (connected !== undefined) return connected; try { - await resumeSlackEffect(client, runId, dataDir); - const outcome = await client.runResume(runId); + let outcome = await client.runResume(runId, options.allowHumanInfluenced); + if (await resumeSlackEffect(client, runId, dataDir)) { + outcome = await client.runResume(runId, options.allowHumanInfluenced); + } return await classifyOutcome(client, 'resume', outcome, base, socketPath, options); } catch (error) { + if (error instanceof JournalProtocolError && error.code === 'human_influenced_run') { + return { exitCode: 2, report: { ...base, runId, socketPath, + diagnostics: [{ severity: 'refusal', kind: 'human_influenced_run', message: error.message.replace(/^human_influenced_run: /, '') }] } }; + } if (error instanceof AuthoredFlowExecutionError && (error.code === 'helper_slack.credential_missing' || error.code === 'helper_slack.mount_required')) { return { exitCode: 2, report: { ...base, runId, socketPath, @@ -280,11 +288,11 @@ export async function classifyOutcome( } if (inspection?.runningStep !== undefined) { await waitForRunningStep(client, current.run_id, inspection.runningStep, options); - current = await client.runResume(current.run_id); + current = await client.runResume(current.run_id, command === 'run' || options.allowHumanInfluenced); continue; } if (inspection?.status === 'completed' || inspection?.status === 'failed') { - current = await client.runResume(current.run_id); + current = await client.runResume(current.run_id, command === 'run' || options.allowHumanInfluenced); continue; } // The run is still RUNNING but no step is identifiable at this instant. @@ -312,7 +320,7 @@ export async function classifyOutcome( // is already progressing -- it returns the current state rather than // re-dispatching. This loop leans on that up to MAX_UNCLASSIFIED_POLLS // times while the daemon is mid-transition. - current = await client.runResume(current.run_id); + current = await client.runResume(current.run_id, command === 'run' || options.allowHumanInfluenced); continue; } // Fail closed rather than loop forever: if it never resolves, the original diff --git a/packages/sdk/src/failure-kinds.ts b/packages/sdk/src/failure-kinds.ts index 7fbf59584..e4b2d574b 100644 --- a/packages/sdk/src/failure-kinds.ts +++ b/packages/sdk/src/failure-kinds.ts @@ -74,6 +74,7 @@ export const RUN_FAILURE_KINDS = [ 'bucket_unreachable', 'bundle_signature_invalid', 'bundle_unsupported', + 'human_influenced_run', 'reuse_spec_mismatch', 'reuse_run_not_found', 'reuse_journal_read_failed', diff --git a/packages/sdk/src/journal-client.ts b/packages/sdk/src/journal-client.ts index 5240208de..10659d41a 100644 --- a/packages/sdk/src/journal-client.ts +++ b/packages/sdk/src/journal-client.ts @@ -209,8 +209,8 @@ export class JournalClient extends EventEmitter { } /** §3 memoized resume. */ - runResume(runId: string): Promise { - return this.request('run.resume', { run_id: runId }, null); + runResume(runId: string, allowHumanInfluenced = false): Promise { + return this.request('run.resume', { run_id: runId, ...(allowHumanInfluenced ? { allow_human_influenced: true } : {}) }, null); } /** Durably request cancellation and return the terminal run fact. */ @@ -367,6 +367,7 @@ export class JournalClient extends EventEmitter { end_pins?: Pins; effects?: EffectRef[]; trajectory_tail?: unknown; + human_intervention?: boolean; } = {}, ): Promise { return this.request('step.complete', { diff --git a/packages/sdk/src/local-agent.ts b/packages/sdk/src/local-agent.ts index fb68ef2ef..4fe5ad5e3 100644 --- a/packages/sdk/src/local-agent.ts +++ b/packages/sdk/src/local-agent.ts @@ -3,7 +3,7 @@ import type { JournalClient } from './journal-client.js'; import { AgentWorker } from './worker.js'; /** A local worker for stream-only steps; no workspace recovery is claimed. */ -export async function attachLocalAgent(client: JournalClient): Promise<{ +export async function attachLocalAgent(client: JournalClient, dataDir?: string, onPtyReady?: (path: string) => void): Promise<{ stream: string; readonly failure: unknown; close(): Promise; @@ -14,6 +14,7 @@ export async function attachLocalAgent(client: JournalClient): Promise<{ const worker = new AgentWorker(client, { workerId: stream, capacity: 1, + dataDir, onPtyReady, pins: { workspace: [], streams: [{ stream, read_offset: 0 }] }, }); let failure: unknown; diff --git a/packages/sdk/src/protocol.ts b/packages/sdk/src/protocol.ts index 0679b5eb6..447acee70 100644 --- a/packages/sdk/src/protocol.ts +++ b/packages/sdk/src/protocol.ts @@ -98,6 +98,7 @@ export interface RunOutcome { export type RunStartResult = RunOutcome; export interface RunResumeParams { + allow_human_influenced?: boolean; run_id: string; } export type RunResumeResult = RunOutcome; @@ -297,6 +298,7 @@ export interface EffectConfirmResult { } export interface StepCompleteParams { + human_intervention?: boolean; run_id: string; step_id: string; attempt: number; diff --git a/packages/sdk/src/pty-sidechannel.ts b/packages/sdk/src/pty-sidechannel.ts new file mode 100644 index 000000000..eaf83497c --- /dev/null +++ b/packages/sdk/src/pty-sidechannel.ts @@ -0,0 +1,81 @@ +import { createServer, type Socket } from 'node:net'; +import { chmod, mkdir } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; + +export interface SidechannelContext { + dataDir: string; + runId: string; + stepId: string; + onReady?: (path: string) => void; + onDrive: () => void; +} + +export function ptySocketPath(context: Pick): string { + for (const id of [context.runId, context.stepId]) { + if (!id || id === '.' || id === '..' || /[/\\\0]/.test(id)) throw new Error('Invalid sidechannel path component'); + } + return join(resolve(context.dataDir), 'runs', context.runId, 'steps', context.stepId, 'pty.sock'); +} + +/** Best-effort live bytes. Slow peers are dropped; they never pause execution. */ +export async function openSidechannel(context: SidechannelContext, input: (bytes: Buffer) => boolean) { + const peers = new Map(); + let closed = false; + const server = createServer(socket => { + if (peers.size >= 16) { socket.destroy(); return; } + peers.set(socket, false); + let hello = Buffer.alloc(0); + let mode: string | undefined; + socket.setTimeout(2_000, () => socket.destroy()); + socket.on('error', () => socket.destroy()); + socket.on('close', () => peers.delete(socket)); + socket.on('data', (bytes: Buffer) => { + if (mode === undefined) { + hello = Buffer.concat([hello, bytes]); + const end = hello.indexOf(10); + if (end < 0) { if (hello.length > 32) socket.destroy(); return; } + const line = hello.subarray(0, end).toString('utf8'); + if (!['HELLO view', 'HELLO drive', 'HELLO passthrough'].includes(line)) { socket.destroy(); return; } + mode = line.slice(6); + socket.setTimeout(0); + peers.set(socket, true); + // Passthrough is a passive raw-byte view in this initial slice. + if (mode === 'drive') context.onDrive(); + bytes = hello.subarray(end + 1); + hello = Buffer.alloc(0); + } + if (mode === 'drive' && bytes.length > 0 && !input(bytes)) socket.destroy(); + }); + }); + server.on('error', () => { for (const peer of peers.keys()) peer.destroy(); }); + try { + const path = ptySocketPath(context); + // Restrict traversal as well as socket access to the worker's OS user. + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + await chmod(dirname(path), 0o700); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(path, () => { server.off('error', reject); resolve(); }); + }); + await chmod(path, 0o600); + context.onReady?.(path); + } catch { + // Never unlink a pre-existing socket: it may belong to a live attempt. + if (server.listening) server.close(); + return undefined; + } + return { + publish(bytes: Buffer) { + if (closed) return; + for (const [peer, ready] of peers) { + if (ready && !peer.write(bytes)) peer.destroy(); + } + }, + close() { + if (closed) return; + closed = true; + for (const peer of peers.keys()) peer.destroy(); + server.close(); + }, + }; +} diff --git a/packages/sdk/src/worker-cli.ts b/packages/sdk/src/worker-cli.ts index f5d7ffb8d..232317794 100644 --- a/packages/sdk/src/worker-cli.ts +++ b/packages/sdk/src/worker-cli.ts @@ -1,4 +1,5 @@ import { decodeProviderResult, decodeWrapperResult, requirePricedUsage } from './worker-usage.js'; +import { openSidechannel, type SidechannelContext } from './pty-sidechannel.js'; import { spawn } from 'node:child_process'; import { childStop, ownsProcessGroup } from './child-stop.js'; import { @@ -39,6 +40,7 @@ export async function runAgentCli( wrapperLimits?: Partial, signal?: AbortSignal, mode: 'agent' | 'llm' = 'agent', + sidechannel?: SidechannelContext, ): Promise { signal?.throwIfAborted(); if (signal !== undefined && process.platform === 'win32') { @@ -79,21 +81,28 @@ export async function runAgentCli( // Structured provider output carries the authoritative token counts. const args = [...invocation.args]; args.splice(args.length - 1, 0, ...(kind === 'claude' ? ['--output-format', 'json'] : ['--json'])); - return requirePricedUsage(decodeProviderResult(await spawnInvocation(cli, { ...invocation, args }, env, signal), kind), model); + return requirePricedUsage(decodeProviderResult(await spawnInvocation(cli, { ...invocation, args }, env, signal, sidechannel), kind), model); } -function spawnInvocation( +async function spawnInvocation( cli: string, invocation: CliInvocation, env: NodeJS.ProcessEnv, signal?: AbortSignal, + sidechannel?: SidechannelContext, ): Promise { + let writeInput: (bytes: Buffer) => boolean = () => false; + const channel = sidechannel === undefined ? undefined : await openSidechannel(sidechannel, bytes => writeInput(bytes)); + if (signal?.aborted) { channel?.close(); signal.throwIfAborted(); } return new Promise((resolve) => { const ownsGroup = ownsProcessGroup(signal); const child = spawn(cli, invocation.args, { - stdio: ['ignore', 'pipe', 'pipe'], env, + stdio: ['pipe', 'pipe', 'pipe'], env, detached: ownsGroup, }); + child.stdin.on('error', () => {}); + if (channel === undefined) child.stdin.end(); + writeInput = bytes => !child.stdin.destroyed && child.stdin.write(bytes); const stop = childStop(child, ownsGroup); const stdout: Buffer[] = []; const stderr: Buffer[] = []; @@ -102,6 +111,7 @@ function spawnInvocation( const finish = (result: WorkerCliResult): void => { if (settled) return; settled = true; + channel?.close(); if (timer !== undefined) clearTimeout(timer); signal?.removeEventListener('abort', onAbort); resolve(result); @@ -124,8 +134,8 @@ function spawnInvocation( }; signal?.addEventListener('abort', onAbort, { once: true }); if (signal?.aborted) onAbort(); - child.stdout.on('data', (chunk: Buffer) => stdout.push(chunk)); - child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk)); + child.stdout.on('data', (chunk: Buffer) => { stdout.push(chunk); channel?.publish(chunk); }); + child.stderr.on('data', (chunk: Buffer) => { stderr.push(chunk); channel?.publish(chunk); }); child.once('error', (error) => finishOnChildExit({ exit_code: null, stdout_tail: Buffer.concat(stdout).toString('utf8'), diff --git a/packages/sdk/src/worker.ts b/packages/sdk/src/worker.ts index 1cbb13e3c..a931d5d65 100644 --- a/packages/sdk/src/worker.ts +++ b/packages/sdk/src/worker.ts @@ -14,6 +14,8 @@ export interface AgentWorkerOptions { workerId: string; pins: Pins; capacity?: number; + dataDir?: string; + onPtyReady?: (path: string) => void; } /** @@ -94,9 +96,13 @@ export class AgentWorker extends EventEmitter { private async execute(dispatch: StepDispatchEvent): Promise { const spec = dispatch.spec as Partial; + let humanIntervention = false; const completed: WorkerCliResult = await withWorkerLease(this.client, dispatch, signal => typeof spec.cli === 'string' && typeof spec.instruction === 'string' - ? runAgentCli(spec.cli, workerInstruction(spec.instruction, dispatch), dispatch.wake_context, spec.model, undefined, signal) + ? runAgentCli(spec.cli, workerInstruction(spec.instruction, dispatch), dispatch.wake_context, spec.model, undefined, signal, 'agent', this.options.dataDir === undefined ? undefined : { + dataDir: this.options.dataDir, runId: dispatch.run_id, stepId: dispatch.step_id, + onReady: this.options.onPtyReady, onDrive: () => { humanIntervention = true; }, + }) : Promise.resolve({ exit_code: null, stdout_tail: '', stderr_tail: 'agent step has no declared CLI' })); const { result, usage } = workerSpend(completed, spec.model); const completionReason = result.exit_code === 0 ? 'success' : 'worker_error'; @@ -124,6 +130,7 @@ export class AgentWorker extends EventEmitter { completionReason, { output, + ...(humanIntervention ? { human_intervention: true } : {}), ...(usage !== undefined ? { usage } : {}), started_pins: dispatch.pins, end_pins: dispatch.pins, diff --git a/packages/sdk/tests/cli-replay.test.ts b/packages/sdk/tests/cli-replay.test.ts index d74ebbb13..58aae3753 100644 --- a/packages/sdk/tests/cli-replay.test.ts +++ b/packages/sdk/tests/cli-replay.test.ts @@ -294,6 +294,16 @@ describe('flows replay', () => { expect(output.stderr[0]).toContain('REFUSED [journal_read_failed]'); }); + it('refuses human-influenced replay unless explicitly allowed', async () => { + const { dataDir, writer } = fixture(); + writer.exec("UPDATE entries SET payload = json_set(payload, '$.human_intervention', json('true')) WHERE entry_type = 'step.completed'"); + writer.close(); + const refused = await replay(dataDir); + expect(refused.code).toBe(2); + expect(refused.stderr.join('\n')).toContain('REFUSED [human_influenced_run] step'); + expect((await replay(dataDir, ['--allow-human-influenced'])).code).toBe(0); + }); + it.each([ [], [RUN_ID, '--at'], [RUN_ID, '--data-dir'], [RUN_ID, '--no-spawn'], [RUN_ID, '--json', '--json'], [RUN_ID, '--at', 'x', '--at', 'y'], diff --git a/packages/sdk/tests/cli.test.ts b/packages/sdk/tests/cli.test.ts index 87389b040..ae8c4dfaf 100644 --- a/packages/sdk/tests/cli.test.ts +++ b/packages/sdk/tests/cli.test.ts @@ -959,6 +959,24 @@ describe('flows run/resume CLI over the journal protocol', () => { expect(output.stderr.join('\n')).not.toContain('journal.read'); }); + it('refuses human-influenced resume with exit 2 and forwards the explicit override', async () => { + const dataDir = temporaryProject('flows-human-'); + await startCliLoopback(dataDir, { + hello: sendOk, + 'run.resume': (ctx, params) => { + if (params['allow_human_influenced'] === true) { + sendResult(ctx, { run_id: 'human-run', status: 'completed', completion_reason: 'success', completed_steps: 1 }); + } else { + ctx.send({ id: ctx.id, ok: false, error: { code: 'human_influenced_run', message: 'step "agent"' } }); + } + }, + }); + const refused = capture(); + expect(await runCli(['resume', '--no-observer-link', '--data-dir', dataDir, 'human-run'], refused.io)).toBe(2); + expect(refused.stderr.join('\n')).toContain('REFUSED [human_influenced_run] step "agent"'); + expect(await runCli(['resume', '--allow-human-influenced', '--no-observer-link', '--data-dir', dataDir, 'human-run'], capture().io)).toBe(0); + }); + it('maps only run_not_found resumes to exit 2', async () => { const dataDir = temporaryProject('flows-resume-'); await startCliLoopback(dataDir, { diff --git a/packages/sdk/tests/pty-sidechannel.test.ts b/packages/sdk/tests/pty-sidechannel.test.ts new file mode 100644 index 000000000..fd3a70f19 --- /dev/null +++ b/packages/sdk/tests/pty-sidechannel.test.ts @@ -0,0 +1,72 @@ +import { EventEmitter } from 'node:events'; +import { mkdtempSync, writeFileSync, rmSync, existsSync } from 'node:fs'; +import { connect, type Socket } from 'node:net'; +import { join } from 'node:path'; +import { afterEach, expect, it } from 'vitest'; +import { AgentWorker } from '../src/worker.js'; +import { openSidechannel } from '../src/pty-sidechannel.js'; +import type { JournalClient } from '../src/journal-client.js'; + +const dirs: string[] = []; +afterEach(() => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); }); +function dir() { const path = mkdtempSync('/tmp/q-'); dirs.push(path); return path; } + +it.each(['view', 'drive', 'passthrough', 'none'])('%s attach preserves worker completion and marks only drive', async mode => { + const dataDir = dir(); + const cli = join(dataDir, 'claude'); + writeFileSync(cli, `#!/usr/bin/env node +process.stdin.on('data', b => { process.stdout.write('injected:' + b); process.exit(0); }); +setTimeout(() => process.stdout.write('visible\\n'), 150); +setTimeout(() => process.exit(0), 450); +`, { mode: 0o755 }); + const client = new EventEmitter() as EventEmitter & { workerAttach: () => Promise; stepComplete: (...args: unknown[]) => Promise; stepHeartbeat: () => Promise<{ lease_deadline_ms: number }> }; + client.workerAttach = async () => {}; + client.stepHeartbeat = async () => ({ lease_deadline_ms: Date.now() + 60_000 }); + let finish!: (args: unknown[]) => void; + const done = new Promise(resolve => { finish = resolve; }); + client.stepComplete = async (...args) => { finish(args); }; + let socket: Socket | undefined; + let received = ''; + let socketPath = ''; + const worker = new AgentWorker(client as unknown as JournalClient, { + workerId: 'test', pins: { workspace: [], streams: [] }, dataDir, + onPtyReady(path) { + socketPath = path; + if (mode === 'none') return; + socket = connect(path, () => { + socket!.write('HEL'); + socket!.write(`LO ${mode}\n${mode === 'drive' ? 'operator\n' : 'ignored\n'}`); + }); + socket.on('data', bytes => { received += bytes.toString(); }); + }, + }); + worker.on('error', error => { throw error; }); + await worker.attach(); + client.emit('step.dispatch', { + run_id: 'r', step_id: 's', step_type: 'agent', attempt: 1, + idempotency_key: 'k', lease_id: 'lease', lease_deadline_ms: Date.now() + 60_000, + pins: { workspace: [], streams: [] }, spec: { cli, instruction: 'test' }, + }); + try { + const args = await done; + expect(args[4]).toBe('success'); + const completion = args[5] as { human_intervention?: boolean; output: { stdout_tail: string } }; + expect(completion.human_intervention).toBe(mode === 'drive' ? true : undefined); + expect(completion.output.stdout_tail).toContain(mode === 'drive' ? 'injected:operator' : 'visible'); + if (mode !== 'none' && mode !== 'drive') expect(received).toContain('visible'); + expect(existsSync(socketPath)).toBe(false); + } finally { socket?.destroy(); await worker.close(); } +}); + +it('broken handshake and occupied socket do not affect the sidechannel owner', async () => { + const context = { dataDir: dir(), runId: 'r', stepId: 's', onDrive: () => { throw new Error('must not drive'); } }; + let path = ''; + const first = await openSidechannel({ ...context, onReady: value => { path = value; } }, () => true); + expect(first).toBeDefined(); + try { + expect(await openSidechannel(context, () => true)).toBeUndefined(); + const peer = connect(path); + await new Promise(resolve => { peer.once('close', () => resolve()); peer.write('garbage\n'); }); + expect(existsSync(path)).toBe(true); + } finally { first?.close(); } +}); From 43e3747c836abe5f1940bfb1850e0c0149a0b5ff Mon Sep 17 00:00:00 2001 From: Miya Date: Fri, 11 Sep 2026 21:11:38 +0200 Subject: [PATCH 2/2] fix(sdk): bound unattended stdin and preserve drive backpressure Session-Id: 01a091de-4529-7e13-9a6e-dee6d30020dc Session-Id: efeda5df-9b7c-48d4-b2ce-957f5bef0a82 --- docs/SURFACE.md | 6 ++ docs/evidence/spec-Q/README.md | 18 ++++ docs/evidence/spec-Q/bugbot-fixes.txt | 36 ++++++++ packages/sdk/src/pty-sidechannel.ts | 15 +++- packages/sdk/src/worker-cli.ts | 23 +++++- packages/sdk/tests/pty-sidechannel.test.ts | 96 ++++++++++++++++++++++ 6 files changed, 189 insertions(+), 5 deletions(-) create mode 100644 docs/evidence/spec-Q/bugbot-fixes.txt diff --git a/docs/SURFACE.md b/docs/SURFACE.md index 655a8cbe5..4a86b7384 100644 --- a/docs/SURFACE.md +++ b/docs/SURFACE.md @@ -572,6 +572,12 @@ kernel dispatch, including for authored `f.agent` calls. A subscriber sends `HELLO view\n`, `HELLO drive\n`, or `HELLO passthrough\n`, then receives live stdout/stderr bytes. View and passthrough are passive. Only drive forwards subsequent bytes to child stdin. +In this pipe-based slice, a drive greeting must arrive within 100ms of child +startup. Without one, the worker closes stdin so unattended and passive-view +agents receive EOF. Later drive greetings are rejected without marking human +intervention; a closed stdin pipe cannot be reopened. Supporting drive attachment +at arbitrary times requires a future terminal/session transport. Drive readers +pause while child stdin writes flush, preserving input under backpressure. There is no backlog, terminal resize, or framing after the greeting. Socket access is restricted to the worker's OS user. Slow, malformed, excess, and broken subscribers are disconnected independently; absent subscribers or an diff --git a/docs/evidence/spec-Q/README.md b/docs/evidence/spec-Q/README.md index 5cd83af12..1df21400f 100644 --- a/docs/evidence/spec-Q/README.md +++ b/docs/evidence/spec-Q/README.md @@ -1,5 +1,23 @@ # Slice Q verification +## PR #338 Bugbot follow-up + +Rebased onto `origin/main` at `494f2a11`. Unattended, passive, and incomplete +subscribers now allow stdin EOF after a 100ms startup drive-attachment window. +Late drive greetings are rejected without setting the intervention marker. +Drive input pauses its socket until each child write completes, including +writes that exceed the pipe's buffer capacity. Arbitrary-time drive attachment +remains a terminal/session transport follow-up, documented in SURFACE.md. + +After SDK `npm ci --ignore-scripts` and repository-root +`npm install ./packages/surface --prefix packages/sdk --no-save --ignore-scripts`, +ran from `packages/sdk`: +`npm run typecheck && ./node_modules/.bin/vitest run tests/worker-cli.test.ts tests/pty-sidechannel.test.ts`. +Exit 0; literal command and output: [bugbot-fixes.txt](bugbot-fixes.txt). +This is focused SDK regression evidence, not a new live-provider acceptance run. + +## Original slice evidence + This is a minimal byte-stream proof for #334, with completion markers and resume/replay protection. Actual PTY allocation, wrapper-session attachment, crash-safe intervention recording before completion, and the companion diff --git a/docs/evidence/spec-Q/bugbot-fixes.txt b/docs/evidence/spec-Q/bugbot-fixes.txt new file mode 100644 index 000000000..22422b857 --- /dev/null +++ b/docs/evidence/spec-Q/bugbot-fixes.txt @@ -0,0 +1,36 @@ +$ cd packages/sdk +$ npm run typecheck && ./node_modules/.bin/vitest run tests/worker-cli.test.ts tests/pty-sidechannel.test.ts + +> @relayflows/sdk@2.0.8 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + + + RUN v2.1.9 /Users/khaliqgant/flows-spec-Q-sidechannel/packages/sdk + + ✓ tests/pty-sidechannel.test.ts (11 tests) 5016ms + ✓ view attach preserves worker completion and marks only drive 1048ms + ✓ passthrough attach preserves worker completion and marks only drive 800ms + ✓ none attach preserves worker completion and marks only drive 707ms + ✓ none subscriber lets an unattended CLI read EOF 311ms + ✓ rejects drive after EOF without marking human intervention 678ms + ✓ delivers all drive bytes in order across child stdin backpressure 661ms + ✓ tests/worker-cli.test.ts (13 tests) 24222ms + ✓ custom wrapper execution identity > passes an explicit safe environment at identification and execution 381ms + ✓ custom wrapper execution identity > refuses a wrapper symlink retarget before delivering private values 520ms + ✓ custom wrapper execution identity > bounds wrapper execution after acknowledgement 360ms + ✓ custom wrapper execution identity > refuses a duplicate execute protocol frame 400ms + ✓ custom wrapper execution bounds are reader-owned > resolves when a conforming wrapper leaks a stdio pipe to a background helper 1864ms + ✓ custom wrapper execution bounds are reader-owned > resolves when the leaked helper inherits stderr only 2009ms + ✓ custom wrapper execution bounds are reader-owned > resolves when a wrapper leaks a stdio pipe and exits before identifying 3280ms + ✓ custom wrapper execution bounds are reader-owned > journals a completionReason at the default bound when a wrapper leaks a stdio pipe 11264ms + ✓ custom wrapper execution bounds are reader-owned > accepts an execute token and an over-8KiB payload flushed in one write 1289ms + ✓ custom wrapper execution bounds are reader-owned > accepts the same over-8KiB payload whether or not it coalesces with the execute token 896ms + ✓ custom wrapper execution bounds are reader-owned > still bounds an un-terminated handshake buffer and names the bound 377ms + ✓ delivers the journaled memory pack to the real wrapper and excludes its charge from completion usage 1297ms + + Test Files 2 passed (2) + Tests 24 passed (24) + Start at 21:10:32 + Duration 24.48s (transform 50ms, setup 0ms, collect 81ms, tests 29.24s, environment 0ms, prepare 75ms) + +Exit status: 0 diff --git a/packages/sdk/src/pty-sidechannel.ts b/packages/sdk/src/pty-sidechannel.ts index eaf83497c..c0a0210b7 100644 --- a/packages/sdk/src/pty-sidechannel.ts +++ b/packages/sdk/src/pty-sidechannel.ts @@ -18,7 +18,11 @@ export function ptySocketPath(context: Pick boolean) { +export async function openSidechannel( + context: SidechannelContext, + input: (bytes: Buffer) => boolean | Promise, + canDrive: () => boolean = () => true, +) { const peers = new Map(); let closed = false; const server = createServer(socket => { @@ -37,6 +41,7 @@ export async function openSidechannel(context: SidechannelContext, input: (bytes const line = hello.subarray(0, end).toString('utf8'); if (!['HELLO view', 'HELLO drive', 'HELLO passthrough'].includes(line)) { socket.destroy(); return; } mode = line.slice(6); + if (mode === 'drive' && !canDrive()) { socket.destroy(); return; } socket.setTimeout(0); peers.set(socket, true); // Passthrough is a passive raw-byte view in this initial slice. @@ -44,7 +49,13 @@ export async function openSidechannel(context: SidechannelContext, input: (bytes bytes = hello.subarray(end + 1); hello = Buffer.alloc(0); } - if (mode === 'drive' && bytes.length > 0 && !input(bytes)) socket.destroy(); + if (mode === 'drive' && bytes.length > 0) { + socket.pause(); + void Promise.resolve().then(() => input(bytes)).then(accepted => { + if (!accepted) socket.destroy(); + else if (!socket.destroyed) socket.resume(); + }, () => socket.destroy()); + } }); }); server.on('error', () => { for (const peer of peers.keys()) peer.destroy(); }); diff --git a/packages/sdk/src/worker-cli.ts b/packages/sdk/src/worker-cli.ts index 232317794..d9481b541 100644 --- a/packages/sdk/src/worker-cli.ts +++ b/packages/sdk/src/worker-cli.ts @@ -91,8 +91,13 @@ async function spawnInvocation( signal?: AbortSignal, sidechannel?: SidechannelContext, ): Promise { - let writeInput: (bytes: Buffer) => boolean = () => false; - const channel = sidechannel === undefined ? undefined : await openSidechannel(sidechannel, bytes => writeInput(bytes)); + let writeInput: (bytes: Buffer) => Promise = async () => false; + let canDrive = () => false; + let driven = false; + const channel = sidechannel === undefined ? undefined : await openSidechannel({ + ...sidechannel, + onDrive() { driven = true; sidechannel.onDrive(); }, + }, bytes => writeInput(bytes), () => canDrive()); if (signal?.aborted) { channel?.close(); signal.throwIfAborted(); } return new Promise((resolve) => { const ownsGroup = ownsProcessGroup(signal); @@ -102,7 +107,18 @@ async function spawnInvocation( }); child.stdin.on('error', () => {}); if (channel === undefined) child.stdin.end(); - writeInput = bytes => !child.stdin.destroyed && child.stdin.write(bytes); + canDrive = () => !child.stdin.destroyed && !child.stdin.writableEnded; + // A pipe cannot be reopened after EOF. Give startup subscribers a bounded + // chance to opt into drive, then let unattended/view-only CLIs read EOF. + const inputTimer = channel === undefined ? undefined : setTimeout(() => { + if (!driven) child.stdin.end(); + }, 100); + writeInput = bytes => new Promise(resolve => { + if (!canDrive()) { resolve(false); return; } + // write(false) still accepts the bytes. The completion callback waits + // until they flush; the sidechannel pauses its reader in the meantime. + child.stdin.write(bytes, error => resolve(!error)); + }); const stop = childStop(child, ownsGroup); const stdout: Buffer[] = []; const stderr: Buffer[] = []; @@ -111,6 +127,7 @@ async function spawnInvocation( const finish = (result: WorkerCliResult): void => { if (settled) return; settled = true; + if (inputTimer !== undefined) clearTimeout(inputTimer); channel?.close(); if (timer !== undefined) clearTimeout(timer); signal?.removeEventListener('abort', onAbort); diff --git a/packages/sdk/tests/pty-sidechannel.test.ts b/packages/sdk/tests/pty-sidechannel.test.ts index fd3a70f19..3091577b0 100644 --- a/packages/sdk/tests/pty-sidechannel.test.ts +++ b/packages/sdk/tests/pty-sidechannel.test.ts @@ -1,10 +1,12 @@ import { EventEmitter } from 'node:events'; +import { createHash } from 'node:crypto'; import { mkdtempSync, writeFileSync, rmSync, existsSync } from 'node:fs'; import { connect, type Socket } from 'node:net'; import { join } from 'node:path'; import { afterEach, expect, it } from 'vitest'; import { AgentWorker } from '../src/worker.js'; import { openSidechannel } from '../src/pty-sidechannel.js'; +import { runAgentCli } from '../src/worker-cli.js'; import type { JournalClient } from '../src/journal-client.js'; const dirs: string[] = []; @@ -70,3 +72,97 @@ it('broken handshake and occupied socket do not affect the sidechannel owner', a expect(existsSync(path)).toBe(true); } finally { first?.close(); } }); + +it.each(['none', 'view', 'passthrough', 'incomplete'])('%s subscriber lets an unattended CLI read EOF', async mode => { + const dataDir = dir(); + const cli = join(dataDir, 'claude'); + writeFileSync(cli, `#!/usr/bin/env node +const watchdog = setTimeout(() => process.exit(91), 2000); +process.stdin.resume(); +process.stdin.on('end', () => { clearTimeout(watchdog); process.stdout.write('eof'); }); +`, { mode: 0o755 }); + let peer: Socket | undefined; + let driven = false; + try { + const result = await runAgentCli(cli, 'test', undefined, undefined, undefined, undefined, 'agent', { + dataDir, runId: 'r', stepId: 's', onDrive: () => { driven = true; }, + onReady(path) { + if (mode === 'none') return; + peer = connect(path, () => peer!.write(mode === 'incomplete' ? 'HELLO dri' : `HELLO ${mode}\n`)); + }, + }); + expect(result.exit_code).toBe(0); + expect(result.stdout_tail).toBe('eof'); + expect(driven).toBe(false); + } finally { peer?.destroy(); } +}); + +it('rejects drive after EOF without marking human intervention', async () => { + const dataDir = dir(); + const cli = join(dataDir, 'claude'); + writeFileSync(cli, `#!/usr/bin/env node +process.stdin.resume(); +process.stdin.on('end', () => { + process.stdout.write('eof'); + setTimeout(() => process.exit(0), 300); +}); +`, { mode: 0o755 }); + let peer: Socket | undefined; + let driven = false; + let attempted = false; + let rejectedBeforeExit = false; + try { + const result = await runAgentCli(cli, 'test', undefined, undefined, undefined, undefined, 'agent', { + dataDir, runId: 'r', stepId: 's', onDrive: () => { driven = true; }, + onReady(path) { + peer = connect(path, () => peer!.write('HELLO view\n')); + peer.once('data', () => { + attempted = true; + const late = connect(path, () => late.write('HELLO drive\nignored')); + late.once('close', () => { rejectedBeforeExit = true; }); + }); + }, + }); + expect(result.exit_code).toBe(0); + expect(attempted).toBe(true); + expect(rejectedBeforeExit).toBe(true); + expect(driven).toBe(false); + } finally { peer?.destroy(); } +}); + +it('delivers all drive bytes in order across child stdin backpressure', async () => { + const dataDir = dir(); + const cli = join(dataDir, 'claude'); + // Several pipe buffers, with distinct content to catch loss or retries. + const payload = Buffer.concat(Array.from({ length: 64 }, (_, i) => Buffer.alloc(64 * 1024, i))); + const digest = createHash('sha256').update(payload).digest('hex'); + writeFileSync(cli, `#!/usr/bin/env node +const hash = require('node:crypto').createHash('sha256'); +let count = 0; +setTimeout(() => { + process.stdin.on('data', bytes => { + hash.update(bytes); + count += bytes.length; + if (count >= ${payload.length}) { + process.stdout.write(count + ':' + hash.digest('hex')); + process.exit(0); + } + }); +}, 250); +`, { mode: 0o755 }); + let peer: Socket | undefined; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 4000); + try { + const result = await runAgentCli(cli, 'test', undefined, undefined, undefined, controller.signal, 'agent', { + dataDir, runId: 'r', stepId: 's', onDrive() {}, + onReady(path) { + peer = connect(path, () => { peer!.write('HELLO drive\n'); peer!.write(payload); }); + peer.on('error', () => {}); + peer.resume(); + }, + }); + expect(result.exit_code).toBe(0); + expect(result.stdout_tail).toBe(`${payload.length}:${digest}`); + } finally { clearTimeout(timeout); peer?.destroy(); } +});