diff --git a/INVARIANTS.md b/INVARIANTS.md index ed266b34..c16a608b 100644 --- a/INVARIANTS.md +++ b/INVARIANTS.md @@ -10,7 +10,7 @@ materialization, messaging, DING, or presence must preserve them. | **Clean exec teardown** | Killing an exec task reaps its whole process group. | `tests/exec_backend.rs::exec_kill_reaps_the_whole_process_group_not_just_the_leader` | | **Bounded restart diagnostics** | Relaunching an exec task preserves the just-finished log as one prior generation while bounding retained diagnostics to current plus prior. Final retirement removes the PID and both logs. | `tests/exec_backend.rs::exec_restart_reap_keeps_bounded_diagnostics_and_final_remove_cleans_them`; `tests/run.rs::up_once_finally_removes_dead_retired_tasks_without_restarting_them` | | **Exactly-once-safe native bus** | Messages use stable `-.md` files. An archive filename is a durable receipt that shadows and cleans restored inbox replicas and makes repeated archive cleanup idempotent. | `src/message.rs::filename_grammar`; `src/message.rs::archive_receipt_suppresses_and_idempotently_cleans_a_restored_inbox_copy`; `tests/message.rs` | -| **Fail-closed observed native DING** | Each unread message becomes one normalized `[DING]` frame. A maintained Codex or Claude composer must be positively empty before a bracketed paste, then show the exact notice in two immediately adjacent inspections before a separate bare Return. Human, modal, active, changed, timed-out, and unknown states never receive Return. Once paste starts, inspect-only staged ownership prevents duplicate paste across command failures, archive races, and restart adoption. Startup backlog otherwise becomes one generic recovery DING; new arrivals remain FIFO; `busy` delivers immediately; only fresh `dnd` defers. | `src/ding/mod.rs::poke_text_normalizes_and_bounds_untrusted_fields`; `src/ding/mod.rs::malicious_controls_cannot_escape_the_single_paste_frame`; `src/ding/mod.rs::pty_stage_and_submit_are_separate_exact_sequences`; `src/ding/mod.rs::maintained_composer_classifiers_require_exact_idle_state`; `src/ding/mod.rs::paste_then_two_exact_observations_precede_return`; `src/ding/mod.rs::changed_modal_ambiguous_and_bounded_timeout_never_return`; `src/ding/mod.rs::final_observation_change_and_staged_retry_are_fail_closed`; `src/ding/mod.rs::staged_ownership_survives_archive_and_never_repastes`; `src/ding/mod.rs::pty_commands_have_a_real_outer_timeout`; `src/ding/mod.rs::session_watch_has_startup_grace_debounce_and_live_reset`; `src/ding/mod.rs::new_arrivals_is_fifo_and_archive_receipts_prevent_reding`; `src/ding/mod.rs::pending_delivery_ignores_busy_but_respects_fresh_dnd_archive_and_retry`; `src/ding/mod.rs::startup_recovery_notice_retries_in_memory`; `src/ding/mod.rs::startup_backlog_gets_one_generic_recovery_then_new_arrivals_poke` | +| **Fail-closed observed native DING** | Each unread message becomes one normalized `[DING]` frame. Fresh delivery records ownership, then preserves the one combined bracketed-paste, 0.5 second delay, and Return transaction. PTY and Return success are transport only: `Delivered` additionally requires adapter classification of the expected notice text in a submitted-prompt or queued-message pattern while the lowest live composer is empty or an accepted idle placeholder. Every other receipt retains staged ownership. Retry never re-pastes and may send one bare Return only after two adjacent `RetainedSafe` observations; human, modal, active, changed, timed-out, and unknown retry states receive no input. Ownership prevents duplicate paste across command failures, receipt ambiguity, archive races, and restart adoption. Startup backlog otherwise becomes one generic recovery DING; new arrivals remain FIFO; `busy` delivers immediately; only fresh `dnd` defers. | `src/ding/mod.rs::poke_text_normalizes_and_bounds_untrusted_fields`; `src/ding/mod.rs::malicious_controls_cannot_escape_the_single_paste_frame`; `src/ding/mod.rs::pty_delivery_uses_face607_delay_order_and_seconds`; `src/ding/mod.rs::maintained_composer_classifiers_require_exact_idle_state`; `src/ding/mod.rs::successful_transport_with_retained_or_unproven_pixels_is_not_delivered`; `src/ding/mod.rs::ambiguous_transport_receipt_and_retry_errors_retain_staged_ownership`; `src/ding/mod.rs::adapter_recognized_notice_with_an_empty_live_composer_is_a_positive_receipt`; `src/ding/mod.rs::staged_retry_submits_only_retained_safe_and_requires_a_receipt`; `src/ding/mod.rs::staged_ownership_survives_archive_and_never_repastes`; `src/ding/mod.rs::pty_commands_have_a_real_outer_timeout`; `src/ding/mod.rs::session_watch_has_startup_grace_debounce_and_live_reset`; `src/ding/mod.rs::new_arrivals_is_fifo_and_archive_receipts_prevent_reding`; `src/ding/mod.rs::pending_delivery_ignores_busy_but_respects_fresh_dnd_archive_and_retry`; `src/ding/mod.rs::startup_recovery_notice_retries_in_memory`; `src/ding/mod.rs::startup_backlog_gets_one_generic_recovery_then_new_arrivals_poke` | | **Mutation-only filesystem wakeups** | Supervisor and DING filesystem watchers ignore read/open access events and wake early only for create, modify, rename, or remove events. Their own catalog and inbox reads therefore cannot bypass the bounded timer cadence or form a Linux inotify CPU loop. | `src/watch.rs::only_mutations_wake_watch_loops`; `src/watch.rs::linux_reads_are_silent_but_real_mutations_wake`; `src/ding/mod.rs::idle_ding_does_not_spin_on_its_own_inbox_reads`; `src/run.rs::idle_supervisor_does_not_spin_on_its_own_catalog_reads` | | **Bounded DING PTY probe churn** | An unsafe or active composer retains its FIFO notice but deferred delivery retries use a bounded backoff, so each inbox poll cannot spawn another short-lived PTY probe. | `src/ding/mod.rs::deferred_delivery_backoff_bounds_short_lived_pty_attempts` | | **Agent-declared presence discipline** | The shipped bus contract requires agents to declare `busy` before executing work, use `available` only while yielding or ready, and reserve `dnd` for an explicit hold. Both native harnesses materialize that contract. Busy remains observable but does not suppress DING; fresh `dnd` is the only delivery gate. | `tests/compile_agent.rs::compile_agent_generates_claude_then_materializes_verbatim_persona`; `tests/compile_agent.rs::compile_agent_generates_codex_then_materializes_composed_agents_md`; `src/ding/mod.rs::pending_delivery_ignores_busy_but_respects_fresh_dnd_archive_and_retry` | diff --git a/docs/vrs/01-ding/01-claude/spec.md b/docs/vrs/01-ding/01-claude/spec.md index 13356893..f3265093 100644 --- a/docs/vrs/01-ding/01-claude/spec.md +++ b/docs/vrs/01-ding/01-claude/spec.md @@ -59,6 +59,15 @@ differently from typed input, so recognizing it relies on a text grammar a human could in principle type; an empty composer cannot be confused with a human draft, because a draft is not empty. +## Post-submit receipt + +The exact notice as the complete lowest live composer is `RetainedSafe` only +with the ordinary idle proof and no blocking state; otherwise it is +`RetainedBlocked`. `Accepted` requires both an empty or recognized placeholder +in the lowest live composer and the expected notice text in the adapter's +submitted-prompt or queued-message pattern. A placeholder alone, disappearance, +a different live draft, and unrecognized pixels are `Unproven`. + ## Blocked states Return is withheld while the screen shows an active turn or a modal. Two diff --git a/docs/vrs/01-ding/02-codex/spec.md b/docs/vrs/01-ding/02-codex/spec.md index 8c4676f9..17171c25 100644 --- a/docs/vrs/01-ding/02-codex/spec.md +++ b/docs/vrs/01-ding/02-codex/spec.md @@ -45,6 +45,15 @@ markers rather than by inspecting recovered text. Comparison against the expected notice uses the shared soft-wrap candidate enumeration in [`../spec.md`](../spec.md). +## Post-submit receipt + +The exact notice as the complete lowest live composer is `RetainedSafe` only +with the ordinary idle proof and no blocking state; otherwise it is +`RetainedBlocked`. `Accepted` requires both an empty lowest live composer and +the expected notice text in the adapter's submitted-prompt or queued-message +pattern. An empty composer alone, disappearance, a different live draft, and +unrecognized pixels are `Unproven`. + ## Blocked states Active-turn and modal detection is owned here rather than shared diff --git a/docs/vrs/01-ding/requirements.md b/docs/vrs/01-ding/requirements.md index c23a28d6..9915a19e 100644 --- a/docs/vrs/01-ding/requirements.md +++ b/docs/vrs/01-ding/requirements.md @@ -41,21 +41,22 @@ is in [`spec.md`](./spec.md). ## Requirements -### Must never disturb a human - -- **DING-R01 Positive idle precondition:** Text is pasted only after a - maintained harness's composer has been positively identified as present, - empty, and idle. Absence of evidence that a human is typing is not evidence - of an idle composer. -- **DING-R02 Two adjacent exact observations:** Return is pressed only after the - exact staged notice has been observed as the complete composer contents twice - in immediately adjacent inspections, with the final observation adjacent to - the Return itself. Any change, block, or uncertainty between them prevents - submission. -- **DING-R03 Fail-closed default:** A changed composer, a human draft, an - active turn, a modal, an unreadable screen, an unrecognized harness, and a - bounded observation timeout all withhold Return. The default for anything not - positively understood is deferral. +### Must preserve initial transport and gate every retry + +- **DING-R01 Combined initial transport:** A fresh notice uses one bounded PTY + transaction containing the bracketed paste, the accepted 0.5 second delay, + and Return. Ownership is recorded before that command starts. Composer + heuristics do not split or suppress this initial transport. +- **DING-R02 Two adjacent retained-safe retry observations:** A later bare + Return is permitted only for a transport-owned payload whose exact notice is + still the complete composer and is classified `RetainedSafe` in two + immediately adjacent inspections. The final observation is adjacent to the + Return itself. Any change, block, or uncertainty prevents retry submission. +- **DING-R03 Fail-closed receipt and retry:** After the initial transport, a + changed composer, a human draft, an active turn, a modal, an unreadable + screen, an unrecognized harness, and a bounded observation timeout never + become `Delivered` and receive no retry input. Anything not positively + understood retains staged ownership. ### Must classify the surface it will actually type into @@ -86,6 +87,13 @@ is in [`spec.md`](./spec.md). terminal probe. - **DING-R09 Presence gate:** Declared `busy` is observable but never suppresses delivery; only fresh `dnd` defers it. Delivery may wake a working agent. +- **DING-R10 Positive harness receipt:** `Delivered` requires adapter-provided + classification that the expected notice text is visible in that harness's + submitted-prompt or queued-message pattern while its lowest live composer is + empty or an accepted idle placeholder. PTY command success, generic screen + change, disappearance alone, and ambiguous pixels are not receipts. Until + that evidence exists, a transport attempt retains staged ownership and + retries by inspection without re-pasting. ## Evidence diff --git a/docs/vrs/01-ding/spec.md b/docs/vrs/01-ding/spec.md index 5e13e414..025d3234 100644 --- a/docs/vrs/01-ding/spec.md +++ b/docs/vrs/01-ding/spec.md @@ -29,24 +29,49 @@ notice, yields exactly one state: ## Delivery ```text -peek ─► classify ─┬─ ExactSafe ────► final observation ─► Return ─► Delivered - ├─ ExactBlocked ─────────────────────────────────► Staged - ├─ Changed / Ambiguous ──────────────────────────► Deferred - └─ EmptySafe ─► paste ─► observe until deadline ─┬► Delivered - ├► Staged - └► Deferred +record ownership ─► combined transport (paste ─► 0.5s ─► Return) ─► receipt + │ command failure or ambiguity │ + └──────────────────────────────────────────────► Staged + +receipt ─┬─ Accepted ──────────────────────────────────────────────► Delivered + └─ RetainedSafe / RetainedBlocked / Unproven ────────────► Staged + +staged retry ─► receipt ─┬─ Accepted ──────────────────────────────► Delivered + ├─ RetainedSafe ─► final receipt ─┬─ Accepted ─► Delivered + │ ├─ RetainedSafe ─► Return ─► receipt + │ └─ other ────────► Staged + └─ RetainedBlocked / Unproven ─────────────► Staged ``` -Delivery is two-phase: a bracketed paste that carries no Return, then a separate -bare Return gated on a second exact observation (`DING-R02`). The observation -loop after paste runs to a bounded deadline; expiry yields `Staged`, never a -Return. +Fresh delivery preserves the production transport: one bounded PTY transaction +contains a bracketed paste, a 0.5 second delay, and Return (`DING-R01`). +Ownership is recorded immediately before that transaction. The production path +does not inspect the composer first and does not use the separate staging +helper. -Every failure of a terminal command after paste has begun resolves to `Staged` -rather than `Deferred` (`DING-R07`): the paste may already have reached the -harness, so ownership is retained and retry re-inspects instead of re-pasting. A -retry of a staged payload is inspect-only — it may submit or defer, but it never -pastes. +Every failure of that terminal command or of the following receipt observation +resolves to `Staged` (`DING-R07`): the paste and Return may already have reached +the harness, so ownership is retained and retry re-inspects instead of +re-pasting. A staged retry is inspect-only unless two adjacent `RetainedSafe` +observations authorize one bare Return (`DING-R02`). + +Return is transport, not a delivery receipt. After any submission attempt, a +bounded observation loop asks the selected harness adapter for one of four +states: + +| Receipt state | Meaning | +| --- | --- | +| `Accepted` | The expected notice text is visible in an adapter-recognized submitted-prompt or queued-message pattern while the lowest live composer is empty or an accepted idle placeholder | +| `RetainedSafe` | The exact notice remains the complete live composer and Return is currently safe | +| `RetainedBlocked` | The exact notice remains the complete live composer but the harness is active or blocked | +| `Unproven` | No positive acceptance or exact retained-composer state was proven | + +Only `Accepted` becomes `Delivered` (`DING-R10`). PTY command success, generic +screen change, disappearance alone, a changed composer, unreadable output, and +observation timeout retain `Staged` ownership. A staged retry completes without +input when it observes `Accepted`; it may send one bare Return only after two +adjacent `RetainedSafe` observations, then must obtain the same positive +receipt. `RetainedBlocked` and `Unproven` send no input. No retry re-pastes. ## Harness dispatch @@ -66,6 +91,11 @@ pasted into a pane — without a per-pair special case. When exactly one harness locates a composer, that one is classified; when none do, the screen is `Ambiguous`. +Post-submit receipt classification uses that same positional dispatch and a +shared `ReceiptState` type. Each adapter owns the renderer-specific proof that +the exact notice is retained or accepted; shared delivery code never matches +Codex or Claude pixels directly (`DING-R06`, `DING-R10`). + Positions are compared in one unit. Harnesses do not agree on what they match against: some locate against the raw screen including escape sequences, others against ANSI-stripped lines. A raw byte offset and a stripped line index are not diff --git a/src/ding/composer.rs b/src/ding/composer.rs index 7b491b62..24a696b9 100644 --- a/src/ding/composer.rs +++ b/src/ding/composer.rs @@ -1,7 +1,7 @@ //! What a peeked screen proves about one exact notice, and the routing that decides which //! composer on the screen is the live one. -use super::harness::{self, Screen}; +use super::harness::{self, ReceiptState, Screen}; /// What the current bottom composer proves about one exact normalized notice. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -22,7 +22,10 @@ pub(super) enum ComposerState { /// discarded one inter-word space or split a token. Current 80-column Codex/Claude composers wrap /// long DING rows at 70+ content cells and indent continuations by exactly two cells. Short or /// unfamiliar multiline input remains literal and cannot equal a normalized single-line DING. -pub(super) fn logical_soft_wrap_candidates(input: &str, minimum_first_content_chars: usize) -> Vec { +pub(super) fn logical_soft_wrap_candidates( + input: &str, + minimum_first_content_chars: usize, +) -> Vec { let rows: Vec<&str> = input.lines().collect(); let Some(first) = rows.first() else { return vec![String::new()]; @@ -144,12 +147,39 @@ pub(super) fn strip_ansi(input: &str) -> String { /// construction, so picking the lowest needs no per-pair special case. pub(super) fn classify_composer(screen: &str, expected: &str) -> ComposerState { let plain = strip_ansi(screen); - let screen = Screen { raw: screen, plain: &plain }; + let screen = Screen { + raw: screen, + plain: &plain, + }; harness::all() .into_iter() - .filter_map(|harness| harness.locate(&screen).map(|located| (located.row, harness))) + .filter_map(|harness| { + harness + .locate(&screen) + .map(|located| (located.row, harness)) + }) .max_by_key(|(row, _)| *row) .map(|(_, harness)| harness.classify(&screen, expected)) // No maintained composer is locatable, so nothing is proven either way. .unwrap_or(ComposerState::Ambiguous) } + +/// Route post-submit receipt classification through the lowest maintained live composer, using +/// the same positional rule as pre-submit classification. +pub(super) fn classify_receipt(screen: &str, expected: &str) -> ReceiptState { + let plain = strip_ansi(screen); + let screen = Screen { + raw: screen, + plain: &plain, + }; + harness::all() + .into_iter() + .filter_map(|harness| { + harness + .locate(&screen) + .map(|located| (located.row, harness)) + }) + .max_by_key(|(row, _)| *row) + .map(|(_, harness)| harness.receipt(&screen, expected)) + .unwrap_or(ReceiptState::Unproven) +} diff --git a/src/ding/harness/claude.rs b/src/ding/harness/claude.rs index 1ab2bdfd..a298c302 100644 --- a/src/ding/harness/claude.rs +++ b/src/ding/harness/claude.rs @@ -1,7 +1,7 @@ //! The Claude Code composer: a `❯` row between two full-width rules, with the permission-mode //! footer below it. -use super::{Harness, Located, Screen}; +use super::{Harness, Located, ReceiptState, Screen, screen_has_accepted_notice}; use crate::ding::composer::{ComposerState, logical_soft_wrap_candidates, looks_like_choice_menu}; pub(super) struct Claude; @@ -17,6 +17,26 @@ impl Harness for Claude { }; classify_claude_composer(screen.plain, (logical_inputs, footer), expected) } + + fn receipt(&self, screen: &Screen<'_>, expected: &str) -> ReceiptState { + let Some((_, logical_inputs, footer)) = located_bottom_claude_composer(screen.plain) else { + return ReceiptState::Unproven; + }; + let exact = logical_inputs.iter().any(|input| input == expected); + let placeholder = logical_inputs.len() == 1 + && (logical_inputs[0].is_empty() || is_claude_idle_placeholder(&logical_inputs[0])); + let idle_footer = footer.contains("⏵⏵") && footer.contains("permissions on"); + let blocked = interaction_blocked(screen.plain); + if exact && idle_footer && !blocked { + ReceiptState::RetainedSafe + } else if exact { + ReceiptState::RetainedBlocked + } else if placeholder && screen_has_accepted_notice(screen, '❯', expected) { + ReceiptState::Accepted + } else { + ReceiptState::Unproven + } + } } fn classify_claude_composer( diff --git a/src/ding/harness/codex.rs b/src/ding/harness/codex.rs index 6d5b4f55..d46b1ad8 100644 --- a/src/ding/harness/codex.rs +++ b/src/ding/harness/codex.rs @@ -1,7 +1,7 @@ //! The Codex composer: a `›` prompt written directly as an ANSI sequence, with the model/cwd //! status line below it. -use super::{Harness, Located, Screen}; +use super::{Harness, Located, ReceiptState, Screen, screen_has_accepted_notice}; use crate::ding::composer::{ ComposerState, logical_soft_wrap_candidates, looks_like_choice_menu, strip_ansi, }; @@ -13,13 +13,40 @@ impl Harness for Codex { // The markers are ANSI, so this locator works in raw byte offsets while the router compares // stripped rows. Every marker starts at an `\x1b[` boundary, so stripping the prefix is // faithful and its newline count is that composer's row. - located_bottom_codex_composer(screen.raw) - .map(|(start, _)| Located { row: strip_ansi(&screen.raw[..start]).matches('\n').count() }) + located_bottom_codex_composer(screen.raw).map(|(start, _)| Located { + row: strip_ansi(&screen.raw[..start]).matches('\n').count(), + }) } fn classify(&self, screen: &Screen<'_>, expected: &str) -> ComposerState { classify_codex_composer(screen.raw, screen.plain, expected) } + + fn receipt(&self, screen: &Screen<'_>, expected: &str) -> ReceiptState { + let blocked = interaction_blocked(screen.plain); + let Some((start, composer)) = located_bottom_codex_composer(screen.raw) else { + return ReceiptState::Unproven; + }; + let idle_footer = codex_idle_footer(&screen.raw[start..]); + match composer { + CodexComposer::Empty if screen_has_accepted_notice(screen, '›', expected) => { + ReceiptState::Accepted + } + CodexComposer::Empty => ReceiptState::Unproven, + CodexComposer::Typed(input) => { + let exact = logical_soft_wrap_candidates(&input, 70) + .iter() + .any(|input| input == expected); + if exact && !blocked && idle_footer { + ReceiptState::RetainedSafe + } else if exact { + ReceiptState::RetainedBlocked + } else { + ReceiptState::Unproven + } + } + } + } } const CODEX_EMPTY_COMPOSERS: [&str; 3] = [ diff --git a/src/ding/harness/mod.rs b/src/ding/harness/mod.rs index 0fa86193..6971df2a 100644 --- a/src/ding/harness/mod.rs +++ b/src/ding/harness/mod.rs @@ -14,6 +14,23 @@ pub(super) mod codex; use super::composer::ComposerState; +/// What one maintained harness can prove after a transport attempted to submit one exact notice. +/// +/// The shared delivery state machine consumes only this type. Harness-specific screen vocabulary +/// stays behind [`Harness`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ReceiptState { + /// The expected notice text is visible in the harness's submitted-prompt or queued-message + /// pattern while the live composer is empty or an accepted idle placeholder. + Accepted, + /// The exact notice remains the complete live composer and Return is currently safe. + RetainedSafe, + /// The exact notice remains the complete live composer, but the harness is active or blocked. + RetainedBlocked, + /// No positive acceptance or exact retained-composer state was proven. + Unproven, +} + /// One peeked screen in both forms a harness may need: the raw bytes, in which the Codex composer /// markers are written as ANSI sequences, and the stripped text, in which the Claude rules are /// found. Both describe the same screen, so a row index is comparable across harnesses. @@ -36,6 +53,45 @@ pub(super) trait Harness { /// implementation re-derives it rather than the registry threading a harness-specific payload /// through; a screen is one viewport, so the extra scan is a few string searches. fn classify(&self, screen: &Screen<'_>, expected: &str) -> ComposerState; + + /// Classify post-submit evidence for one exact notice. Implementations may return `Accepted` + /// only when the lowest live composer is empty or an accepted idle placeholder and the + /// expected notice text is visible in that harness's submitted-prompt or queued-message + /// pattern. + fn receipt(&self, screen: &Screen<'_>, expected: &str) -> ReceiptState; +} + +/// The normalized expected notice text is visible after this harness's submitted-prompt glyph or +/// after the explicit queued-message heading. Whitespace compaction admits renderer soft wraps; +/// the caller separately proves that the lowest live composer is empty or an accepted placeholder. +pub(super) fn screen_has_accepted_notice( + screen: &Screen<'_>, + submitted_prompt: char, + expected: &str, +) -> bool { + const QUEUED_MESSAGES: &str = "Messages to be submitted after next tool call"; + + fn compact(input: &str) -> String { + input + .chars() + .filter(|character| !character.is_whitespace()) + .collect() + } + + let screen = super::normalize_line(screen.plain); + let expected = super::normalize_line(expected); + let submitted = format!("{submitted_prompt} {expected}"); + let compact_screen = compact(&screen); + let compact_expected = compact(&expected); + let compact_submitted = format!("{submitted_prompt}{compact_expected}"); + + screen.contains(&submitted) + || compact_screen.contains(&compact_submitted) + || screen + .split_once(QUEUED_MESSAGES) + .is_some_and(|(_, queued)| { + queued.contains(&expected) || compact(queued).contains(&compact_expected) + }) } /// Every registered harness. Claude is last so that an exact row tie resolves to Claude, which is diff --git a/src/ding/mod.rs b/src/ding/mod.rs index 29d98c51..1777ea49 100644 --- a/src/ding/mod.rs +++ b/src/ding/mod.rs @@ -1,12 +1,14 @@ //! Native inbox-to-terminal DING delivery. //! -//! Every maintained harness uses the same fail-closed transport: positively identify an empty -//! composer, bracketed-paste the normalized notice without Return, and submit only after two exact -//! safe-composer observations. A human draft, active turn, modal, changed composer, unreadable -//! screen, or bounded observation timeout never receives Return. +//! Fresh delivery preserves one bounded production transport containing the normalized +//! bracketed-paste, a 0.5 second delay, and Return. Once that transaction has started, any command +//! or receipt ambiguity retains staged ownership. A later bare-Return retry is allowed only after +//! two adjacent adapter observations prove the exact retained composer is safe. //! //! Once a paste command starts, the sidecar owns that payload and retries by inspection only. It -//! never pastes the same notice again until the exact staged payload has disappeared or changed. +//! never pastes the same notice again while that transport attempt remains owned. +//! PTY or Return success is not delivery: a harness adapter must positively classify the expected +//! notice text in its submitted-prompt or queued-message pattern while the live composer is empty. //! This preserves FIFO/archive behavior without letting a command timeout create duplicate text. //! Startup can adopt an exact staged recovery or backlog notice before coalescing remaining unread //! work into one generic recovery DING. `busy` never suppresses a notification; fresh `dnd` does. @@ -25,8 +27,9 @@ mod composer; mod harness; use crate::message::{self, Message}; -use composer::{ComposerState, classify_composer}; use crate::status; +use composer::{ComposerState, classify_composer, classify_receipt}; +use harness::ReceiptState; const BRACKETED_PASTE_START: &str = "\x1b[200~"; const BRACKETED_PASTE_END: &str = "\x1b[201~"; @@ -36,9 +39,7 @@ const RECOVERY_POKE: &str = "[DING] unread st2 messages remain; check your inbox // Must exceed face607's bounded 0.5s delivery delay plus PTY/Node startup overhead; otherwise a // successful pane write is misreported as a timeout and retried, duplicating the owned payload. const PTY_COMMAND_TIMEOUT: Duration = Duration::from_secs(2); -#[allow(dead_code)] const COMPOSER_OBSERVATION_WINDOW: Duration = Duration::from_millis(450); -#[allow(dead_code)] const COMPOSER_OBSERVATION_POLL: Duration = Duration::from_millis(10); /// A human or active turn can keep a staged notice unsafe for minutes. Retrying `pty peek` every /// inbox poll creates a short-lived child for each attempt, so keep the correctness fallback but @@ -128,7 +129,10 @@ pub fn pty_submit_args(session: &str) -> Vec { ] } -/// Recovery delivery: one bounded PTY transaction containing paste and Return. +/// Recovery transport: one bounded PTY transaction containing paste and Return. +/// +/// A successful command proves only that the PTY accepted the input sequence. [`PokeOutcome::Delivered`] +/// still requires a separate harness receipt for the exact notice. pub fn pty_delivery_args(session: &str, text: &str) -> Vec { vec![ "send".into(), @@ -142,8 +146,8 @@ pub fn pty_delivery_args(session: &str, text: &str) -> Vec { ] } -/// One delivery attempt either submitted the notice, owns a paste that must be retried by -/// inspection only, or performed no input because the target was not positively safe. +/// One delivery attempt either has positive harness acceptance for the exact notice, owns an +/// ambiguous or retained payload that must be retried by inspection only, or performed no input. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PokeOutcome { Delivered, @@ -207,20 +211,21 @@ impl PtyPoker { }) } - /// Central production safety path shared by inbox DING and any caller that needs to record an - /// attempt immediately before the only command containing Return. + /// Central production path shared by inbox DING and any caller that needs to record an attempt + /// immediately before the command containing Return. pub fn poke_with( &self, text: &str, before_submit: &mut dyn FnMut() -> anyhow::Result<()>, ) -> anyhow::Result { - // Recovery contract: PTY-alive delivery is transport-first. Keep one owned payload, - // stage it once, wait a bounded interval, then submit exactly once; pane heuristics remain - // diagnostic only and must not silently suppress messaging. - self.run(pty_delivery_args(&self.session, text), "send") - .map_err(|error| anyhow::anyhow!("staging DING payload: {error}"))?; - before_submit()?; - Ok(PokeOutcome::Delivered) + transport_and_observe_with_window( + text, + &mut || self.run(pty_delivery_args(&self.session, text), "send"), + &mut || self.peek(), + &mut || thread::sleep(COMPOSER_OBSERVATION_POLL), + before_submit, + COMPOSER_OBSERVATION_WINDOW, + ) } } @@ -230,10 +235,14 @@ impl Poker for PtyPoker { } fn retry_staged(&self, text: &str) -> anyhow::Result { - let _ = text; - self.run(pty_submit_args(&self.session), "send") - .map_err(|error| anyhow::anyhow!("submitting staged DING payload: {error}"))?; - Ok(PokeOutcome::Delivered) + retry_staged_with_window( + text, + &mut || self.peek(), + &mut || self.run(pty_submit_args(&self.session), "send"), + &mut || thread::sleep(COMPOSER_OBSERVATION_POLL), + &mut || Ok(()), + COMPOSER_OBSERVATION_WINDOW, + ) } fn adopt_staged(&self, candidates: &[String]) -> anyhow::Result> { @@ -306,6 +315,120 @@ fn exact_staged_candidate(screen: &str, candidates: &[String]) -> Option }) } +fn transport_and_observe_with_window( + text: &str, + transport: &mut dyn FnMut() -> anyhow::Result<()>, + peek: &mut dyn FnMut() -> anyhow::Result, + poll: &mut dyn FnMut(), + before_submit: &mut dyn FnMut() -> anyhow::Result<()>, + observation_window: Duration, +) -> anyhow::Result { + before_submit()?; + // Preserve the accepted transport-first transaction. Once it starts, any command or + // observation failure is ambiguous: the paste may have landed even if Return did not. + if let Err(error) = transport() { + eprintln!("st2 ding: DING transport became ambiguous; retaining staged ownership: {error}"); + return Ok(PokeOutcome::Staged); + } + observe_receipt_with_window(text, peek, poll, observation_window) +} + +/// Observe one bounded post-submit window. PTY success, disappearance, and generic screen change +/// are not receipts; only the adapter's positive accepted-pattern classification completes +/// delivery. +fn observe_receipt_with_window( + text: &str, + peek: &mut dyn FnMut() -> anyhow::Result, + poll: &mut dyn FnMut(), + observation_window: Duration, +) -> anyhow::Result { + let deadline = Instant::now() + observation_window; + loop { + let screen = match peek() { + Ok(screen) => screen, + Err(error) => { + eprintln!( + "st2 ding: post-submit receipt observation failed; retaining staged ownership: {error}" + ); + return Ok(PokeOutcome::Staged); + } + }; + if classify_receipt(&screen, text) == ReceiptState::Accepted { + return Ok(PokeOutcome::Delivered); + } + if Instant::now() >= deadline { + return Ok(PokeOutcome::Staged); + } + poll(); + } +} + +/// Inspect-only retry for a transport-owned payload. It never pastes; one bare Return is allowed +/// only after two adjacent adapter observations prove the exact retained composer is safe. +fn retry_staged_with_window( + text: &str, + peek: &mut dyn FnMut() -> anyhow::Result, + submit: &mut dyn FnMut() -> anyhow::Result<()>, + poll: &mut dyn FnMut(), + before_submit: &mut dyn FnMut() -> anyhow::Result<()>, + observation_window: Duration, +) -> anyhow::Result { + let screen = match peek() { + Ok(screen) => screen, + Err(error) => { + eprintln!("st2 ding: staged retry observation failed; retaining ownership: {error}"); + return Ok(PokeOutcome::Staged); + } + }; + match classify_receipt(&screen, text) { + ReceiptState::Accepted => Ok(PokeOutcome::Delivered), + ReceiptState::RetainedSafe => submit_retained_after_final_observation( + text, + peek, + submit, + poll, + before_submit, + observation_window, + ), + ReceiptState::RetainedBlocked | ReceiptState::Unproven => Ok(PokeOutcome::Staged), + } +} + +fn submit_retained_after_final_observation( + text: &str, + peek: &mut dyn FnMut() -> anyhow::Result, + submit: &mut dyn FnMut() -> anyhow::Result<()>, + poll: &mut dyn FnMut(), + before_submit: &mut dyn FnMut() -> anyhow::Result<()>, + observation_window: Duration, +) -> anyhow::Result { + let screen = match peek() { + Ok(screen) => screen, + Err(error) => { + eprintln!( + "st2 ding: final retained-composer observation failed; retaining ownership: {error}" + ); + return Ok(PokeOutcome::Staged); + } + }; + match classify_receipt(&screen, text) { + ReceiptState::Accepted => return Ok(PokeOutcome::Delivered), + ReceiptState::RetainedSafe => {} + ReceiptState::RetainedBlocked | ReceiptState::Unproven => { + return Ok(PokeOutcome::Staged); + } + } + if let Err(error) = before_submit() { + eprintln!("st2 ding: pre-submit receipt failed; retaining staged ownership: {error}"); + return Ok(PokeOutcome::Staged); + } + if let Err(error) = submit() { + eprintln!("st2 ding: Return command became ambiguous; retaining staged ownership: {error}"); + return Ok(PokeOutcome::Staged); + } + observe_receipt_with_window(text, peek, poll, observation_window) +} + #[allow(dead_code)] fn observed_poke( text: &str, @@ -338,7 +461,14 @@ fn observed_poke_with_window( ) -> anyhow::Result { match classify_composer(&peek()?, text) { ComposerState::ExactSafe => { - return submit_after_final_observation(text, peek, submit, before_submit); + return submit_after_final_observation( + text, + peek, + submit, + poll, + before_submit, + observation_window, + ); } ComposerState::ExactBlocked => return Ok(PokeOutcome::Staged), ComposerState::EmptySafe => {} @@ -367,10 +497,17 @@ fn observed_poke_with_window( }; match classify_composer(&screen, text) { ComposerState::ExactSafe => { - return submit_after_final_observation(text, peek, submit, before_submit); + return submit_after_final_observation( + text, + peek, + submit, + poll, + before_submit, + observation_window, + ); } ComposerState::ExactBlocked => return Ok(PokeOutcome::Staged), - ComposerState::Changed => return Ok(PokeOutcome::Deferred), + ComposerState::Changed => return Ok(PokeOutcome::Staged), ComposerState::EmptySafe | ComposerState::Ambiguous => {} } if Instant::now() >= deadline { @@ -386,7 +523,9 @@ fn submit_after_final_observation( text: &str, peek: &mut dyn FnMut() -> anyhow::Result, submit: &mut dyn FnMut() -> anyhow::Result<()>, + poll: &mut dyn FnMut(), before_submit: &mut dyn FnMut() -> anyhow::Result<()>, + observation_window: Duration, ) -> anyhow::Result { let screen = match peek() { Ok(screen) => screen, @@ -403,7 +542,7 @@ fn submit_after_final_observation( return Ok(PokeOutcome::Staged); } ComposerState::EmptySafe | ComposerState::Changed => { - return Ok(PokeOutcome::Deferred); + return Ok(PokeOutcome::Staged); } } if let Err(error) = before_submit() { @@ -414,31 +553,7 @@ fn submit_after_final_observation( eprintln!("st2 ding: Return command became ambiguous; retaining staged ownership: {error}"); return Ok(PokeOutcome::Staged); } - Ok(PokeOutcome::Delivered) -} - -/// Inspect-only retry for a payload whose paste command already started. -#[allow(dead_code)] -fn observed_retry_staged( - text: &str, - peek: &mut dyn FnMut() -> anyhow::Result, - submit: &mut dyn FnMut() -> anyhow::Result<()>, - before_submit: &mut dyn FnMut() -> anyhow::Result<()>, -) -> anyhow::Result { - let screen = match peek() { - Ok(screen) => screen, - Err(error) => { - eprintln!("st2 ding: staged retry observation failed; retaining ownership: {error}"); - return Ok(PokeOutcome::Staged); - } - }; - match classify_composer(&screen, text) { - ComposerState::ExactSafe => { - submit_after_final_observation(text, peek, submit, before_submit) - } - ComposerState::ExactBlocked | ComposerState::Ambiguous => Ok(PokeOutcome::Staged), - ComposerState::EmptySafe | ComposerState::Changed => Ok(PokeOutcome::Deferred), - } + observe_receipt_with_window(text, peek, poll, observation_window) } /// `/.pid` + `kill(pid, 0)`; any miss means gone. This mirrors the @@ -1067,6 +1182,21 @@ mod tests { staged_codex_screen("please keep my half-written draft") } + fn accepted_codex_screen(text: &str) -> String { + format!( + "{}\r\n\r\n{}", + staged_codex_screen(text), + idle_codex_screen() + ) + } + + fn queued_codex_screen(text: &str) -> String { + format!( + "Messages to be submitted after next tool call:\r\n{text}\r\n\r\n{}", + idle_codex_screen() + ) + } + fn claude_rule() -> String { "─".repeat(80) } @@ -1158,10 +1288,14 @@ mod tests { /// escape-heavy output. The escapes inflate the Codex byte offset far past the Claude /// composer's row, which is what makes the two locators' units observably disagree. fn live_claude_below_escape_heavy_codex_transcript() -> String { - let padding = "\x1b[1;32m\x1b[38;5;204mpadding with lots of escapes\x1b[0m\x1b[0m\r\n".repeat(10); + let padding = + "\x1b[1;32m\x1b[38;5;204mpadding with lots of escapes\x1b[0m\x1b[0m\r\n".repeat(10); let codex = staged_codex_screen("a stale pasted codex draft"); let filler = "\x1b[1;32mmore padding\x1b[0m\r\n".repeat(6); - format!("{padding}{codex}\r\n{filler}{}", mature_idle_claude_screen()) + format!( + "{padding}{codex}\r\n{filler}{}", + mature_idle_claude_screen() + ) } /// A Codex pane whose scrollback holds a captured Claude screen — two ruled lines around a `❯` @@ -1288,7 +1422,11 @@ mod tests { // shows a captured Codex composer still classifies from its own live Claude composer. assert_eq!( classify_composer( - &format!("{}\r\n{}", staged_codex_screen("a stale pasted codex draft"), mature_idle_claude_screen()), + &format!( + "{}\r\n{}", + staged_codex_screen("a stale pasted codex draft"), + mature_idle_claude_screen() + ), expected ), ComposerState::EmptySafe @@ -1416,6 +1554,7 @@ mod tests { idle_codex_screen(), staged_codex_screen(text), staged_codex_screen(text), + accepted_codex_screen(text), ])); let actions = RefCell::new(Vec::new()); let outcome = observed_poke_with_window( @@ -1443,7 +1582,7 @@ mod tests { assert_eq!(outcome, PokeOutcome::Delivered); assert_eq!( *actions.borrow(), - ["peek", "paste", "peek", "peek", "receipt", "return"] + ["peek", "paste", "peek", "peek", "receipt", "return", "peek"] ); } @@ -1534,23 +1673,26 @@ mod tests { Duration::from_millis(10), ) .unwrap(); - assert_eq!(outcome, PokeOutcome::Deferred); + assert_eq!(outcome, PokeOutcome::Staged); assert_eq!(*actions.borrow(), ["paste"]); let retry_screens = RefCell::new(VecDeque::from([ staged_claude_screen(text), staged_claude_screen(text), + format!("❯\u{00a0}{text}\r\n{}", idle_claude_screen()), ])); let retry_actions = RefCell::new(Vec::new()); assert_eq!( - observed_retry_staged( + retry_staged_with_window( text, &mut || Ok(retry_screens.borrow_mut().pop_front().unwrap()), &mut || { retry_actions.borrow_mut().push("return"); Ok(()) }, + &mut || {}, &mut || Ok(()), + Duration::ZERO, ) .unwrap(), PokeOutcome::Delivered @@ -1558,6 +1700,237 @@ mod tests { assert_eq!(*retry_actions.borrow(), ["return"]); } + #[test] + fn successful_transport_with_retained_or_unproven_pixels_is_not_delivered() { + use std::cell::RefCell; + + let text = "[DING] new st2 message: [id:abc123] receipt truth (from cos); check your inbox"; + for screen in [ + staged_codex_screen(text), + idle_codex_screen(), + human_codex_screen(), + "unknown renderer".to_string(), + ] { + let actions = RefCell::new(Vec::new()); + let outcome = transport_and_observe_with_window( + text, + &mut || { + actions.borrow_mut().push("transport"); + Ok(()) + }, + &mut || { + actions.borrow_mut().push("peek"); + Ok(screen.clone()) + }, + &mut || actions.borrow_mut().push("poll"), + &mut || { + actions.borrow_mut().push("before-submit"); + Ok(()) + }, + Duration::ZERO, + ) + .unwrap(); + assert_eq!(outcome, PokeOutcome::Staged); + assert_eq!( + *actions.borrow(), + ["before-submit", "transport", "peek"], + "transport success alone must never become Delivered" + ); + } + } + + #[test] + fn ambiguous_transport_receipt_and_retry_errors_retain_staged_ownership() { + use std::cell::RefCell; + + let text = "[DING] new st2 message: [id:abc123] error truth (from cos); check your inbox"; + + let actions = RefCell::new(Vec::new()); + assert_eq!( + transport_and_observe_with_window( + text, + &mut || { + actions.borrow_mut().push("transport"); + anyhow::bail!("ambiguous transport") + }, + &mut || { + actions.borrow_mut().push("peek"); + Ok(idle_codex_screen()) + }, + &mut || {}, + &mut || { + actions.borrow_mut().push("before-submit"); + Ok(()) + }, + Duration::ZERO, + ) + .unwrap(), + PokeOutcome::Staged + ); + assert_eq!(*actions.borrow(), ["before-submit", "transport"]); + + assert_eq!( + transport_and_observe_with_window( + text, + &mut || Ok(()), + &mut || anyhow::bail!("unreadable receipt"), + &mut || {}, + &mut || Ok(()), + Duration::ZERO, + ) + .unwrap(), + PokeOutcome::Staged + ); + + let screens = RefCell::new(VecDeque::from([ + staged_codex_screen(text), + staged_codex_screen(text), + ])); + let submits = RefCell::new(0); + assert_eq!( + retry_staged_with_window( + text, + &mut || Ok(screens.borrow_mut().pop_front().unwrap()), + &mut || { + *submits.borrow_mut() += 1; + anyhow::bail!("ambiguous Return") + }, + &mut || {}, + &mut || Ok(()), + Duration::ZERO, + ) + .unwrap(), + PokeOutcome::Staged + ); + assert_eq!(*submits.borrow(), 1); + } + + #[test] + fn adapter_recognized_notice_with_an_empty_live_composer_is_a_positive_receipt() { + let text = "[DING] new st2 message: [id:abc123] receipt truth (from cos); check your inbox"; + assert_eq!( + classify_receipt(&queued_codex_screen(text), text), + ReceiptState::Accepted + ); + assert_eq!( + classify_receipt(&accepted_codex_screen(text), text), + ReceiptState::Accepted + ); + assert_eq!( + classify_receipt(&staged_codex_screen(text), text), + ReceiptState::RetainedSafe + ); + assert_eq!( + classify_receipt( + &format!("ordinary transcript: {text}\r\n{}", idle_codex_screen()), + text + ), + ReceiptState::Unproven, + "notice text outside an adapter-recognized accepted pattern is not a receipt" + ); + assert_eq!( + classify_receipt( + &format!("old receipt: {text}\r\n{}", human_codex_screen()), + text + ), + ReceiptState::Unproven, + "a changed live composer cannot be accepted from transcript evidence" + ); + + assert_eq!( + classify_receipt( + &format!("❯\u{00a0}{text}\r\n{}", idle_claude_screen()), + text + ), + ReceiptState::Accepted + ); + assert_eq!( + classify_receipt(&staged_claude_screen(text), text), + ReceiptState::RetainedSafe + ); + assert_eq!( + classify_receipt( + &format!("ordinary transcript: {text}\r\n{}", idle_claude_screen()), + text + ), + ReceiptState::Unproven + ); + } + + #[test] + fn staged_retry_submits_only_retained_safe_and_requires_a_receipt() { + use std::cell::RefCell; + + let text = "[DING] new st2 message: [id:abc123] retry truth (from cos); check your inbox"; + + let retained = RefCell::new(VecDeque::from([ + staged_codex_screen(text), + staged_codex_screen(text), + staged_codex_screen(text), + ])); + let submits = RefCell::new(0); + let outcome = retry_staged_with_window( + text, + &mut || Ok(retained.borrow_mut().pop_front().unwrap()), + &mut || { + *submits.borrow_mut() += 1; + Ok(()) + }, + &mut || {}, + &mut || Ok(()), + Duration::ZERO, + ) + .unwrap(); + assert_eq!(outcome, PokeOutcome::Staged); + assert_eq!(*submits.borrow(), 1); + + for screen in [ + format!("Create a plan?\r\n{}", staged_codex_screen(text)), + idle_codex_screen(), + human_codex_screen(), + "unknown renderer".to_string(), + ] { + let submits = RefCell::new(0); + let outcome = retry_staged_with_window( + text, + &mut || Ok(screen.clone()), + &mut || { + *submits.borrow_mut() += 1; + Ok(()) + }, + &mut || {}, + &mut || Ok(()), + Duration::ZERO, + ) + .unwrap(); + assert_eq!(outcome, PokeOutcome::Staged); + assert_eq!( + *submits.borrow(), + 0, + "blocked or unproven receipts must not receive Return" + ); + } + + let accepted = queued_codex_screen(text); + let submits = RefCell::new(0); + assert_eq!( + retry_staged_with_window( + text, + &mut || Ok(accepted.clone()), + &mut || { + *submits.borrow_mut() += 1; + Ok(()) + }, + &mut || {}, + &mut || Ok(()), + Duration::ZERO, + ) + .unwrap(), + PokeOutcome::Delivered + ); + assert_eq!(*submits.borrow(), 0); + } + #[test] fn pty_commands_have_a_real_outer_timeout() { let started = Instant::now();