diff --git a/INVARIANTS.md b/INVARIANTS.md index fa14f15c..af142da1 100644 --- a/INVARIANTS.md +++ b/INVARIANTS.md @@ -11,7 +11,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. 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` | +| **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. Retry never re-pastes and may send one bare Return only after two adjacent `RetainedSafe` observations. A maintained adapter's positive `NotRetained` observation releases only an already archived staged head; unread, blocked, timed-out, errored, unknown, and unrecognized states retain ownership and later FIFO work remains blocked. Ownership prevents duplicate paste across command failures, receipt ambiguity, archive races, and restart adoption without letting a vanished archived head block FIFO indefinitely. 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_retry_keeps_unproven_and_retained_blocked_owned`; `src/ding/mod.rs::staged_ownership_survives_archive_and_never_repastes`; `src/ding/mod.rs::archived_not_retained_releases_fifo_without_repasting_owned_notice`; `src/ding/mod.rs::unread_not_retained_keeps_fifo_ownership_without_repasting`; `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/native_only.rs::clean_path_executes_the_maintained_native_authoring_guide`; `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 f3265093..4427d367 100644 --- a/docs/vrs/01-ding/01-claude/spec.md +++ b/docs/vrs/01-ding/01-claude/spec.md @@ -65,8 +65,9 @@ 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`. +submitted-prompt or queued-message pattern. A parsed placeholder, empty +composer, or different live draft that is not accepted is `NotRetained`. +Disappearance and unrecognized pixels are `Unproven`. ## Blocked states diff --git a/docs/vrs/01-ding/02-codex/spec.md b/docs/vrs/01-ding/02-codex/spec.md index 17171c25..de3cd5d9 100644 --- a/docs/vrs/01-ding/02-codex/spec.md +++ b/docs/vrs/01-ding/02-codex/spec.md @@ -51,8 +51,8 @@ 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`. +pattern. A parsed empty composer or different live draft that is not accepted +is `NotRetained`. Disappearance and unrecognized pixels are `Unproven`. ## Blocked states diff --git a/docs/vrs/01-ding/requirements.md b/docs/vrs/01-ding/requirements.md index 9915a19e..130f72fa 100644 --- a/docs/vrs/01-ding/requirements.md +++ b/docs/vrs/01-ding/requirements.md @@ -56,7 +56,11 @@ is in [`spec.md`](./spec.md). 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. + understood retains staged ownership. On an inspect-only staged retry, a + maintained adapter may positively prove that the exact owned payload is no + longer retained; that proof relinquishes ownership only when an archive + receipt already removed the notice from the inbox. An unread notice remains + staged even after positive absence, so it is never pasted again. ### Must classify the surface it will actually type into @@ -91,9 +95,11 @@ is in [`spec.md`](./spec.md). 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. + change, disappearance alone, and ambiguous pixels are not receipts. A + maintained adapter that successfully parses the live composer may separately + prove `NotRetained`; this is never delivery and releases only an already + archived staged head. Unread, unreadable, unrecognized, and ambiguous attempts + retain staged ownership and retry by inspection without re-pasting. ## Evidence diff --git a/docs/vrs/01-ding/spec.md b/docs/vrs/01-ding/spec.md index 025d3234..3d432022 100644 --- a/docs/vrs/01-ding/spec.md +++ b/docs/vrs/01-ding/spec.md @@ -34,12 +34,14 @@ record ownership ─► combined transport (paste ─► 0.5s ─► Return) ─ └──────────────────────────────────────────────► Staged receipt ─┬─ Accepted ──────────────────────────────────────────────► Delivered - └─ RetainedSafe / RetainedBlocked / Unproven ────────────► Staged + └─ RetainedSafe / RetainedBlocked / NotRetained / Unproven ► Staged staged retry ─► receipt ─┬─ Accepted ──────────────────────────────► Delivered ├─ RetainedSafe ─► final receipt ─┬─ Accepted ─► Delivered │ ├─ RetainedSafe ─► Return ─► receipt │ └─ other ────────► Staged + ├─ NotRetained + archived ────────────────► release head + ├─ NotRetained + unread ──────────────────► Staged └─ RetainedBlocked / Unproven ─────────────► Staged ``` @@ -56,7 +58,7 @@ 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 +bounded observation loop asks the selected harness adapter for one of five states: | Receipt state | Meaning | @@ -64,14 +66,19 @@ states: | `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 | +| `NotRetained` | A maintained adapter parsed the live composer and positively proved that the exact notice is neither its complete contents nor an accepted submission | | `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. +observation timeout never become delivery. `NotRetained` requires successful +parsing by a maintained adapter; missing or unrecognized composer evidence stays +`Unproven`. 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. `NotRetained` releases ownership +only when the notice is already archived; an unread notice remains staged. +`RetainedBlocked`, `NotRetained`, and `Unproven` send no input. No retry +re-pastes. ## Harness dispatch @@ -126,13 +133,16 @@ the original text either lost one inter-word space or split a token, so each boundary yields exactly two candidates. Comparison against the expected notice succeeds if any candidate matches exactly. The bounded notice length keeps the candidate set small; an unfamiliar multiline shape yields no match and fails -closed. +closed: it is `Ambiguous` before submission and `Unproven` after transport, +never positive `NotRetained` evidence. ## Retry and suppression Deferred notices retain FIFO order and retry on a bounded backoff, so an indefinitely occupied composer cannot spawn a terminal probe per inbox poll -(`DING-R08`). Archive receipts remove pending notices without another attempt. +(`DING-R08`). A staged archived head advances FIFO only after a maintained +adapter positively observes `NotRetained`; an unread head does not release. +Archive receipts remove pending notices that do not already own a transport. Declared `busy` never suppresses delivery; only fresh `dnd` defers it (`DING-R09`). diff --git a/src/ding/composer.rs b/src/ding/composer.rs index 24a696b9..29202046 100644 --- a/src/ding/composer.rs +++ b/src/ding/composer.rs @@ -18,20 +18,35 @@ pub(super) enum ComposerState { Ambiguous, } +/// Logical inputs reconstructed from renderer-proven boundaries, or an unsupported shape. +pub(super) enum SoftWrapCandidates { + Proven(Vec), + Unsupported, +} + +impl SoftWrapCandidates { + pub(super) fn proven(&self) -> Option<&[String]> { + match self { + Self::Proven(candidates) => Some(candidates), + Self::Unsupported => None, + } + } +} + /// Enumerate the two logical strings possible at each renderer-shaped soft-wrap row: the TUI either /// 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. +/// unfamiliar multiline input is unsupported rather than positive mismatch evidence. pub(super) fn logical_soft_wrap_candidates( input: &str, minimum_first_content_chars: usize, -) -> Vec { +) -> SoftWrapCandidates { let rows: Vec<&str> = input.lines().collect(); let Some(first) = rows.first() else { - return vec![String::new()]; + return SoftWrapCandidates::Proven(vec![String::new()]); }; if rows.len() == 1 { - return vec![(*first).to_string()]; + return SoftWrapCandidates::Proven(vec![(*first).to_string()]); } let mut candidates = vec![(*first).to_string()]; let mut previous = *first; @@ -41,13 +56,13 @@ pub(super) fn logical_soft_wrap_candidates( || !row.starts_with(" ") || row.trim().is_empty() { - return vec![input.to_string()]; + return SoftWrapCandidates::Unsupported; } let continuation = row.strip_prefix(" ").expect("prefix checked").trim_end(); let mut next = Vec::with_capacity(candidates.len().saturating_mul(2).min(32)); for candidate in candidates { if next.len() >= 32 { - return vec![input.to_string()]; + return SoftWrapCandidates::Unsupported; } next.push(format!("{candidate}{continuation}")); next.push(format!("{candidate} {continuation}")); @@ -55,7 +70,7 @@ pub(super) fn logical_soft_wrap_candidates( candidates = next; previous = row; } - candidates + SoftWrapCandidates::Proven(candidates) } pub(super) fn looks_like_choice_menu(plain: &str) -> bool { diff --git a/src/ding/harness/claude.rs b/src/ding/harness/claude.rs index a298c302..31931640 100644 --- a/src/ding/harness/claude.rs +++ b/src/ding/harness/claude.rs @@ -2,7 +2,9 @@ //! footer below it. use super::{Harness, Located, ReceiptState, Screen, screen_has_accepted_notice}; -use crate::ding::composer::{ComposerState, logical_soft_wrap_candidates, looks_like_choice_menu}; +use crate::ding::composer::{ + ComposerState, SoftWrapCandidates, logical_soft_wrap_candidates, looks_like_choice_menu, +}; pub(super) struct Claude; @@ -22,6 +24,9 @@ impl Harness for Claude { let Some((_, logical_inputs, footer)) = located_bottom_claude_composer(screen.plain) else { return ReceiptState::Unproven; }; + let Some(logical_inputs) = logical_inputs.proven() 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])); @@ -34,16 +39,19 @@ impl Harness for Claude { } else if placeholder && screen_has_accepted_notice(screen, '❯', expected) { ReceiptState::Accepted } else { - ReceiptState::Unproven + ReceiptState::NotRetained } } } fn classify_claude_composer( plain: &str, - (logical_inputs, footer): (Vec, String), + (logical_inputs, footer): (SoftWrapCandidates, String), expected: &str, ) -> ComposerState { + let Some(logical_inputs) = logical_inputs.proven() else { + return ComposerState::Ambiguous; + }; let exact = logical_inputs.iter().any(|input| input == expected); // An empty composer is a stronger positive-empty proof than the placeholder below, because no // human draft can be empty. Claude only shows the rotating placeholder on an unused pane. @@ -85,7 +93,7 @@ fn is_claude_idle_placeholder(input: &str) -> bool { /// row Claude may either wrap at a discarded space or split a token, so each proven boundary has /// exactly two candidates: join with one space or with none. The bounded DING length keeps this set /// small; any unfamiliar multiline shape fails closed. -fn located_bottom_claude_composer(plain: &str) -> Option<(usize, Vec, String)> { +fn located_bottom_claude_composer(plain: &str) -> Option<(usize, SoftWrapCandidates, String)> { let lines: Vec<&str> = plain.lines().collect(); let separators: Vec = lines .iter() diff --git a/src/ding/harness/codex.rs b/src/ding/harness/codex.rs index d46b1ad8..838eca70 100644 --- a/src/ding/harness/codex.rs +++ b/src/ding/harness/codex.rs @@ -32,17 +32,19 @@ impl Harness for Codex { CodexComposer::Empty if screen_has_accepted_notice(screen, '›', expected) => { ReceiptState::Accepted } - CodexComposer::Empty => ReceiptState::Unproven, + CodexComposer::Empty => ReceiptState::NotRetained, CodexComposer::Typed(input) => { - let exact = logical_soft_wrap_candidates(&input, 70) - .iter() - .any(|input| input == expected); + let candidates = logical_soft_wrap_candidates(&input, 70); + let Some(candidates) = candidates.proven() else { + return ReceiptState::Unproven; + }; + let exact = candidates.iter().any(|input| input == expected); if exact && !blocked && idle_footer { ReceiptState::RetainedSafe } else if exact { ReceiptState::RetainedBlocked } else { - ReceiptState::Unproven + ReceiptState::NotRetained } } } @@ -77,9 +79,11 @@ fn classify_codex_composer(screen: &str, plain: &str, expected: &str) -> Compose CodexComposer::Empty if !blocked && idle_footer => ComposerState::EmptySafe, CodexComposer::Empty => ComposerState::Ambiguous, CodexComposer::Typed(input) => { - let exact = logical_soft_wrap_candidates(&input, 70) - .iter() - .any(|input| input == expected); + let candidates = logical_soft_wrap_candidates(&input, 70); + let Some(candidates) = candidates.proven() else { + return ComposerState::Ambiguous; + }; + let exact = candidates.iter().any(|input| input == expected); if exact && !blocked && idle_footer { ComposerState::ExactSafe } else if exact { diff --git a/src/ding/harness/mod.rs b/src/ding/harness/mod.rs index 6971df2a..fc08265b 100644 --- a/src/ding/harness/mod.rs +++ b/src/ding/harness/mod.rs @@ -27,6 +27,9 @@ pub(super) enum ReceiptState { RetainedSafe, /// The exact notice remains the complete live composer, but the harness is active or blocked. RetainedBlocked, + /// A maintained harness parsed its live composer and positively proved that the exact notice + /// is neither retained as its complete contents nor visible as an accepted submission. + NotRetained, /// No positive acceptance or exact retained-composer state was proven. Unproven, } diff --git a/src/ding/mod.rs b/src/ding/mod.rs index 1777ea49..f4040f04 100644 --- a/src/ding/mod.rs +++ b/src/ding/mod.rs @@ -152,6 +152,9 @@ pub fn pty_delivery_args(session: &str, text: &str) -> Vec { pub enum PokeOutcome { Delivered, Staged, + /// A maintained adapter positively proved that the exact staged notice is absent. Queue state + /// decides whether an archive receipt makes that proof sufficient to relinquish ownership. + NotRetained, Deferred, } @@ -390,6 +393,7 @@ fn retry_staged_with_window( before_submit, observation_window, ), + ReceiptState::NotRetained => Ok(PokeOutcome::NotRetained), ReceiptState::RetainedBlocked | ReceiptState::Unproven => Ok(PokeOutcome::Staged), } } @@ -414,6 +418,7 @@ fn submit_retained_after_final_observation( match classify_receipt(&screen, text) { ReceiptState::Accepted => return Ok(PokeOutcome::Delivered), ReceiptState::RetainedSafe => {} + ReceiptState::NotRetained => return Ok(PokeOutcome::NotRetained), ReceiptState::RetainedBlocked | ReceiptState::Unproven => { return Ok(PokeOutcome::Staged); } @@ -666,6 +671,13 @@ impl PendingNotice { } } + fn is_archived(&self) -> bool { + match self { + Self::Recovery { in_inbox, .. } | Self::Message { in_inbox, .. } => !*in_inbox, + Self::Adopted { .. } => false, + } + } + fn adopted(text: String) -> Self { Self::Adopted { staged_text: Some(text), @@ -920,6 +932,13 @@ fn flush_pending( notice.set_staged_text(Some(text)); break; } + Ok(PokeOutcome::NotRetained) if was_staged && notice.is_archived() => { + pending.pop_front(); + } + Ok(PokeOutcome::NotRetained) => { + notice.set_staged_text(Some(text)); + break; + } Ok(PokeOutcome::Deferred) if was_staged => { // The exact owned payload disappeared or changed. Adopted startup text has the // generic recovery notice behind it, while unread ordinary work may make one later @@ -1825,7 +1844,7 @@ mod tests { &format!("ordinary transcript: {text}\r\n{}", idle_codex_screen()), text ), - ReceiptState::Unproven, + ReceiptState::NotRetained, "notice text outside an adapter-recognized accepted pattern is not a receipt" ); assert_eq!( @@ -1833,8 +1852,17 @@ mod tests { &format!("old receipt: {text}\r\n{}", human_codex_screen()), text ), + ReceiptState::NotRetained, + "a parsed changed live composer positively excludes the exact owned notice" + ); + assert_eq!( + classify_receipt(&idle_codex_screen(), text), + ReceiptState::NotRetained + ); + assert_eq!( + classify_receipt("unknown renderer", text), ReceiptState::Unproven, - "a changed live composer cannot be accepted from transcript evidence" + "an unrecognized screen cannot prove that the owned notice disappeared" ); assert_eq!( @@ -1853,7 +1881,43 @@ mod tests { &format!("ordinary transcript: {text}\r\n{}", idle_claude_screen()), text ), - ReceiptState::Unproven + ReceiptState::NotRetained + ); + assert_eq!( + classify_receipt(&idle_claude_screen(), text), + ReceiptState::NotRetained + ); + } + + #[test] + fn unsupported_composer_wraps_are_unproven_receipts() { + let text = "[DING] new st2 message: [id:abc123] receipt truth (from cos); check your inbox"; + let (first, continuation) = text.split_at(32); + let codex = format!( + "\x1b[1m›\x1b[1C\x1b[0m{first}\r\n {continuation}\r\n\r\n\ + \x1b[2C\x1b[0mgpt-5.6-sol xhigh · /workspace" + ); + let rule = claude_rule(); + let claude = format!( + "Claude Code v2.1.220\r\n{rule}\r\n❯\u{00a0}{first}\r\n {continuation}\r\n\ + {rule}\r\n⏵⏵ bypass permissions on (shift+tab to cycle)" + ); + + assert_eq!( + ( + classify_receipt(&codex, text), + classify_receipt(&claude, text), + ), + (ReceiptState::Unproven, ReceiptState::Unproven), + "unsupported wraps cannot prove that the notice disappeared" + ); + assert_eq!( + classify_receipt(&human_codex_screen(), text), + ReceiptState::NotRetained + ); + assert_eq!( + classify_receipt(&staged_claude_screen("a changed human composer"), text), + ReceiptState::NotRetained ); } @@ -1884,11 +1948,14 @@ mod tests { 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(), + for (screen, expected) in [ + ( + format!("Create a plan?\r\n{}", staged_codex_screen(text)), + PokeOutcome::Staged, + ), + (idle_codex_screen(), PokeOutcome::NotRetained), + (human_codex_screen(), PokeOutcome::NotRetained), + ("unknown renderer".to_string(), PokeOutcome::Staged), ] { let submits = RefCell::new(0); let outcome = retry_staged_with_window( @@ -1903,7 +1970,7 @@ mod tests { Duration::ZERO, ) .unwrap(); - assert_eq!(outcome, PokeOutcome::Staged); + assert_eq!(outcome, expected); assert_eq!( *submits.borrow(), 0, @@ -1931,6 +1998,35 @@ mod tests { assert_eq!(*submits.borrow(), 0); } + #[test] + fn staged_retry_keeps_unproven_and_retained_blocked_owned() { + use std::cell::RefCell; + + let text = "[DING] new st2 message: [id:abc123] retry truth (from cos); check your inbox"; + for screen in [ + "unknown renderer".to_string(), + format!("Create a plan?\r\n{}", staged_codex_screen(text)), + ] { + let submits = RefCell::new(0); + assert_eq!( + retry_staged_with_window( + text, + &mut || Ok(screen.clone()), + &mut || { + *submits.borrow_mut() += 1; + Ok(()) + }, + &mut || {}, + &mut || Ok(()), + Duration::ZERO, + ) + .unwrap(), + PokeOutcome::Staged + ); + assert_eq!(*submits.borrow(), 0); + } + } + #[test] fn pty_commands_have_a_real_outer_timeout() { let started = Instant::now(); @@ -2038,6 +2134,81 @@ mod tests { ); } + #[test] + fn archived_not_retained_releases_fifo_without_repasting_owned_notice() { + let agent = tempfile::tempdir().unwrap(); + let inbox = inbox_dir(agent.path()); + let archive = archive_dir(agent.path()); + let first = send_to_inbox(&inbox, "alice", Some("first"), None, &[], "one").unwrap(); + std::thread::sleep(Duration::from_millis(2)); + let second = send_to_inbox(&inbox, "bob", Some("second"), None, &[], "two").unwrap(); + let mut pending: VecDeque = message::list_inbox(&inbox) + .unwrap() + .into_iter() + .map(PendingNotice::message) + .collect(); + let first_text = pending[0].text(); + let second_text = pending[1].text(); + let poker = OwnershipPoker { + pokes: Mutex::new(Vec::new()), + retries: Mutex::new(Vec::new()), + poke_outcomes: Mutex::new(VecDeque::from([ + PokeOutcome::Staged, + PokeOutcome::Delivered, + ])), + retry_outcomes: Mutex::new(VecDeque::from([PokeOutcome::NotRetained])), + }; + + flush_pending(None, &mut pending, &poker); + archive_msg(&inbox, &archive, &first).unwrap(); + prune_archived_pending(&inbox, &mut pending); + flush_pending(None, &mut pending, &poker); + + assert!(pending.is_empty()); + assert_eq!( + poker.pokes.lock().unwrap().as_slice(), + [first_text.as_str(), second_text.as_str()] + ); + assert_eq!(poker.retries.lock().unwrap().as_slice(), [first_text]); + assert!(!inbox.join(first).exists()); + assert!(inbox.join(second).exists()); + } + + #[test] + fn unread_not_retained_keeps_fifo_ownership_without_repasting() { + let agent = tempfile::tempdir().unwrap(); + let inbox = inbox_dir(agent.path()); + send_to_inbox(&inbox, "alice", Some("first"), None, &[], "one").unwrap(); + std::thread::sleep(Duration::from_millis(2)); + send_to_inbox(&inbox, "bob", Some("second"), None, &[], "two").unwrap(); + let mut pending: VecDeque = message::list_inbox(&inbox) + .unwrap() + .into_iter() + .map(PendingNotice::message) + .collect(); + let first_text = pending[0].text(); + let poker = OwnershipPoker { + pokes: Mutex::new(Vec::new()), + retries: Mutex::new(Vec::new()), + poke_outcomes: Mutex::new(VecDeque::from([ + PokeOutcome::Staged, + PokeOutcome::Delivered, + ])), + retry_outcomes: Mutex::new(VecDeque::from([PokeOutcome::NotRetained])), + }; + + flush_pending(None, &mut pending, &poker); + flush_pending(None, &mut pending, &poker); + + assert_eq!(pending.len(), 2); + assert_eq!(pending[0].staged_text(), Some(first_text.as_str())); + assert_eq!( + poker.pokes.lock().unwrap().as_slice(), + [first_text.as_str()] + ); + assert_eq!(poker.retries.lock().unwrap().as_slice(), [first_text]); + } + #[test] fn pending_delivery_ignores_busy_but_respects_fresh_dnd_archive_and_retry() { let agent = tempfile::tempdir().unwrap();