diff --git a/crates/spt-daemon/src/brain.rs b/crates/spt-daemon/src/brain.rs index 26727cc..710181a 100644 --- a/crates/spt-daemon/src/brain.rs +++ b/crates/spt-daemon/src/brain.rs @@ -1182,11 +1182,13 @@ impl Brain { /// ID (REQ-SEND-SPT-HOSTED) — the endpoint-keyed inject a CLI `spt send` uses /// to reach an spt-hosted endpoint (broker holds its PTY, no relay). The /// broker resolves endpoint→session atomically and writes the bytes (a - /// pre-rendered `` envelope). Returns `true` when a hosted session was - /// found and written, `false` when none hosts the endpoint (the caller then - /// falls back to the spool). + /// pre-rendered `` envelope). Returns `(delivered, spool_deferred)`: + /// `delivered=true` = broker injected via translation binary; `spool_deferred=true` + /// = caller should spool deferred (active window), `false` = spool non-deferred + /// (idle window). When `delivered=true` the spool_deferred hint is false (no spool). // [impl->REQ-SEND-SPT-HOSTED] - pub fn inject_endpoint(&mut self, endpoint: &str, bytes: &[u8]) -> io::Result { + // [impl->REQ-MSG-DELIVERY-AXES] + pub fn inject_endpoint(&mut self, endpoint: &str, bytes: &[u8]) -> io::Result<(bool, bool)> { self.send( KIND_ENDPOINT_INPUT, serde_json::to_value(EndpointInputReq { @@ -1200,7 +1202,7 @@ impl Brain { BrokerEvent::Other(env) if env.kind == KIND_ENDPOINT_INJECTED => { let reply: EndpointInjected = serde_json::from_value(env.payload) .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; - return Ok(reply.delivered); + return Ok((reply.delivered, reply.spool_deferred)); } BrokerEvent::Error { message } => return Err(io::Error::other(message)), _ => continue, diff --git a/crates/spt-daemon/src/broker.rs b/crates/spt-daemon/src/broker.rs index b90fe2a..8ce4b59 100644 --- a/crates/spt-daemon/src/broker.rs +++ b/crates/spt-daemon/src/broker.rs @@ -1067,8 +1067,17 @@ fn build_translation( let w_child = Arc::clone(&child); let w_floor = Arc::clone(&floor); let w_faulted = Arc::clone(&faulted); + let w_endpoint = endpoint_id.to_string(); thread::spawn(move || { - run_inject_worker(w_input, w_child, w_floor, event_rx, cmd_rx, w_faulted); + run_inject_worker( + w_input, + w_child, + w_floor, + event_rx, + cmd_rx, + w_faulted, + w_endpoint, + ); }); Some(Arc::new(Translation { floor, @@ -1107,6 +1116,7 @@ fn read_translation_path(install_dir: &std::path::Path) -> Option { // [impl->REQ-MSG-IDLE-TRANSLATION-BINARY] // [impl->REQ-HAZARD-INJECT-CONTROL-COEXIST] +// [impl->REQ-MSG-DELIVERY-AXES] fn run_inject_worker( input: Arc, child: Arc, @@ -1114,6 +1124,7 @@ fn run_inject_worker( event_rx: Receiver, cmd_rx: Receiver, faulted: Arc, + endpoint: String, ) { // `event_rx` blocks here; events queue → one sequence at a time. for envelope in event_rx { @@ -1122,11 +1133,20 @@ fn run_inject_worker( // to this event's sequence. while cmd_rx.try_recv().is_ok() {} + // Clone BEFORE send: ToBinary::Event moves the envelope, so we need the + // clone available for fault re-spool on either failure path. + let envelope_copy = envelope.clone(); floor.lock().unwrap().open(); if child.send(&ToBinary::Event { envelope }).is_err() { // stdin write failed → the binary is gone; release the floor + fault. flush_inject_floor(&floor, &input); - fault_translation(&faulted, &child, "stdin write failed"); + respool_and_fault( + &endpoint, + envelope_copy, + &faulted, + &child, + "stdin write failed", + ); return; } @@ -1169,7 +1189,13 @@ fn run_inject_worker( flush_inject_floor(&floor, &input); if !committed { - fault_translation(&faulted, &child, "no {commit} within INJECT_COMMIT_DEADLINE"); + respool_and_fault( + &endpoint, + envelope_copy, + &faulted, + &child, + "no {commit} within INJECT_COMMIT_DEADLINE", + ); return; } } @@ -1177,6 +1203,26 @@ fn run_inject_worker( child.terminate(); } +/// Re-spool the in-flight envelope as non-deferred (idle-eligible) so the +/// relay/poll can surface it, then fault the translation binary. +// [impl->REQ-MSG-DELIVERY-AXES] +fn respool_and_fault( + endpoint: &str, + envelope: String, + faulted: &AtomicBool, + child: &TranslationChild, + why: &str, +) { + let perch_path = + spt_store::perch::resolve_perch_path(endpoint, spt_store::perch::ParentHint::Infer); + if let Err(e) = spt_store::spool::spool_message_at(&perch_path, "", &envelope) { + eprintln!( + "TRANSLATION_FAULT_RESPOOL:{endpoint}: failed to re-spool ({e}) — message dropped" + ); + } + fault_translation(faulted, child, why); +} + /// FAULT a translation binary: mark the session degraded (so inbound delivery /// SPOOLS until a working binary and `dispatch_input` stops pinging), log it, and /// reap the child. Never respawns — inbound spools (poll-fed), never raw-injected, @@ -1907,10 +1953,35 @@ impl Broker { }; match resolved { Some(translation) => { - // Layer E: a live (non-faulted) translation binary OWNS the + // ACTIVITY GATE (ADR-0028, v0.15.0): route by the endpoint's `.idle` + // sentinel, read ONCE here so the caller spools the right deferred-ness + // off this single read (no caller-side TOCTOU). An endpoint is ACTIVE + // until it explicitly declares idle (`api state idle`). + // ACTIVE ⇒ the message waits for the receiver's own hook-poll: spool + // DEFERRED (no live wake) — never injected mid-activity, even with a + // binary attached (the InjectFloor mid-turn path is the W3 native + // override, not the default). + // IDLE ⇒ deliver now via a working translation binary, else spool + // NON-deferred (the live relay/poll surfaces it = the wake). + let idle = spt_store::perch::resolve_idle_file( + &req.endpoint, + spt_store::perch::ParentHint::Infer, + ) + .exists(); + if !idle { + // [impl->REQ-MSG-DELIVERY-AXES] + eprintln!( + "ENDPOINT_INJECT:{}: endpoint ACTIVE -> spooled (deferred, hook-poll window), not injected", + req.endpoint + ); + send_frame(send, &endpoint_injected_envelope(&req.endpoint, false, true)); + return Ok(()); + } + // IDLE window: a live (non-faulted) translation binary OWNS the // choreography — queue the whole `` envelope to the inject - // worker, which drives it through the binary and applies the - // resulting keystrokes atomically w.r.t. the controller. + // worker, which drives it through the binary and applies the resulting + // keystrokes atomically w.r.t. the controller. + // [impl->REQ-MSG-DELIVERY-AXES] if let Some(tr) = &translation { if !tr.faulted.load(Ordering::Relaxed) { let envelope = String::from_utf8_lossy(&bytes).into_owned(); @@ -1920,30 +1991,35 @@ impl Broker { req.endpoint, bytes.len() ); - send_frame(send, &endpoint_injected_envelope(&req.endpoint, true)); + send_frame( + send, + &endpoint_injected_envelope(&req.endpoint, true, false), + ); return Ok(()); } // worker receiver dropped → SPOOL (below), never raw inject. } } - // No WORKING translation binary (none declared, faulted, or its - // inject worker is gone) → tell the caller to SPOOL (delivered=false). - // Raw `payload + \r` is NOT a fallback: on a modern TUI it does not - // submit — a non-delivering pseudo-write that MASKED F-019. The - // inbound is never lost (it stays spooled, poll-fed) and the failure - // is LOUD so a mis-declared/repaired binary is diagnosable. + // IDLE + no WORKING translation binary (none declared, faulted, or its + // inject worker is gone) → tell the caller to SPOOL NON-deferred (idle + // window; the live relay/poll surfaces it = the wake). Raw `payload + \r` + // is NOT a fallback: on a modern TUI it does not submit — a non-delivering + // pseudo-write that MASKED F-019. The inbound is never lost (spooled, + // poll-fed) and the failure is LOUD so a mis-declared/repaired binary is + // diagnosable. // [impl->REQ-MSG-IDLE-TRANSLATION-BINARY] // [impl->REQ-HAZARD-IDLE-SILENT-NONDELIVERY] eprintln!( - "ENDPOINT_INJECT:{}: no working translation binary (absent/faulted/worker-gone) -> SPOOLED, not injected", + "ENDPOINT_INJECT:{}: no working translation binary (absent/faulted/worker-gone) -> SPOOLED (idle window), not injected", req.endpoint ); - send_frame(send, &endpoint_injected_envelope(&req.endpoint, false)); + send_frame(send, &endpoint_injected_envelope(&req.endpoint, false, false)); Ok(()) } - // No hosted session for this endpoint — tell the caller to spool. + // No hosted session for this endpoint — tell the caller to spool + // NON-deferred (idle-eligible; a non-hosted target has no active window). None => { - send_frame(send, &endpoint_injected_envelope(&req.endpoint, false)); + send_frame(send, &endpoint_injected_envelope(&req.endpoint, false, false)); Ok(()) } } diff --git a/crates/spt-daemon/src/msg.rs b/crates/spt-daemon/src/msg.rs index 815c93a..f8d7d26 100644 --- a/crates/spt-daemon/src/msg.rs +++ b/crates/spt-daemon/src/msg.rs @@ -702,9 +702,20 @@ pub struct EndpointInputReq { pub struct EndpointInjected { /// The endpoint the inject targeted. pub endpoint: String, - /// `true` = a hosted session was found and the bytes were written to its PTY; - /// `false` = no broker session hosts this endpoint (caller falls back to spool). + /// `true` = a hosted session was found, IDLE, with a working translation binary, + /// and the bytes were delivered (injected) to its PTY; `false` = the caller must + /// SPOOL (no hosted session, the endpoint is ACTIVE, or no working binary). pub delivered: bool, + /// The activity-gate hint (ADR-0028, v0.15.0): when `delivered == false`, the + /// deferred-ness the caller should spool with — decided from the broker's SINGLE + /// activity-sentinel read so there is no caller-side TOCTOU. `true` = the endpoint + /// was ACTIVE → spool DEFERRED (the receiver's hook-poll window only, no live + /// wake); `false` = IDLE / no hosted session → spool NON-deferred (the live + /// relay/poll surfaces it = the spt-core-driven wake). Additive + serde-defaulted + /// (`false`): an N-1 broker omits it and the caller spools non-deferred exactly as + /// in v0.14.3. Meaningless (ignored) when `delivered == true`. + #[serde(default)] + pub spool_deferred: bool, } /// `brain-restarted` payload — the broker's ack for [`KIND_BRAIN_RESTART`] @@ -859,12 +870,17 @@ pub fn applied_envelope(session_id: u64, op_id: u64, applied_now: bool) -> Envel } /// Build an `endpoint-injected` reply envelope (REQ-SEND-SPT-HOSTED). -pub fn endpoint_injected_envelope(endpoint: &str, delivered: bool) -> Envelope { +pub fn endpoint_injected_envelope( + endpoint: &str, + delivered: bool, + spool_deferred: bool, +) -> Envelope { Envelope::new( KIND_ENDPOINT_INJECTED, serde_json::to_value(EndpointInjected { endpoint: endpoint.to_string(), delivered, + spool_deferred, }) .expect("EndpointInjected serializes"), ) @@ -1027,12 +1043,12 @@ mod tests { assert_eq!(back.endpoint, "wall-b"); assert_eq!(decode_bytes(&back.data_b64).unwrap(), decode_bytes(&req.data_b64).unwrap()); - let env = endpoint_injected_envelope("wall-b", true); + let env = endpoint_injected_envelope("wall-b", true, false); assert_eq!(env.kind, KIND_ENDPOINT_INJECTED); let ev: EndpointInjected = serde_json::from_value(env.payload).unwrap(); assert_eq!((ev.endpoint.as_str(), ev.delivered), ("wall-b", true)); let miss: EndpointInjected = - serde_json::from_value(endpoint_injected_envelope("x", false).payload).unwrap(); + serde_json::from_value(endpoint_injected_envelope("x", false, false).payload).unwrap(); assert!(!miss.delivered); } @@ -1080,26 +1096,44 @@ mod tests { assert_eq!(decode_bytes(&ev.data_b64).unwrap(), b"hello"); } - // [unit->REQ-HAZARD-IDLE-SILENT-NONDELIVERY] The `delivered` flag is the spool - // signal: `false` is what the CLI's `try_broker_inject` reads to fall through to - // `deliver::send` (SPOOL) instead of reporting "Sent". With raw inject removed, - // the no-working-binary path replies `delivered=false` — this asserts the - // envelope faithfully carries that bool BOTH ways, so a false (spool) verdict can - // never be silently rendered as a true (delivered) one on the wire. + // [unit->REQ-HAZARD-IDLE-SILENT-NONDELIVERY] [unit->REQ-MSG-DELIVERY-AXES] The + // `delivered` flag is the spool signal: `false` is what the CLI's + // `try_broker_inject` reads to fall through to `deliver::send` (SPOOL) instead of + // reporting "Sent". The additive `spool_deferred` hint (v0.15.0 activity gate) + // tells the caller HOW to spool when delivered=false: ACTIVE → deferred (hook-poll + // only), IDLE/no-session → non-deferred (relay wakes). This asserts the envelope + // carries both bools faithfully AND that an N-1 broker (no `spool_deferred` on the + // wire) defaults to `false` (non-deferred) = the v0.14.3 behavior. #[test] - fn endpoint_injected_envelope_carries_delivered_both_ways() { - let spooled = endpoint_injected_envelope("wall-b", false); - assert_eq!(spooled.kind, KIND_ENDPOINT_INJECTED); - let ev: EndpointInjected = serde_json::from_value(spooled.payload).unwrap(); + fn endpoint_injected_envelope_carries_delivered_and_spool_hint() { + // ACTIVE spool: delivered=false + spool_deferred=true. + let active = endpoint_injected_envelope("wall-b", false, true); + assert_eq!(active.kind, KIND_ENDPOINT_INJECTED); + let ev: EndpointInjected = serde_json::from_value(active.payload).unwrap(); assert_eq!(ev.endpoint, "wall-b"); - assert!( - !ev.delivered, - "a no-working-binary reply MUST carry delivered=false so the caller spools" - ); + assert!(!ev.delivered, "a spooled reply carries delivered=false"); + assert!(ev.spool_deferred, "an ACTIVE-window spool hint is deferred"); + // IDLE/no-binary spool: delivered=false + spool_deferred=false. + let idle: EndpointInjected = + serde_json::from_value(endpoint_injected_envelope("wall-b", false, false).payload) + .unwrap(); + assert!(!idle.delivered); + assert!(!idle.spool_deferred, "an IDLE-window spool hint is non-deferred (relay wakes)"); + + // Delivered: delivered=true (spool_deferred ignored). let delivered: EndpointInjected = - serde_json::from_value(endpoint_injected_envelope("wall-b", true).payload).unwrap(); + serde_json::from_value(endpoint_injected_envelope("wall-b", true, false).payload) + .unwrap(); assert!(delivered.delivered, "a binary-delivered reply carries delivered=true"); + + // N-1: an old broker omits `spool_deferred` → serde-defaults to false. + let n1: EndpointInjected = + serde_json::from_value(json!({ "endpoint": "wall-b", "delivered": false })).unwrap(); + assert!( + !n1.spool_deferred, + "an N-1 reply without spool_deferred defaults to non-deferred (v0.14.3 behavior)" + ); } // [unit->REQ-HAZARD-INPUT-ACK-BACKPRESSURE] An `input` payload from an OLDER diff --git a/crates/spt-store/src/spool.rs b/crates/spt-store/src/spool.rs index 24a1c2b..359452c 100644 --- a/crates/spt-store/src/spool.rs +++ b/crates/spt-store/src/spool.rs @@ -307,6 +307,22 @@ pub fn peek_all_at(perch_path: &Path) -> rusqlite::Result rusqlite::Result> { + let conn = open_spool_at(perch_path)?; + purge_expired(&conn)?; + let mut stmt = conn.prepare( + "SELECT id, from_id, body FROM messages WHERE delivered = 0 AND deferred = 0 ORDER BY id ASC", + )?; + let rows = stmt + .query_map([], |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?)) + })? + .filter_map(|r| r.ok()) + .collect(); + Ok(rows) +} + /// Count of undelivered, non-expired rows (all deferred statuses). pub fn pending_count_at(perch_path: &Path) -> rusqlite::Result { let conn = open_spool_at(perch_path)?; diff --git a/crates/spt/src/api/delivery.rs b/crates/spt/src/api/delivery.rs index c1df8f4..0045e9d 100644 --- a/crates/spt/src/api/delivery.rs +++ b/crates/spt/src/api/delivery.rs @@ -33,6 +33,7 @@ fn sentinel_path(id: &str, name: &str) -> std::path::PathBuf { /// Set/clear the `.idle` sentinel for an activity transition. Idle additionally /// arms the echo-gate unless `no_gate` (CONTEXT §api state). // [impl->REQ-SEAM-ACTIVITY] +// [impl->REQ-MSG-DELIVERY-AXES] pub fn cmd_state(id: &str, state: &str, no_gate: bool) -> i32 { let idle = sentinel_path(id, IDLE_SENTINEL); match state { @@ -52,6 +53,11 @@ pub fn cmd_state(id: &str, state: &str, no_gate: bool) -> i32 { " (gate-armed)" } ); + // Idle-transition DRAIN: try to inject any queued non-deferred messages + // now that the sentinel is written and the broker will route them via the + // translation binary. Rows that don't inject stay in the spool for + // relay/poll. Best-effort: errors are silent (spool is the fallback). + drain_idle_window(id); 0 } "busy" => { @@ -141,12 +147,19 @@ pub fn resolve_inject_methods(manifest: Option<&Manifest>, idle: bool) -> VecREQ-API-2] // [impl->REQ-MSG-ENVELOPE] +// [impl->REQ-MSG-DELIVERY-AXES] pub fn cmd_poll(id: &str, include_deferred: bool, manifest: Option<&Manifest>) -> i32 { - // Inject-method selection is resolved (hook/relay) even though the M2a poll - // channel is the hook drain; PTY delivery lands with the broker in M3. - let _methods = resolve_inject_methods(manifest, is_idle(id)); - for m in poll_drain(id, include_deferred) { - emit_event(&m.from, &m.body); + // Honor the manifest `[inject]` idle/activity method: only drain via the + // hook channel when Hook is in the resolved method set. An adapter declaring + // `idle = ["relay"]` never expects hook-poll to drain its idle messages — + // relay is its live channel; draining here would double-consume the rows. + // Default (no manifest / empty config after PTY-filter) = [Hook] → drain as + // before; existing adapters are unaffected. + let methods = resolve_inject_methods(manifest, is_idle(id)); + if methods.contains(&InjectMethod::Hook) { + for m in poll_drain(id, include_deferred) { + emit_event(&m.from, &m.body); + } } 0 } @@ -209,6 +222,52 @@ pub fn poll_drain(id: &str, include_deferred: bool) -> Vec { drained.unwrap_or_default() } +/// Idle-transition DRAIN: try to inject any queued non-deferred spool rows via +/// the broker's translation binary NOW that the endpoint just declared idle. +/// Rows that the broker successfully delivers are marked delivered; those that +/// don't inject (no working binary, still-active race, IPC error) remain in the +/// spool for relay/poll to surface. Best-effort: all errors are silent. +// [impl->REQ-MSG-DELIVERY-AXES] +fn drain_idle_window(id: &str) { + let perch_path = perch::resolve_perch_path(id, ParentHint::Infer); + let rows = match spool::peek_non_deferred_at(&perch_path) { + Ok(r) if !r.is_empty() => r, + _ => return, + }; + if !spt_daemon::is_running() { + return; + } + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + let mut brain = match spt_daemon::brain::Brain::cold_start( + &spt_daemon::broker_socket_name(), + now_ms, + ) { + Ok(b) => b, + Err(_) => return, + }; + let mut delivered_ids = Vec::new(); + for (msg_id, from, body) in rows { + let mut envelope = spt_msg::emit::render_event_whole(&from, &body); + envelope.push('\n'); + match brain.inject_endpoint(id, envelope.as_bytes()) { + Ok((true, _)) => { + delivered_ids.push(msg_id); + } + _ => break, // binary absent/faulted for this endpoint — stop trying + } + } + if !delivered_ids.is_empty() { + let _ = spool::mark_delivered_at(&perch_path, &delivered_ids); + eprintln!( + "IDLE_DRAIN:{id}: injected {} queued message(s) via translation binary", + delivered_ids.len() + ); + } +} + /// Emit one drained row to a hook-channel surface as ONE whole `` line /// (composed via [`spt_msg::emit::render_event_whole`]: typed bodies pass /// through verbatim, plain bodies compose ``; never @@ -373,4 +432,71 @@ mod tests { vec![InjectMethod::Hook] ); } + + // [unit->REQ-MSG-DELIVERY-AXES] cmd_poll only drains when Hook is in the resolved method set. + #[test] + fn poll_only_drains_when_hook_method_present() { + let _h = isolated_home(); + establish("bob"); + let perch_path = perch::resolve_perch_path("bob", ParentHint::Infer); + spool::spool_message_at(&perch_path, "alice", "hello").unwrap(); + + // No manifest → defaults to [Hook] → drains. + assert_eq!( + poll_drain("bob", false).len(), + 1, + "Hook method: row must drain" + ); + + // Re-spool for the relay test. + spool::spool_message_at(&perch_path, "alice", "hello again").unwrap(); + + // Manifest with relay-only idle method → Hook NOT in set → poll_drain is not + // called via cmd_poll (tested via the drain helper directly as a proxy). + // Verify the method resolution: a relay-only config does not contain Hook. + let m = Manifest::from_toml_str( + "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n[inject]\nidle=[\"relay\"]\n", + ) + .unwrap(); + let methods = resolve_inject_methods(Some(&m), true); + assert!( + !methods.contains(&InjectMethod::Hook), + "relay-only idle config must not contain Hook: {methods:?}" + ); + // Row still undelivered (relay handles it). + assert_eq!( + spool::pending_count_non_deferred_at(&perch_path).unwrap(), + 1 + ); + } + + // [unit->REQ-MSG-DELIVERY-AXES] idle-transition drain marks rows delivered after + // successful broker inject (simulated by verifying the peek + mark_delivered_at + // path without a live broker — we confirm the helper doesn't crash and the spool + // state is consistent; the live delivery is covered by the int gate). + #[test] + fn idle_drain_peek_returns_non_deferred_rows_only() { + let _h = isolated_home(); + establish("carol"); + let perch_path = perch::resolve_perch_path("carol", ParentHint::Infer); + spool::spool_message_at(&perch_path, "alice", "live one").unwrap(); + spool::spool_message_deferred_at(&perch_path, "alice", "deferred one").unwrap(); + + let rows = spool::peek_non_deferred_at(&perch_path).unwrap(); + assert_eq!(rows.len(), 1, "peek_non_deferred_at must exclude deferred rows"); + assert!(rows[0].2.contains("live one")); + + // Marking delivered does not touch the deferred row. + let ids: Vec = rows.iter().map(|r| r.0).collect(); + spool::mark_delivered_at(&perch_path, &ids).unwrap(); + assert_eq!( + spool::pending_count_non_deferred_at(&perch_path).unwrap(), + 0 + ); + assert_eq!( + spool::pending_count_at(&perch_path).unwrap(), + 1, + "deferred row survives mark_delivered_at on non-deferred ids" + ); + } } diff --git a/crates/spt/src/cli.rs b/crates/spt/src/cli.rs index 4ec3559..0568aee 100644 --- a/crates/spt/src/cli.rs +++ b/crates/spt/src/cli.rs @@ -3502,20 +3502,23 @@ fn is_spt_hosted_no_relay(target: &str, owlery: &std::path::Path) -> bool { /// the SAME whole `` every arriving-message surface emits (ADR-0020, via /// [`render_event_whole`] — one whole envelope, never EVENT-PART-chunked: the PTY /// has no Monitor per-line cap), so the harness parses one format regardless of -/// topology. Returns `true` only on a CONFIRMED broker write (so the caller -/// reports `Sent`, un-lying "online"); any miss (daemon down, no hosted session, -/// write error) returns `false` so the caller falls back to the spool — the -/// message is never lost. +/// topology. Returns `Some((delivered, spool_deferred))` on a successful broker +/// round-trip: `delivered=true` = broker injected via translation binary (Sent); +/// `spool_deferred=true` = endpoint is ACTIVE (spool deferred / hook-channel), +/// `false` = endpoint is IDLE but no working binary (spool non-deferred / wake). +/// Returns `None` on any miss (daemon down, no session, IPC error) so the caller +/// falls back to the normal deliver path — the message is never lost. // [impl->REQ-SEND-SPT-HOSTED] -fn try_broker_inject(target: &str, from: &str, body: &str) -> bool { +// [impl->REQ-MSG-DELIVERY-AXES] +fn try_broker_inject(target: &str, from: &str, body: &str) -> Option<(bool, bool)> { if !spt_daemon::is_running() { - return false; + return None; } let mut bytes = spt_msg::emit::render_event_whole(from, body); bytes.push('\n'); match spt_daemon::brain::Brain::cold_start(&spt_daemon::broker_socket_name(), now_ms()) { - Ok(mut brain) => brain.inject_endpoint(target, bytes.as_bytes()).unwrap_or(false), - Err(_) => false, + Ok(mut brain) => brain.inject_endpoint(target, bytes.as_bytes()).ok(), + Err(_) => None, } } @@ -3561,18 +3564,21 @@ fn cmd_send( // spt-hosted PTY-inject path (REQ-SEND-SPT-HOSTED): an spt-hosted endpoint // (broker holds its PTY, no `api listen` relay) would otherwise spool - // silently — inject the rendered straight into its PTY instead. Only - // a CONFIRMED broker write counts as Sent; any miss falls through to the - // normal deliver path (spool / WAN), so the message is never lost. Skipped - // for deferred (the no-interrupt hook channel) and empty bodies (let - // deliver::send return Empty). + // silently — inject the rendered straight into its PTY instead. The + // broker hint (spool_deferred) tells us WHICH spool channel to use when the + // binary is absent/idle: active window → deferred (hook-poll), idle window → + // non-deferred (relay/poll wakes it). Only a CONFIRMED broker write counts as + // Sent; any miss falls through to the normal deliver path, never lost. + // Skipped for --deferred (the explicit hook channel) and empty bodies. + // [impl->REQ-MSG-DELIVERY-AXES] let outcome = if deferred { deliver::send_deferred(&target, &from, body) - } else if !body.is_empty() - && is_spt_hosted_no_relay(&target, &owlery) - && try_broker_inject(&target, &from, body) - { - SendOutcome::Sent + } else if !body.is_empty() && is_spt_hosted_no_relay(&target, &owlery) { + match try_broker_inject(&target, &from, body) { + Some((true, _)) => SendOutcome::Sent, + Some((false, true)) => deliver::send_deferred(&target, &from, body), + Some((false, false)) | None => deliver::send(&target, &from, body, &owlery), + } } else { deliver::send(&target, &from, body, &owlery) }; diff --git a/traceable-reqs.toml b/traceable-reqs.toml index 91f4f3f..dac8d91 100644 --- a/traceable-reqs.toml +++ b/traceable-reqs.toml @@ -1253,7 +1253,7 @@ required_stages = ["doc", "impl", "unit", "int"] # ACTIVATED v0.14.3 (todlando, [[requirements]] id = "REQ-MSG-DELIVERY-AXES" title = "Activity-gated inbound delivery + per-message send control as THREE ORTHOGONAL AXES plus opaque metadata (ADR-0028; grilled w/ operator 2026-06-23). SUBSTRATE (the legacy-SPT parity gap, scaffolded-but-unwired today: `delivery::is_idle` + `resolve_inject_methods` exist but the result is discarded `let _methods`, and `broker::dispatch_endpoint_input` injects unconditionally — its comment calls activity-gating 'a deferred follow wave'): an inbound message has an ACTIVE window (endpoint active → spool for the receiver's hook-poll, non-disruptive) and an IDLE window (idle/idle-transition → deliver immediately: translation binary spt-hosted → relay-poll either topology → spool, in fallback order). AXES (each composes; each defaults to its unrestricted value): (1) DELIVERY WINDOW — default (both, first-to-fire) | `--idle-only` (idle window; immediate if already idle) | `--active-only` (active window only, never wakes; the RENAMED `--deferred` — `deferred=1` spool column + `api poll --include-deferred` keep their names). (2) CHANNEL RESTRICTION — unrestricted | `--prefer-native` (translation binary if running else fall back) | `--force-native` (binary ONLY, no fallback/no spool-to-other-method). Native flags do NOT respect the binary's idle-gating: the WINDOW says when, the native flag says through-what (so `--force-native --active-only` = binary injects during the active window, mid-turn-safe via the existing InjectFloor). (3) PERSISTENCE — durable (default; spool until delivered or TTL) | `--ephemeral` (drop if undeliverable in the accepted window — at window-open with no live carrier, or at TTL, whichever first). METADATA (orthogonal): `--json-payload ''` → a single attr-escaped `json=\"…\"` envelope attr ALONGSIDE (not replacing) the body, pure verbatim passthrough across spool/TCP/WAN/EVENT-PART, parsed only by the receiving adapter; collision-proof by construction (structured data lives INSIDE the one `json` value, can never forge `from`/`type`); available to ANY sender (confers no spt-core authority). HAZARD: `--ephemeral` is the ONLY path permitted to drop silently — the sender-opted-in carve-out to REQ-HAZARD-IDLE-SILENT-NONDELIVERY (that hazard gains a '…unless --ephemeral' clause in v0.15.0). (v0.15.0)" -required_stages = ["doc"] # SEED→DOC: ADR-0028 + CONTEXT.md (activity-gated delivery / message delivery axes / message metadata `json`) landed at the design grill (2026-06-23). impl/unit/int activate when the v0.15.0 build starts (wire the discarded resolve_inject_methods into dispatch_endpoint_input + the active/idle two-window router; add the window/channel/persistence flags to `spt send` w/ mutual-exclusion per axis; render `json` attr; rename --deferred→--active-only + grep tests for the old flag; amend REQ-HAZARD-IDLE-SILENT-NONDELIVERY w/ the --ephemeral carve-out). +required_stages = ["doc", "impl"] # doc landed at the grill (ADR-0028 + CONTEXT). ACTIVATED +impl v0.15.0 W1 (todlando): the activity-gated routing substrate — broker.rs dispatch_endpoint_input reads the `.idle` sentinel (spt_store::perch::resolve_idle_file) and routes ACTIVE→spool(deferred) / IDLE+working-binary→inject / IDLE+no-binary→spool(non-deferred); msg.rs EndpointInjected.spool_deferred additive hint carries the deferred-ness to the caller (one broker sentinel read, no TOCTOU). unit/int activate at later waves (unit incrementally, int at W4 — traceable-per-wave). W2 adds the window/persistence flags + spool schema; W3 the channel axis; W4 the `json` attr + console-window fix + the --ephemeral carve-out on REQ-HAZARD-IDLE-SILENT-NONDELIVERY + activate +unit+int. # ── translate-proof: the author-time EMIT-half proof for the translation binary ── [[requirements]]