From 6eec9dac4b2547866f7aa7561170b74fa134f52c Mon Sep 17 00:00:00 2001 From: w-seller-memory-guard Date: Tue, 8 Sep 2026 11:09:28 -0700 Subject: [PATCH 1/7] seller memory: truncate an over-budget MEMORY.md and warn, instead of dropping it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An index one byte over MAX_MEMORY_INDEX_BYTES used to be refused with InvalidData and the job ran with no memory at all — a specialized seat silently became a generalist, with one per-job log line as the only signal. Now `read_on_start` cuts at the last complete line at or before the budget (never mid-line, never mid-UTF-8-char; a single over-long line is cut at the nearest lower char boundary), appends a marker line inside the injected text, and the whole injected index incl. marker stays <= the budget because the marker's bytes are reserved before the cut. The result is Ok(Some(..)), never an error. - `job_memory_section` keeps its degrade contract (Option, never propagates, never fails a job) and now logs a truncation warning that names the real byte count, the budget and the file path. - Boot warns once, in the shape of `unreachable_seat_warning`, when memory_enabled and the index is over budget. Read-only: never creates memory/. - New `inspect_index` / `IndexState` for the operator surfaces (boot and the doctor check to follow). - Doc comments stating the old REFUSED contract corrected. Tests: `read_on_start_refuses_index_over_size_bound` rewritten as `read_on_start_truncates_index_over_size_bound` (one byte over is truncated, still injects, marker present, <= budget); `an_over_budget_index_degrades_instead_of_blocking_the_job` now asserts the prompt CONTAINS the surviving head plus the marker. New: 3x-over line-boundary cut, multi-byte UTF-8 char-boundary cut, inspect_index states, boot/per-job warning wording. Exact-cap, golden invariant and never-creates tests untouched and green. --- crates/maxplayer-core/src/seller_memory.rs | 350 ++++++++++++++++-- crates/maxplayer-core/src/seller_node/run.rs | 140 ++++++- .../tests/seller_memory_read_on_start.rs | 78 ++-- 3 files changed, 501 insertions(+), 67 deletions(-) diff --git a/crates/maxplayer-core/src/seller_memory.rs b/crates/maxplayer-core/src/seller_memory.rs index 6c3e4adcf..3f7732f94 100644 --- a/crates/maxplayer-core/src/seller_memory.rs +++ b/crates/maxplayer-core/src/seller_memory.rs @@ -31,9 +31,11 @@ pub const MEMORY_DIR_NAME: &str = "memory"; /// The cost is prompt tokens on every job, paid by the seller who chose to write the file, so it is /// self-limiting. The section is appended LAST, so a larger block never pushes the buyer's task down. /// -/// An index over this bound is REFUSED at the injection site — [`read_on_start_section`] returns -/// `InvalidData` instead of inlining it, and the daemon degrades to running the job without memory -/// (the seam never blocks a job). +/// An index over this bound is TRUNCATED at the injection site — [`read_on_start_section`] inlines +/// the last complete line at or before the budget plus a marker line saying what was dropped, and +/// stays `Ok(Some(..))`, so the seat keeps its specialization head rather than silently running +/// every job as a generalist. The daemon warns on the console (per job, and once at boot), and +/// `maxplayer doctor` reports it; the seam never blocks a job. pub const MAX_MEMORY_INDEX_BYTES: usize = 64 * 1024; /// The index file loaded at job start. pub const MEMORY_INDEX_FILE: &str = "MEMORY.md"; @@ -150,15 +152,93 @@ fn render(template: &str, substitutions: &[(&str, &str)]) -> String { out } +/// What the injection site cut from an over-budget index. Carried out to the daemon so the console +/// warning can name the real numbers, and rendered INTO the injected text as a marker line so the +/// agent knows it is reading a fragment. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct IndexTruncation { + /// Bytes of `MEMORY.md` that reached the prompt (the marker line is on top of this). + pub shown_bytes: usize, + /// Bytes `MEMORY.md` actually holds on disk. + pub total_bytes: usize, +} + +/// The rendered read-on-start section plus what, if anything, was cut to fit it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReadOnStart { + /// The section text to inline into the job prompt. + pub section: String, + /// `Some` when the index was over [`MAX_MEMORY_INDEX_BYTES`] and its tail was dropped. + pub truncation: Option, +} + +/// The marker line appended inside a truncated index so the agent reads it as a fragment, never as +/// the whole file. Always ONE line: the line-boundary cut above it depends on that. +pub fn truncation_marker(shown_bytes: usize, total_bytes: usize) -> String { + format!( + "[maxplayer: MEMORY.md truncated to the {MAX_MEMORY_INDEX_BYTES}-byte injection budget — \ + {shown_bytes} of {total_bytes} bytes shown, tail dropped]" + ) +} + +/// Fit an index into [`MAX_MEMORY_INDEX_BYTES`]. An index at or under the budget comes back +/// trailing-trimmed and untouched. An index over it is cut at the LAST COMPLETE LINE at or before +/// the budget — never mid-line and never mid-UTF-8-character; a single line longer than the budget +/// is cut at the nearest lower char boundary — and [`truncation_marker`] is appended as the final +/// line. The marker's bytes are reserved BEFORE the cut, so the returned text is always +/// `<= MAX_MEMORY_INDEX_BYTES` including the marker. +pub fn fit_index_to_budget(index: &str) -> (String, Option) { + let total_bytes = index.len(); + if total_bytes <= MAX_MEMORY_INDEX_BYTES { + return (index.trim_end().to_owned(), None); + } + // Reserve the marker at its LONGEST: `shown <= total`, so a marker rendered with `total` in both + // slots has at least as many digits as the real one will. Plus one byte for the newline that + // joins the surviving head to the marker. + let reserve = truncation_marker(total_bytes, total_bytes).len() + 1; + let content_budget = MAX_MEMORY_INDEX_BYTES.saturating_sub(reserve); + let head = &index[..line_boundary_cut(index, content_budget)]; + let head = head.trim_end(); + let truncation = IndexTruncation { + shown_bytes: head.len(), + total_bytes, + }; + let marker = truncation_marker(truncation.shown_bytes, truncation.total_bytes); + let fitted = format!("{head}\n{marker}"); + debug_assert!(fitted.len() <= MAX_MEMORY_INDEX_BYTES); + (fitted, Some(truncation)) +} + +/// The byte offset to cut `text` at so the result is at most `budget` bytes: just after the last +/// newline at or before the budget when one exists (so the cut lands on a complete line), else the +/// nearest char boundary at or below the budget (a single line longer than the whole budget). +fn line_boundary_cut(text: &str, budget: usize) -> usize { + let budget = budget.min(text.len()); + let window = &text.as_bytes()[..budget]; + if let Some(newline) = window.iter().rposition(|&byte| byte == b'\n') { + // A newline at offset 0 would keep nothing; fall through to the char-boundary cut instead. + if newline > 0 { + return newline + 1; + } + } + let mut cut = budget; + while !text.is_char_boundary(cut) { + cut -= 1; + } + cut +} + /// Render the read-on-start memory section to inline into the job prompt, or `None` when there is /// no non-empty index to inline. `template_path` overrides the in-repo default (read-on-start seam). /// -/// An index over [`MAX_MEMORY_INDEX_BYTES`] is refused with `InvalidData` — never silently -/// injected — so a runaway `MEMORY.md` cannot bloat every job prompt unnoticed. -pub fn read_on_start_section( +/// An index over [`MAX_MEMORY_INDEX_BYTES`] is TRUNCATED, not refused: the surviving head plus a +/// marker line is injected (see [`fit_index_to_budget`]) and `truncation` reports what was cut, so +/// the daemon can warn where the operator will see it. This call never fails over size; the only +/// `Err` is an index that exists and cannot be read. +pub fn read_on_start( memory_dir: &Path, template_path: Option<&Path>, -) -> io::Result> { +) -> io::Result> { let index_path = memory_dir.join(MEMORY_INDEX_FILE); let index = match fs::read_to_string(&index_path) { Ok(text) if !text.trim().is_empty() => text, @@ -166,28 +246,76 @@ pub fn read_on_start_section( Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), Err(error) => return Err(error), }; - if index.len() > MAX_MEMORY_INDEX_BYTES { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - format!( - "memory index {} is {} bytes, over the {MAX_MEMORY_INDEX_BYTES}-byte injection \ - bound — refusing to inject; shorten MEMORY.md itself. Moving detail into linked \ - topic files only helps a HOST job: under a container policy the linked files are \ - outside the job's mount namespace, so this file's own content is all that loads", - index_path.display(), - index.len() - ), - )); - } + let (index, truncation) = fit_index_to_budget(&index); let template = load_template(template_path, DEFAULT_READ_ON_START_TEMPLATE); - let rendered = render( + let section = render( &template, &[ (TOKEN_MEMORY_DIR, memory_dir.display().to_string().as_str()), - (TOKEN_MEMORY_INDEX, index.trim_end()), + (TOKEN_MEMORY_INDEX, index.as_str()), ], ); - Ok(Some(rendered)) + Ok(Some(ReadOnStart { + section, + truncation, + })) +} + +/// [`read_on_start`] without the truncation report — the rendered section alone. +pub fn read_on_start_section( + memory_dir: &Path, + template_path: Option<&Path>, +) -> io::Result> { + read_on_start(memory_dir, template_path).map(|read| read.map(|read| read.section)) +} + +/// What the operator surfaces (boot warning, `maxplayer doctor`) see when they look at the index. +/// Read-only: inspecting never creates `memory/` or anything in it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IndexState { + /// No `memory/` directory at all — the state of nearly every seat. Nothing to say. + NoMemoryDir, + /// `memory/` exists but holds no `MEMORY.md`: the seat injects nothing. + NoIndex, + /// `MEMORY.md` exists but is empty or whitespace: the seat injects nothing. + Empty, + /// The index fits the budget and is injected whole. + Fits { bytes: usize }, + /// The index is over [`MAX_MEMORY_INDEX_BYTES`]: every job prompt gets a truncated copy. + OverBudget { bytes: usize }, +} + +impl IndexState { + /// Bytes still available under the budget for a fitting index (`None` otherwise). + pub fn headroom_bytes(self) -> Option { + match self { + IndexState::Fits { bytes } => Some(MAX_MEMORY_INDEX_BYTES - bytes), + _ => None, + } + } +} + +/// Inspect the index at `memory_dir` for the operator surfaces. Reads only; a missing directory or +/// file is a state, not an error, and the only `Err` is an index that exists and cannot be read. +pub fn inspect_index(memory_dir: &Path) -> io::Result { + if !memory_dir.is_dir() { + return Ok(IndexState::NoMemoryDir); + } + let index_path = memory_dir.join(MEMORY_INDEX_FILE); + let index = match fs::read_to_string(&index_path) { + Ok(text) => text, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(IndexState::NoIndex), + Err(error) => return Err(error), + }; + if index.trim().is_empty() { + return Ok(IndexState::Empty); + } + let bytes = index.len(); + if bytes > MAX_MEMORY_INDEX_BYTES { + Ok(IndexState::OverBudget { bytes }) + } else { + Ok(IndexState::Fits { bytes }) + } } /// Compose the retro/distiller prompt (retro seam). `template_path` overrides the in-repo default. @@ -367,28 +495,182 @@ mod tests { let _ = fs::remove_dir_all(&root); } - /// An index a single byte over [`MAX_MEMORY_INDEX_BYTES`] is REFUSED (`InvalidData`), not - /// silently injected. This test goes red if the bound check is removed — the call would - /// then return `Ok(Some(..))`. + /// A template that is the bare `{memory_index}` token, so the rendered section IS the injected + /// index text and its length can be held against the budget directly. + fn bare_index_template(root: &Path) -> PathBuf { + let template = root.join("bare-index.tmpl"); + fs::write(&template, TOKEN_MEMORY_INDEX).expect("write bare template"); + template + } + + /// The injected index text of a rendered bare-template section, split into the surviving head + /// and the marker line (the marker is always the LAST line). + fn split_head_and_marker(injected: &str) -> (&str, &str) { + injected + .rsplit_once('\n') + .expect("a truncated index is at least head + marker line") + } + + /// An index a single byte over [`MAX_MEMORY_INDEX_BYTES`] is TRUNCATED and still injected — never + /// refused, never dropped. The property this protects is unchanged from the refusal it replaces: + /// a runaway `MEMORY.md` cannot bloat every job prompt, because the injected text (marker + /// included) stays within the budget. This goes red if the bound check is removed — the injected + /// text would then exceed the budget and carry no marker. #[test] - fn read_on_start_refuses_index_over_size_bound() { + fn read_on_start_truncates_index_over_size_bound() { let root = temp_dir("ros-overbound"); let dir = memory_dir(&root); fs::create_dir_all(&dir).expect("mkdir"); let oversized = "x".repeat(MAX_MEMORY_INDEX_BYTES + 1); fs::write(dir.join(MEMORY_INDEX_FILE), &oversized).expect("write index"); + let template = bare_index_template(&root); + + let read = read_on_start(&dir, Some(&template)) + .expect("an over-bound index is not an error") + .expect("and it still injects"); + let truncation = read.truncation.expect("the read reports what it cut"); + assert_eq!(truncation.total_bytes, MAX_MEMORY_INDEX_BYTES + 1, "the real size is reported"); + assert!( + read.section.len() <= MAX_MEMORY_INDEX_BYTES, + "injected text incl. marker is {} bytes, over the {MAX_MEMORY_INDEX_BYTES} budget", + read.section.len() + ); + let (head, marker) = split_head_and_marker(&read.section); + assert_eq!(head.len(), truncation.shown_bytes, "shown_bytes is the surviving head"); + assert!( + head.chars().all(|c| c == 'x') && !head.is_empty(), + "the head is the file's own text" + ); + assert_eq!(marker, truncation_marker(truncation.shown_bytes, truncation.total_bytes)); + assert!(marker.contains(&MAX_MEMORY_INDEX_BYTES.to_string()), "marker names the budget"); + assert!( + marker.contains(&(MAX_MEMORY_INDEX_BYTES + 1).to_string()), + "marker names the actual size: {marker}" + ); + // The section-only wrapper sees the same text. + assert_eq!( + read_on_start_section(&dir, Some(&template)).expect("read").as_deref(), + Some(read.section.as_str()) + ); + let _ = fs::remove_dir_all(&root); + } - let error = read_on_start_section(&dir, None).expect_err("over-bound index must be refused"); - assert_eq!(error.kind(), io::ErrorKind::InvalidData); - let message = error.to_string(); + /// A 3x-over index: the cut lands on a LINE boundary (the last content line is a complete fixture + /// line), the marker is present and last, and the whole injected text fits the budget. + #[test] + fn read_on_start_cuts_a_3x_over_index_on_a_line_boundary_within_budget() { + let root = temp_dir("ros-3x"); + let dir = memory_dir(&root); + fs::create_dir_all(&dir).expect("mkdir"); + // Every fixture line ends in a sentinel so a mid-line cut is detectable. + let mut index = String::from("# Memory\n\nAcme brand: headings in Söhne.|\n"); + let mut n = 0usize; + while index.len() < 3 * MAX_MEMORY_INDEX_BYTES { + index.push_str(&format!( + "- topic line {n:06}: durable lesson text, kept short on purpose |\n" + )); + n += 1; + } + fs::write(dir.join(MEMORY_INDEX_FILE), &index).expect("write index"); + let template = bare_index_template(&root); + + let read = read_on_start(&dir, Some(&template)) + .expect("read") + .expect("injects"); + let truncation = read.truncation.expect("truncated"); + assert_eq!(truncation.total_bytes, index.len()); assert!( - message.contains(&MAX_MEMORY_INDEX_BYTES.to_string()), - "error names the bound: {message}" + read.section.len() <= MAX_MEMORY_INDEX_BYTES, + "3x-over input must render to <= budget incl. marker, got {}", + read.section.len() ); + let (head, marker) = split_head_and_marker(&read.section); assert!( - message.contains(&(MAX_MEMORY_INDEX_BYTES + 1).to_string()), - "error names the actual size: {message}" + head.starts_with("# Memory\n\nAcme brand: headings in Söhne.|"), + "head is the file's start" ); + assert!( + head.ends_with('|'), + "cut is on a line boundary — the last kept line is complete: {:?}", + &head[head.len().saturating_sub(80)..] + ); + assert!(index.starts_with(head), "the head is a prefix of the file"); + assert_eq!(marker, truncation_marker(truncation.shown_bytes, truncation.total_bytes)); + assert!(marker.starts_with("[maxplayer: MEMORY.md truncated"), "marker is the last line"); + // Most of the budget is used: a cut that threw away far more than one line is a bug. + assert!( + read.section.len() > MAX_MEMORY_INDEX_BYTES - 256, + "the cut left {} unused bytes under the budget", + MAX_MEMORY_INDEX_BYTES - read.section.len() + ); + let _ = fs::remove_dir_all(&root); + } + + /// A single-line multi-byte index over the budget is cut at a CHAR boundary: the result is a + /// valid `String` (slicing mid-character would panic), fits the budget, and keeps the marker. + #[test] + fn read_on_start_cuts_multibyte_utf8_on_a_char_boundary() { + let root = temp_dir("ros-utf8"); + let dir = memory_dir(&root); + fs::create_dir_all(&dir).expect("mkdir"); + // 3-byte characters, one line, no newline anywhere: every cut candidate but one in three is + // mid-character. Sized so the budget lands off a boundary whatever the marker length is. + let glyph = "日"; + assert_eq!(glyph.len(), 3); + let index: String = std::iter::repeat(glyph) + .take(MAX_MEMORY_INDEX_BYTES / 3 + 500) + .collect(); + assert!(index.len() > MAX_MEMORY_INDEX_BYTES); + fs::write(dir.join(MEMORY_INDEX_FILE), &index).expect("write index"); + let template = bare_index_template(&root); + + let read = read_on_start(&dir, Some(&template)) + .expect("read") + .expect("injects"); + let truncation = read.truncation.expect("truncated"); + assert!(read.section.len() <= MAX_MEMORY_INDEX_BYTES); + let (head, marker) = split_head_and_marker(&read.section); + assert!( + head.chars().all(|c| c == '日') && !head.is_empty(), + "head is whole characters only" + ); + assert_eq!(head.len() % 3, 0, "head length is a whole number of 3-byte chars"); + assert_eq!(head.len(), truncation.shown_bytes); + assert_eq!(marker, truncation_marker(truncation.shown_bytes, truncation.total_bytes)); + // Also directly: the pure fitter produces a String that round-trips as valid UTF-8 bytes. + let (fitted, _) = fit_index_to_budget(&index); + assert!(std::str::from_utf8(fitted.as_bytes()).is_ok()); + let _ = fs::remove_dir_all(&root); + } + + /// The operator-surface inspector reports each state and creates nothing. + #[test] + fn inspect_index_reports_every_state_and_creates_nothing() { + let root = temp_dir("inspect"); + let dir = memory_dir(&root); + assert_eq!(inspect_index(&dir).expect("no dir"), IndexState::NoMemoryDir); + assert!(!dir.exists(), "inspecting must not create memory/"); + + fs::create_dir_all(&dir).expect("mkdir"); + assert_eq!(inspect_index(&dir).expect("no index"), IndexState::NoIndex); + assert!(!dir.join(MEMORY_INDEX_FILE).exists(), "inspecting must not create MEMORY.md"); + + fs::write(dir.join(MEMORY_INDEX_FILE), " \n\t\n").expect("write blank"); + assert_eq!(inspect_index(&dir).expect("blank"), IndexState::Empty); + + fs::write(dir.join(MEMORY_INDEX_FILE), "# index\nline\n").expect("write small"); + let fits = inspect_index(&dir).expect("fits"); + assert_eq!(fits, IndexState::Fits { bytes: 13 }); + assert_eq!(fits.headroom_bytes(), Some(MAX_MEMORY_INDEX_BYTES - 13)); + + fs::write( + dir.join(MEMORY_INDEX_FILE), + "z".repeat(MAX_MEMORY_INDEX_BYTES + 7), + ) + .expect("write big"); + let over = inspect_index(&dir).expect("over"); + assert_eq!(over, IndexState::OverBudget { bytes: MAX_MEMORY_INDEX_BYTES + 7 }); + assert_eq!(over.headroom_bytes(), None); let _ = fs::remove_dir_all(&root); } diff --git a/crates/maxplayer-core/src/seller_node/run.rs b/crates/maxplayer-core/src/seller_node/run.rs index 660451994..9cff1cd5e 100644 --- a/crates/maxplayer-core/src/seller_node/run.rs +++ b/crates/maxplayer-core/src/seller_node/run.rs @@ -1888,11 +1888,14 @@ pub fn job_prompt( /// from this path would flip every existing seller from inert to injecting on its next job without /// any operator writing a word. Creating memory stays an operator act. /// -/// **It degrades and never propagates.** `read_on_start_section` REFUSES an index over -/// [`MAX_MEMORY_INDEX_BYTES`](crate::seller_memory::MAX_MEMORY_INDEX_BYTES) with `InvalidData`, and -/// an unreadable file is an error too. Neither may fail a job: this is diagnostic/economic context -/// that never feeds the pay gate, the journal or the receipt bind, so a job that would otherwise -/// have been delivered and PAID must not die over it. An error is logged and read as "no memory". +/// **It degrades and never propagates.** An index over +/// [`MAX_MEMORY_INDEX_BYTES`](crate::seller_memory::MAX_MEMORY_INDEX_BYTES) is TRUNCATED by +/// `read_on_start` — the surviving head plus a marker line is injected, and the cut is WARNED on +/// the console with the real byte count, the budget and the path, because a seat silently running +/// as a generalist is the failure this exists to make visible. An unreadable file is still an +/// error, and an error may never fail a job: this is diagnostic/economic context that never feeds +/// the pay gate, the journal or the receipt bind, so a job that would otherwise have been delivered +/// and PAID must not die over it. An error is logged and read as "no memory". pub fn job_memory_section( home_root: &std::path::Path, config: &crate::home::SellerMemoryConfig, @@ -1901,11 +1904,20 @@ pub fn job_memory_section( return None; } let dir = crate::seller_memory::memory_dir(home_root); - match crate::seller_memory::read_on_start_section( - &dir, - config.read_on_start_template_path.as_deref(), - ) { - Ok(section) => section, + match crate::seller_memory::read_on_start(&dir, config.read_on_start_template_path.as_deref()) { + Ok(Some(read)) => { + if let Some(truncation) = read.truncation { + opline!( + "{}", + memory_index_truncated_warning( + &dir.join(crate::seller_memory::MEMORY_INDEX_FILE), + truncation + ) + ); + } + Some(read.section) + } + Ok(None) => None, Err(error) => { opline!("seller node memory read skipped ({error}); running the job without memory"); None @@ -1913,6 +1925,45 @@ pub fn job_memory_section( } } +/// The per-job console line for a truncated index: the real size, the budget, and the path — the +/// three things an operator needs to act. Pure, so the wording is assertable. +fn memory_index_truncated_warning( + index_path: &std::path::Path, + truncation: crate::seller_memory::IndexTruncation, +) -> String { + format!( + "seller node WARNING: memory index {} is {} bytes, over the {}-byte injection budget — \ + injected the first {} bytes plus a truncation marker; the tail is dropped from this job's \ + prompt. Shorten MEMORY.md itself: under a container policy the topic files it links are \ + outside the job's mount namespace, so this file's own content is all that loads.", + index_path.display(), + truncation.total_bytes, + crate::seller_memory::MAX_MEMORY_INDEX_BYTES, + truncation.shown_bytes + ) +} + +/// The boot siren for a seat whose `MEMORY.md` is over the injection budget, in the shape of +/// [`unreachable_seat_warning`]: pure over what the inspector saw, the caller emits. A per-job log +/// line is not a surface anyone reads; the boot scroll is. `None` for every state that is not +/// over budget — a missing or empty index is `maxplayer doctor`'s to report, not boot's, because +/// nearly every seat has no memory dir and must not be nagged at every start. +fn memory_index_budget_warning( + index_path: &std::path::Path, + state: crate::seller_memory::IndexState, +) -> Option { + match state { + crate::seller_memory::IndexState::OverBudget { bytes } => Some(format!( + "seller node WARNING: memory index {} is {bytes} bytes, over the {}-byte injection \ + budget — every job prompt will get a TRUNCATED copy (the head that fits, plus a marker) \ + and the tail is dropped. Shorten MEMORY.md itself; `maxplayer doctor` reports this too.", + index_path.display(), + crate::seller_memory::MAX_MEMORY_INDEX_BYTES + )), + _ => None, + } +} + /// #591: how a job's delivery workdir is provisioned — a from-scratch empty repo, or a clone of a /// served contribution's pinned base at `base_oid` (the fork tip the agent extends). Pure over the /// stored pin so `execute_job`'s routing is unit-testable without a live node. @@ -3650,6 +3701,27 @@ pub async fn boot_advertising_only_proven( opline!("{warning}"); } + // Same surface, same reason: an over-budget `MEMORY.md` used to cost the seat its whole + // specialization with one per-job log line as the only signal. Now it is truncated per job, and + // said ONCE here where an operator watching the boot scroll will see it. READ-ONLY — this must + // never create `memory/` (see `job_memory_section`); an inspection error is reported, not fatal. + if home.config.seller_memory.memory_enabled { + let memory_dir = crate::seller_memory::memory_dir(&home.root); + let index_path = memory_dir.join(crate::seller_memory::MEMORY_INDEX_FILE); + match crate::seller_memory::inspect_index(&memory_dir) { + Ok(state) => { + if let Some(warning) = memory_index_budget_warning(&index_path, state) { + opline!("{warning}"); + } + } + Err(error) => opline!( + "seller node WARNING: memory index {} could not be read ({error}); jobs will run \ + without memory until it is readable", + index_path.display() + ), + } + } + // Take the home lock BEFORE anything reaches the relay. The publish below is the first thing this // path puts on the wire, and a second seller started on the same home used to reach it, announce // its identity, and only then fail the lock inside boot — leaving a kind-0 on the relay for a node @@ -9261,6 +9333,54 @@ mod tests { ); } + // THE MEMORY BUDGET SIREN. Fires on exactly the over-budget state, naming bytes, budget and + // path; stays silent for every other state, because boot must not nag the seats (nearly all) + // that have no memory dir — those are `maxplayer doctor`'s to report. The per-job line carries + // the same three facts plus how many bytes survived. + #[test] + fn the_memory_budget_warnings_name_bytes_budget_and_path() { + use crate::seller_memory::{IndexState, IndexTruncation, MAX_MEMORY_INDEX_BYTES}; + let path = std::path::Path::new("/seat/home/memory/MEMORY.md"); + let over = MAX_MEMORY_INDEX_BYTES + 4242; + + let boot = memory_index_budget_warning(path, IndexState::OverBudget { bytes: over }) + .expect("an over-budget index must warn at boot"); + assert!(boot.contains(&over.to_string()), "names the real size: {boot}"); + assert!(boot.contains(&MAX_MEMORY_INDEX_BYTES.to_string()), "names the budget: {boot}"); + assert!(boot.contains("/seat/home/memory/MEMORY.md"), "names the path: {boot}"); + assert!(boot.contains("TRUNCATED"), "says what happens to the prompt: {boot}"); + + for quiet in [ + IndexState::NoMemoryDir, + IndexState::NoIndex, + IndexState::Empty, + IndexState::Fits { bytes: 12 }, + IndexState::Fits { bytes: MAX_MEMORY_INDEX_BYTES }, + ] { + assert_eq!( + memory_index_budget_warning(path, quiet), + None, + "{quiet:?} must not warn at boot" + ); + } + + let per_job = memory_index_truncated_warning( + path, + IndexTruncation { shown_bytes: 65_000, total_bytes: over }, + ); + assert!(per_job.contains(&over.to_string()), "names the real size: {per_job}"); + assert!( + per_job.contains(&MAX_MEMORY_INDEX_BYTES.to_string()), + "names the budget: {per_job}" + ); + assert!(per_job.contains("65000"), "names what survived: {per_job}"); + assert!(per_job.contains("/seat/home/memory/MEMORY.md"), "names the path: {per_job}"); + assert!( + !per_job.contains("without memory"), + "the old 'running the job without memory' wording is gone: {per_job}" + ); + } + /// The fully-closed seat: the config an upgrading seller with no allowlist lands on. Written out /// rather than derived from `seller_cfg`, which deliberately ships an OPEN targeted surface. fn seller_cfg_closed() -> crate::home::SellerConfig { diff --git a/crates/maxplayer/tests/seller_memory_read_on_start.rs b/crates/maxplayer/tests/seller_memory_read_on_start.rs index 1fc2a753a..f208a00ed 100644 --- a/crates/maxplayer/tests/seller_memory_read_on_start.rs +++ b/crates/maxplayer/tests/seller_memory_read_on_start.rs @@ -22,8 +22,9 @@ //! `the_golden_invariant_holds_no_memory_is_byte_identical` FAIL; the other three pass. //! - make `job_memory_section` ignore `memory_enabled` //! ⇒ `a_disabled_config_injects_nothing` FAILS alone. -//! - make `job_memory_section` propagate the `InvalidData` error instead of degrading -//! ⇒ `an_over_budget_index_degrades_instead_of_blocking_the_job` FAILS alone (it panics). +//! - make `read_on_start` drop (or refuse) an over-budget index instead of truncating it +//! ⇒ `an_over_budget_index_degrades_instead_of_blocking_the_job` FAILS alone (no head, no +//! marker in the prompt); make it inject the file whole ⇒ FAILS alone (over budget). //! - `None => format!("{base}\n\n")` in `compose_agent_prompt`'s `match memory_section` //! ⇒ `the_golden_invariant_holds_no_memory_is_byte_identical` FAILS alone. //! @@ -33,7 +34,10 @@ //! fixed, then re-measured red. use maxplayer_core::home::SellerMemoryConfig; -use maxplayer_core::seller_memory::{MAX_MEMORY_INDEX_BYTES, MEMORY_INDEX_FILE, memory_dir}; +use maxplayer_core::seller_memory::{ + DEFAULT_READ_ON_START_TEMPLATE, MAX_MEMORY_INDEX_BYTES, MEMORY_INDEX_FILE, memory_dir, + truncation_marker, +}; use maxplayer_core::seller_node::run::{job_memory_section, job_prompt}; use maxplayer_core::seller_node::store::Offer; @@ -188,35 +192,63 @@ fn a_disabled_config_injects_nothing() { let _ = std::fs::remove_dir_all(&root); } -/// THE SEAM MUST NEVER BLOCK A JOB. An index over `MAX_MEMORY_INDEX_BYTES` is REFUSED with -/// `InvalidData` by `read_on_start_section` — deliberately, so a runaway file cannot bloat every -/// prompt. That refusal is an `io::Error`, and an error on this path must NOT propagate: the job -/// would otherwise fail over diagnostic context that never feeds the pay gate, the journal or the -/// receipt bind. It degrades to a normal, memory-free job instead. +/// THE SEAM MUST NEVER BLOCK A JOB. An index over `MAX_MEMORY_INDEX_BYTES` is TRUNCATED by +/// `read_on_start` — the head that fits, plus a marker line saying the tail was dropped — so a +/// runaway file cannot bloat every prompt, and a specialized seat is no longer silently degraded +/// to a generalist over one byte. Nothing on this path may propagate as an error: the job would +/// otherwise fail over diagnostic context that never feeds the pay gate, the journal or the receipt +/// bind. The job runs, and its prompt CONTAINS the surviving head of the index and the marker. #[test] fn an_over_budget_index_degrades_instead_of_blocking_the_job() { - let runaway = "x".repeat(MAX_MEMORY_INDEX_BYTES + 1); + // One byte over, as a MULTI-LINE file: the brand line up top is what must survive the cut. + let brand = "Acme brand: always set headings in Söhne, never centre body copy."; + let mut runaway = format!("# Memory\n\n{brand}\n"); + let mut n = 0usize; + while runaway.len() < MAX_MEMORY_INDEX_BYTES + 1 { + runaway.push_str(&format!("- lesson {n:06}: keep the buyer's task the subject of the reply\n")); + n += 1; + } + runaway.truncate(MAX_MEMORY_INDEX_BYTES + 1); // ASCII filler ⇒ safe to cut anywhere + assert_eq!(runaway.len(), MAX_MEMORY_INDEX_BYTES + 1); let root = home_with_index("over-budget", &runaway); - // Control: one byte under the bound DOES inject, so the refusal below is the size bound doing - // its job and not the read silently failing for some unrelated reason. + // Control: one byte under the bound injects WHOLE, with no marker, so the marker below is the + // size bound doing its job and not something every index gets. let ok_root = home_with_index("at-budget", &"y".repeat(MAX_MEMORY_INDEX_BYTES - 1)); + let whole = job_memory_section(&ok_root, &SellerMemoryConfig::default()) + .expect("control: an index just under the bound must still be injected"); assert!( - job_memory_section(&ok_root, &SellerMemoryConfig::default()).is_some(), - "control: an index just under the bound must still be injected" + !whole.contains(&truncation_marker(0, 0)[..30]), + "control: an index under the bound carries no truncation marker" ); - // No panic, no error type — just no memory. - let section = job_memory_section(&root, &SellerMemoryConfig::default()); - assert_eq!( - section, None, - "an over-budget index degrades to no memory rather than failing the job" + // No panic, no error type — and no longer no memory: the head reaches the job. + let section = job_memory_section(&root, &SellerMemoryConfig::default()) + .expect("an over-budget index is truncated and STILL injected, never dropped"); + let prompt = job_prompt(&offer(), GIT_REMOTE, DEADLINE, Some(section.as_str())); + assert!( + prompt.contains(brand), + "the surviving head of the index must reach the agent: {prompt}" ); - // And the job it would have run is exactly the job that runs today. - assert_eq!( - job_prompt(&offer(), GIT_REMOTE, DEADLINE, section.as_deref()), - job_prompt(&offer(), GIT_REMOTE, DEADLINE, None), - "the degraded job is byte-identical to a normal memory-free job" + assert!( + prompt.contains("[maxplayer: MEMORY.md truncated to the"), + "the agent must be told it is reading a fragment: {prompt}" + ); + assert!( + prompt.contains(&(MAX_MEMORY_INDEX_BYTES + 1).to_string()), + "the marker names the file's real size: {prompt}" + ); + // The memory-off prompt is still the byte-for-byte prefix: truncation only APPENDS less. + let baseline = job_prompt(&offer(), GIT_REMOTE, DEADLINE, None); + assert!(prompt.starts_with(&baseline), "the job is the normal job plus a (shorter) memory section"); + // And the injected index text itself is within budget: the section is framing + index, so it is + // bounded by the budget plus the default framing's own bytes (the template text and the memory + // dir path it substitutes in). + let framing = DEFAULT_READ_ON_START_TEMPLATE.len() + memory_dir(&root).display().to_string().len(); + assert!( + section.len() <= MAX_MEMORY_INDEX_BYTES + framing, + "truncated section is {} bytes; budget {MAX_MEMORY_INDEX_BYTES} + framing {framing}", + section.len() ); let _ = std::fs::remove_dir_all(&ok_root); From f64506fa9be010b8d5c8a5353840d6037b71099b Mon Sep 17 00:00:00 2001 From: w-seller-memory-guard Date: Tue, 8 Sep 2026 11:14:59 -0700 Subject: [PATCH 2/7] doctor: report the seller memory index state (`seller memory` check) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `maxplayer doctor` now has a "seller memory" row so the operator can ask, on demand, what every job prompt is actually starting with — the boot scroll only sirens the over-budget case, and a per-job log line is not a surface anyone reads. - PASS with the index's byte size AND the headroom left under MAX_MEMORY_INDEX_BYTES, so "how much more can I write" needs no source. - WARN over budget, in the boot siren's wording: every job prompt gets a TRUNCATED copy; fix hint says shorten MEMORY.md itself (linked topic files are outside a container job's mount namespace). - WARN when memory/ exists but MEMORY.md is missing or empty — a seat that started to specialize and stopped; both inject nothing, both name the path to write. - WARN (not FAIL) when MEMORY.md exists but cannot be read: the job path degrades to no-memory, and the check learned nothing about the content. - PASS, quietly, when memory_enabled = false or there is no memory dir (the state of nearly every seat). Read-only: never creates memory/. Never a FAIL: memory is a quality lever, not a money-path or containment invariant. Shape follows check_sandbox_image: a thin probe over seller_memory::inspect_index plus a pure, total fold (MemoryIndexProbe -> Check) so every verdict's wording is testable without a home. Registered in build_checks after seat reachability, reading config.seller_memory.memory_enabled and seller_memory::memory_dir(&home.root) — the SAME resolved home the job path and the boot siren read. Tests (doctor.rs): PASS bytes+headroom; WARN over budget with the truncation wording; WARN missing/empty index in an existing dir; PASS quiet when disabled (even over a planted over-budget file) or without a dir, asserting the dir is not created; WARN unreadable (a directory wearing the MEMORY.md name); fold-never-fails over every probe; and a RED-PROVE wiring test through build_checks on a bootstrapped home. `cargo test -p maxplayer --locked doctor::` 86 passed. --- crates/maxplayer/src/doctor.rs | 380 +++++++++++++++++++++++++++++++++ 1 file changed, 380 insertions(+) diff --git a/crates/maxplayer/src/doctor.rs b/crates/maxplayer/src/doctor.rs index df728f3d0..f2728279c 100644 --- a/crates/maxplayer/src/doctor.rs +++ b/crates/maxplayer/src/doctor.rs @@ -1704,6 +1704,122 @@ mod checks { ) } + // ---- Seller memory: the MEMORY.md index every job prompt starts with ---- + + const SELLER_MEMORY_CHECK: &str = "seller memory"; + + /// What the check saw when it looked for the seat's memory index. Wraps + /// [`IndexState`](maxplayer_core::seller_memory::IndexState) with the two cases the inspector + /// cannot express — injection switched off, and an index that exists but would not read — so + /// [`fold_seller_memory`] is total over everything the check can observe, and every verdict's + /// wording is testable without touching the filesystem. + #[derive(Debug, Clone, PartialEq, Eq)] + pub(super) enum MemoryIndexProbe { + /// `[seller_memory] memory_enabled = false`: the index is never consulted, so its state is + /// nobody's concern here. + Disabled, + /// The inspector answered. + Inspected(maxplayer_core::seller_memory::IndexState), + /// `MEMORY.md` exists and `read_to_string` on it failed (permissions, not UTF-8, a directory + /// wearing the name). Carries the error text. + Unreadable(String), + } + + /// The seat's `MEMORY.md` index is inlined into every job prompt at start, so a seat that thinks + /// it is specialized may be a generalist without anyone noticing: an over-budget index is + /// TRUNCATED per job (the head that fits, plus a marker), an absent or empty one injects nothing. + /// Boot says the over-budget case once in its scroll; this is where the operator asks on demand + /// and gets every state, including the quiet ones. + /// + /// Advisory throughout — never a FAIL. Memory is a quality lever, not a money-path or + /// containment invariant, and nearly every seat has no memory dir at all: turning those red + /// would be noise. READ-ONLY, like every consumer of the index: this must never create + /// `memory/` or anything in it (see `seller_node::run::job_memory_section`). + pub(super) fn check_seller_memory(memory_enabled: bool, memory_dir: &Path) -> Check { + let index_path = memory_dir.join(maxplayer_core::seller_memory::MEMORY_INDEX_FILE); + if !memory_enabled { + return fold_seller_memory(&index_path, MemoryIndexProbe::Disabled); + } + let probe = match maxplayer_core::seller_memory::inspect_index(memory_dir) { + Ok(state) => MemoryIndexProbe::Inspected(state), + Err(error) => MemoryIndexProbe::Unreadable(error.to_string()), + }; + fold_seller_memory(&index_path, probe) + } + + /// Turn a [`MemoryIndexProbe`] into a Check. Pure, so each verdict — and the byte figures the + /// PASS and over-budget WARN carry — is assertable without a real home. + pub(super) fn fold_seller_memory(index_path: &Path, probe: MemoryIndexProbe) -> Check { + use maxplayer_core::seller_memory::{IndexState, MAX_MEMORY_INDEX_BYTES}; + let index = index_path.display(); + match probe { + MemoryIndexProbe::Disabled => Check::pass( + SELLER_MEMORY_CHECK, + "memory injection is off (`[seller_memory] memory_enabled = false`); MEMORY.md is \ + not consulted", + ), + // The state of nearly every seat; a seat that never asked for memory has nothing to fix. + MemoryIndexProbe::Inspected(IndexState::NoMemoryDir) => Check::pass( + SELLER_MEMORY_CHECK, + "no memory dir; jobs run without seat memory", + ), + // A dir without an index is a seat that STARTED to specialize and stopped — the operator + // (or the retro turn) meant for something to be here, so say that nothing is. + MemoryIndexProbe::Inspected(IndexState::NoIndex) => Check::warn( + SELLER_MEMORY_CHECK, + "memory dir exists but has no MEMORY.md — every job prompt injects nothing", + format!( + "write the index at {index}, or remove the memory dir if the seat is meant to \ + run without one" + ), + ), + MemoryIndexProbe::Inspected(IndexState::Empty) => Check::warn( + SELLER_MEMORY_CHECK, + format!("MEMORY.md at {index} is empty — every job prompt injects nothing"), + "put the distilled index in MEMORY.md, or remove the memory dir if the seat is meant \ + to run without one", + ), + MemoryIndexProbe::Inspected(state @ IndexState::Fits { bytes }) => { + // `headroom_bytes` is Some for every Fits by construction; the fallback is never hit + // and exists only so this arm cannot panic if the enum grows. + let headroom = state.headroom_bytes().unwrap_or(0); + Check::pass( + SELLER_MEMORY_CHECK, + format!( + "MEMORY.md at {index} is {bytes} bytes, injected whole; {headroom} bytes of \ + headroom under the {MAX_MEMORY_INDEX_BYTES}-byte injection budget" + ), + ) + } + // Same wording as the boot siren in `seller_node::run::memory_index_budget_warning`, + // because the operator following that siren's "doctor reports this too" must find the + // same fact here, not a differently phrased one. + MemoryIndexProbe::Inspected(IndexState::OverBudget { bytes }) => Check::warn( + SELLER_MEMORY_CHECK, + format!( + "MEMORY.md at {index} is {bytes} bytes, over the {MAX_MEMORY_INDEX_BYTES}-byte \ + injection budget — every job prompt gets a TRUNCATED copy (the head that fits, \ + plus a marker) and the tail is dropped" + ), + format!( + "shorten MEMORY.md itself to under {MAX_MEMORY_INDEX_BYTES} bytes; under a \ + container policy the topic files it links are outside the job's mount \ + namespace, so this file's own content is all that loads" + ), + ), + // WARN, not FAIL: the job path degrades to no-memory and never fails on this, and the + // check learned nothing about the index's content — only that it could not read it. + MemoryIndexProbe::Unreadable(error) => Check::warn( + SELLER_MEMORY_CHECK, + format!( + "MEMORY.md at {index} exists but could not be read ({error}); jobs run without \ + memory until it is readable" + ), + format!("make {index} a readable UTF-8 file owned by the seat's user"), + ), + } + } + /// Containment, for a seat strangers can reach — which means executing code they posted. /// `check_sandbox_launcher` above answers "does the launcher resolve", a property one layer out /// from this one: bubblewrap resolves on Ubuntu 24.04 and then fails at spawn on the AppArmor @@ -2313,6 +2429,10 @@ fn build_checks( // Home/wallet perms are verified against the SAME resolved home the rest of the gate inspects. let perms_home_root = home.root.clone(); let perms_wallet_dir = home.wallet_dir.clone(); + // The memory index is read from the SAME resolved home boot reads it from (`job_memory_section` + // and the boot siren both go through `seller_memory::memory_dir(&home.root)`). + let memory_enabled = home.config.seller_memory.memory_enabled; + let memory_dir = maxplayer_core::seller_memory::memory_dir(&home.root); // Harness credentials live under the operator $HOME, not the seat home. Empty HOME is // carried as None — never guessed as a relative `.claude`. let user_home = user_home_dir(); @@ -2376,6 +2496,12 @@ fn build_checks( // opposite questions — containment asks how dangerous an incoming job is, this asks whether any // can arrive — and a seat with no way in is silently healthy on every other check here. checks.push(Box::new(move || checks::check_seat_reachability(exposure))); + // What every job prompt starts with. An over-budget MEMORY.md is truncated per job, an absent or + // empty one injects nothing, and either way the seat quietly runs as a generalist — boot sirens + // only the over-budget case, this reports every state. Advisory; never blocks boot. + checks.push(Box::new(move || { + checks::check_seller_memory(memory_enabled, &memory_dir) + })); // Verifies the owner-only invariant `home::bootstrap` enforces at creation hasn't drifted (#473): // WARN for a seat only its named buyers reach, FAIL for one strangers reach. checks.push(Box::new(move || { @@ -4463,6 +4589,260 @@ mod tests { std::fs::remove_dir_all(&tmp).ok(); } + // ---- Seller memory: every state of the MEMORY.md index, and the boot-gate wiring ---- + + /// A fresh scratch dir for one seller-memory test; the caller removes it. + fn seller_memory_scratch(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "maxplayer-doctor-seller-memory-{tag}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).expect("create scratch dir"); + dir + } + + // PASS with the figures an operator needs: the index's byte size AND the headroom left under the + // budget, so "how much more can I write" is answered without reading source. + #[test] + fn doctor_seller_memory_passes_with_bytes_and_headroom() { + use maxplayer_core::seller_memory::{MAX_MEMORY_INDEX_BYTES, MEMORY_INDEX_FILE}; + let home = seller_memory_scratch("fits"); + let memory_dir = maxplayer_core::seller_memory::memory_dir(&home); + std::fs::create_dir_all(&memory_dir).unwrap(); + let index = "# MEMORY\n- [rust](rust.md) — prefers edition 2021\n"; + std::fs::write(memory_dir.join(MEMORY_INDEX_FILE), index).unwrap(); + + let check = checks::check_seller_memory(true, &memory_dir); + assert_eq!(check.status, Status::Pass, "{}", check.render()); + assert_eq!(check.name, "seller memory"); + assert!( + check.detail.contains(&format!("is {} bytes", index.len())), + "PASS must state the index size; got: {}", + check.detail + ); + assert!( + check.detail.contains(&format!( + "{} bytes of headroom", + MAX_MEMORY_INDEX_BYTES - index.len() + )), + "PASS must state the headroom under the budget; got: {}", + check.detail + ); + assert!( + check + .detail + .contains(&format!("{MAX_MEMORY_INDEX_BYTES}-byte")), + "PASS must name the budget; got: {}", + check.detail + ); + assert!( + !check.render().contains("fix:"), + "a PASS carries no fix hint" + ); + std::fs::remove_dir_all(&home).ok(); + } + + // WARN (never FAIL) on an over-budget index: names the real size, the budget, and that every job + // prompt gets a TRUNCATED copy — the same fact the boot siren points the operator here for. + #[test] + fn doctor_seller_memory_warns_over_budget_with_truncation_wording() { + use maxplayer_core::seller_memory::{MAX_MEMORY_INDEX_BYTES, MEMORY_INDEX_FILE}; + let home = seller_memory_scratch("over"); + let memory_dir = maxplayer_core::seller_memory::memory_dir(&home); + std::fs::create_dir_all(&memory_dir).unwrap(); + let over = MAX_MEMORY_INDEX_BYTES + 1; + let mut index = "x".repeat(over - 1); + index.push('\n'); + assert_eq!(index.len(), over); + std::fs::write(memory_dir.join(MEMORY_INDEX_FILE), &index).unwrap(); + + let check = checks::check_seller_memory(true, &memory_dir); + assert_eq!( + check.status, + Status::Warn, + "over budget is advisory: {}", + check.render() + ); + assert!( + check.detail.contains(&format!("is {over} bytes")), + "{}", + check.detail + ); + assert!( + check + .detail + .contains(&format!("over the {MAX_MEMORY_INDEX_BYTES}-byte")), + "{}", + check.detail + ); + assert!(check.detail.contains("TRUNCATED"), "{}", check.detail); + let rendered = check.render(); + assert!(rendered.contains("(fix: shorten MEMORY.md"), "{rendered}"); + std::fs::remove_dir_all(&home).ok(); + } + + // WARN when the seat started to specialize and stopped: a memory dir with no MEMORY.md, or an + // empty one. Both inject nothing, and both say so with the path to write. + #[test] + fn doctor_seller_memory_warns_on_missing_or_empty_index_in_an_existing_dir() { + use maxplayer_core::seller_memory::MEMORY_INDEX_FILE; + let home = seller_memory_scratch("noindex"); + let memory_dir = maxplayer_core::seller_memory::memory_dir(&home); + std::fs::create_dir_all(&memory_dir).unwrap(); + + let no_index = checks::check_seller_memory(true, &memory_dir); + assert_eq!(no_index.status, Status::Warn, "{}", no_index.render()); + assert!( + no_index.detail.contains("has no MEMORY.md"), + "{}", + no_index.detail + ); + assert!( + no_index.detail.contains("injects nothing"), + "{}", + no_index.detail + ); + assert!( + no_index + .render() + .contains(&memory_dir.join(MEMORY_INDEX_FILE).display().to_string()), + "the fix must name where to write the index: {}", + no_index.render() + ); + + std::fs::write(memory_dir.join(MEMORY_INDEX_FILE), " \n\n\t\n").unwrap(); + let empty = checks::check_seller_memory(true, &memory_dir); + assert_eq!(empty.status, Status::Warn, "{}", empty.render()); + assert!(empty.detail.contains("is empty"), "{}", empty.detail); + assert!(empty.detail.contains("injects nothing"), "{}", empty.detail); + std::fs::remove_dir_all(&home).ok(); + } + + // PASS, quietly, for the two states that are nobody's problem: injection switched off, and the + // state of nearly every seat — no memory dir at all. Neither may nag, and neither may CREATE the + // dir: the check is read-only like every other consumer of the index. + #[test] + fn doctor_seller_memory_is_quiet_when_disabled_or_without_a_memory_dir() { + let home = seller_memory_scratch("quiet"); + let memory_dir = maxplayer_core::seller_memory::memory_dir(&home); + assert!(!memory_dir.exists()); + + let no_dir = checks::check_seller_memory(true, &memory_dir); + assert_eq!(no_dir.status, Status::Pass, "{}", no_dir.render()); + assert!(no_dir.detail.contains("no memory dir"), "{}", no_dir.detail); + assert!(!memory_dir.exists(), "the check must never create memory/"); + + // Disabled wins over whatever is on disk: plant an over-budget index and it is not consulted. + std::fs::create_dir_all(&memory_dir).unwrap(); + std::fs::write( + memory_dir.join(maxplayer_core::seller_memory::MEMORY_INDEX_FILE), + "y".repeat(maxplayer_core::seller_memory::MAX_MEMORY_INDEX_BYTES + 10), + ) + .unwrap(); + let disabled = checks::check_seller_memory(false, &memory_dir); + assert_eq!(disabled.status, Status::Pass, "{}", disabled.render()); + assert!( + disabled.detail.contains("memory_enabled = false"), + "{}", + disabled.detail + ); + assert!( + !disabled.detail.contains("bytes"), + "disabled must not report on-disk figures" + ); + std::fs::remove_dir_all(&home).ok(); + } + + // WARN, not FAIL, when MEMORY.md exists but will not read — here a DIRECTORY wearing the name, + // which fails `read_to_string` on every platform without a chmod. The check learned nothing + // about the content, and the job path degrades to no-memory rather than failing, so it says so. + #[test] + fn doctor_seller_memory_warns_on_an_unreadable_index() { + use maxplayer_core::seller_memory::MEMORY_INDEX_FILE; + let home = seller_memory_scratch("unreadable"); + let memory_dir = maxplayer_core::seller_memory::memory_dir(&home); + std::fs::create_dir_all(memory_dir.join(MEMORY_INDEX_FILE)).unwrap(); + + let check = checks::check_seller_memory(true, &memory_dir); + assert_eq!(check.status, Status::Warn, "{}", check.render()); + assert!( + check.detail.contains("could not be read"), + "{}", + check.detail + ); + assert!( + check.detail.contains("jobs run without memory"), + "{}", + check.detail + ); + std::fs::remove_dir_all(&home).ok(); + } + + // The fold is total and pure: every probe value has a verdict and none of them is a FAIL — + // memory is advisory by design, and a future arm that FAILs would turn a working seat red. + #[test] + fn doctor_seller_memory_fold_never_fails() { + use checks::MemoryIndexProbe as P; + use maxplayer_core::seller_memory::IndexState as S; + let path = std::path::Path::new("/seat/memory/MEMORY.md"); + for probe in [ + P::Disabled, + P::Inspected(S::NoMemoryDir), + P::Inspected(S::NoIndex), + P::Inspected(S::Empty), + P::Inspected(S::Fits { bytes: 10 }), + P::Inspected(S::OverBudget { bytes: 1 << 20 }), + P::Unreadable("permission denied".into()), + ] { + let check = checks::fold_seller_memory(path, probe.clone()); + assert_ne!( + check.status, + Status::Fail, + "{probe:?} must stay advisory: {}", + check.render() + ); + assert_eq!(check.name, "seller memory"); + } + } + + // RED-PROVE (wiring): the seller memory check must be part of the boot-gate registry, or an + // operator running `maxplayer doctor` on a seat whose index is truncated every job sees nothing. + // Plant an over-budget MEMORY.md in a bootstrapped home and a "seller memory" WARN naming the + // real byte count must come out of `build_checks`. Drop the push → red. Network-free the same way + // the launcher wiring test is: unparseable relay_url, no mints. + #[cfg(feature = "wallet")] + #[test] + fn seller_memory_check_is_wired_into_the_boot_gate() { + use maxplayer_core::seller_memory::{MAX_MEMORY_INDEX_BYTES, MEMORY_INDEX_FILE}; + let tmp = seller_memory_scratch("wiring"); + let mut home = resolve_doctor_home(Some(tmp.clone())).expect("bootstrap the home"); + home.config.relay_url = "not-a-relay-url".into(); + home.config.accepted_mints = Vec::new(); + assert!( + home.config.seller_memory.memory_enabled, + "memory injection defaults on" + ); + let memory_dir = maxplayer_core::seller_memory::memory_dir(&home.root); + std::fs::create_dir_all(&memory_dir).unwrap(); + let over = MAX_MEMORY_INDEX_BYTES + 7; + std::fs::write(memory_dir.join(MEMORY_INDEX_FILE), "z".repeat(over)).unwrap(); + + let results = run_checks(build_checks(&home, false)); + assert!( + results.iter().any(|c| c.name == "seller memory" + && c.status == Status::Warn + && c.detail.contains(&format!("is {over} bytes"))), + "build_checks must run the seller memory check and WARN with the byte count; got: {:?}", + results.iter().map(Check::render).collect::>() + ); + + std::fs::remove_dir_all(&tmp).ok(); + } + // ---- Issue #217: doctor's agent verdict must equal boot's registry verdict ---- // A config where PATH-resolve (old doctor) and verbatim-resolve (boot) DISAGREE: an absolute From 117bf2c76ed4d063dd5dc326021b1eddb996143e Mon Sep 17 00:00:00 2001 From: w-seller-memory-guard Date: Tue, 8 Sep 2026 11:18:13 -0700 Subject: [PATCH 3/7] =?UTF-8?q?docs:=20SELLER-QUICKSTART=20=C2=A73d=20?= =?UTF-8?q?=E2=80=94=20seat=20memory=20(MEMORY.md),=20budget,=20truncation?= =?UTF-8?q?,=20doctor=20row?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New §3d after the sandbox section: what memory/ + MEMORY.md are and that the daemon never creates them (opt-in by writing the file); what loads at job start and why only the index's OWN content reaches a docker job (the linked topic files are outside the mount namespace); the [seller_memory] keys with defaults, and that retro_enabled is declared but no retro turn runs yet, so MEMORY.md is operator-written; the 64 KiB injection budget and that an over-budget index is TRUNCATED with a marker line, never dropped, with the exact marker and the boot/per-job/doctor warnings; a table of every `maxplayer doctor` "seller memory" row (advisory, never FAIL); and how to opt out. Acceptance checklist gains a memory line. Also: one clippy hit inside this branch's own test hunk (seller_memory.rs, manual_str_repeat/manual_repeat_n) → `glyph.repeat(..)`. The remaining `clippy -D warnings` sites are pre-existing base drift. --- crates/maxplayer-core/src/seller_memory.rs | 4 +- docs/SELLER-QUICKSTART.md | 64 ++++++++++++++++++++++ 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/crates/maxplayer-core/src/seller_memory.rs b/crates/maxplayer-core/src/seller_memory.rs index 3f7732f94..e19ea392a 100644 --- a/crates/maxplayer-core/src/seller_memory.rs +++ b/crates/maxplayer-core/src/seller_memory.rs @@ -617,9 +617,7 @@ mod tests { // mid-character. Sized so the budget lands off a boundary whatever the marker length is. let glyph = "日"; assert_eq!(glyph.len(), 3); - let index: String = std::iter::repeat(glyph) - .take(MAX_MEMORY_INDEX_BYTES / 3 + 500) - .collect(); + let index = glyph.repeat(MAX_MEMORY_INDEX_BYTES / 3 + 500); assert!(index.len() > MAX_MEMORY_INDEX_BYTES); fs::write(dir.join(MEMORY_INDEX_FILE), &index).expect("write index"); let template = bare_index_template(&root); diff --git a/docs/SELLER-QUICKSTART.md b/docs/SELLER-QUICKSTART.md index 4be067266..e67ad2cfc 100644 --- a/docs/SELLER-QUICKSTART.md +++ b/docs/SELLER-QUICKSTART.md @@ -977,6 +977,69 @@ the sandbox image unless it is already local (`doctor` warns and hands you the ` it up front), and under gVisor a dependency-install-heavy job runs slower than on the host. Switch one seat, watch it claim and deliver, then move the rest. +## 3d. Seat memory — `MEMORY.md` + +A seat can carry **durable, operator-written context** into every job: brand guidelines, house style, +what a class of job actually takes, buyers worth noting. It lives in `MAXPLAYER_HOME/memory/` as a +`MEMORY.md` index plus plain-markdown topic files it links with `[[wikilinks]]`. Nothing here is ever an +input to pay, the journal, or the receipt — it is a quality lever for the agent, not a money-path object. + +**Nearly every seat has no `memory/` dir, and that is fine.** The daemon never creates it. A seat without +one runs every job as a generalist, quietly and with no warning: this whole section is opt-**in** by +writing the file. + +**What loads at job start.** When `MEMORY.md` exists and is non-empty, its **content** is inlined into the +job prompt, appended **last** so it never pushes the buyer's task down. Only the index's own text loads — +under `mode = "docker"` the topic files it links sit outside the job's mount namespace and the agent cannot +open them, so anything the agent must see goes in `MEMORY.md` itself, not in a file it points to. Under +`launcher` or no sandbox the agent may follow the links, but do not design for that. + +**Config, and the defaults:** + +```toml +[seller_memory] +# memory_enabled = true # inline MEMORY.md into every job prompt; false ⇒ prompt is byte-identical to a seat with no memory +# retro_enabled = true # post-job write-back (see below) +# read_on_start_template_path = "…" # plugin seam: how the index is framed in the prompt +# retro_prompt_path = "…" # plugin seam: what a retro turn distills +``` + +`retro_enabled` is **declared, not yet live**: no code path runs a retro turn or writes `memory/` today, so +`MEMORY.md` is whatever *you* wrote. Files carrying frontmatter `author: operator` (including +`operator-notes.md`) are the ones a future retro is bound to leave untouched; write yours that way. + +**The injection budget — 64 KiB.** The index is capped at `MAX_MEMORY_INDEX_BYTES` = 65,536 bytes per +job. An index **over** the budget is **truncated, never dropped**: the job gets the last complete line at +or before the budget, then one marker line — + +``` +[maxplayer: MEMORY.md truncated to the 65536-byte injection budget — of bytes shown, tail dropped] +``` + +— so the agent reads a fragment as a fragment and the seat keeps its specialization *head*. The cut is +never mid-line or mid-character, and the injected block including the marker stays ≤ 64 KiB. You are told +three times: once at **boot** (`seller node WARNING: memory index … is N bytes, over the 65536-byte +injection budget — every job prompt will get a TRUNCATED copy …`), once **per job** in the daemon log, and +by `maxplayer doctor` on demand. Fix it by shortening `MEMORY.md` itself; moving text into a linked topic +file does not help a docker seat (see above). + +**`maxplayer doctor` — the `seller memory` row.** Advisory only: it never FAILs and never blocks boot. + +| Row says | Meaning | +|----------|---------| +| `PASS … is N bytes, injected whole; M bytes of headroom under the 65536-byte injection budget` | working; M is how much more you can write | +| `PASS no memory dir; jobs run without seat memory` | the default state, nothing to fix | +| `PASS memory injection is off (…memory_enabled = false…)` | you opted out; the file is not consulted | +| `WARN memory dir exists but has no MEMORY.md` / `WARN MEMORY.md … is empty` | you started to specialize and stopped: every job injects nothing; the fix names the path | +| `WARN MEMORY.md … is N bytes, over the 65536-byte injection budget — every job prompt gets a TRUNCATED copy` | shorten the file | +| `WARN MEMORY.md … could not be read (…)` | permissions or not UTF-8; jobs run without memory until it reads | + +Like every other consumer, `doctor` only reads — it never creates `memory/`. + +**Opt out** with `memory_enabled = false` under `[seller_memory]`; the composed prompt is then +byte-identical to a seat that never had the file. Deleting `memory/` does the same and is the cleaner +choice for a seat that will not use it. + --- ## 4. Delivery — relay-git default, or BYO @@ -1457,4 +1520,5 @@ signed in for you is not signed in for the service unless its config lives under → discoverability: kind-0 profile on start; capability on the kind-30340 seat heartbeat, republished every ~5 min → both open surfaces off by default; --accept-open-targeted for targeted offers from unnamed buyers, --claim-open-pool for the open pool → --rate-sats defaults to 100, the rate buyers post at: wallet nets face − fee; receipt records FACE, not net; dust refused up front +→ seat memory (§3d): MEMORY.md under MAXPLAYER_HOME/memory/ is inlined last into every job prompt when present; over 64 KiB it is TRUNCATED with a marker (never dropped), warned at boot + per job, and `doctor` has a `seller memory` row (advisory, never FAIL, never creates memory/) ``` From c5dac397c9d4930f47d409559ee2e8a2871d4b4c Mon Sep 17 00:00:00 2001 From: w-seller-memory-guard Date: Tue, 8 Sep 2026 12:03:06 -0700 Subject: [PATCH 4/7] seller memory: rule the leading-blank-line cut, say the true contract in code and quickstart (#983 r1 F1, F2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contract revision (addendum 1, ordering seat): the last-complete-line rule applies to the last complete line that leaves a NON-EMPTY head. When the only newline at or before the content budget sits at offset 0, the head would be empty and the seat would inject zero specialization, so the long-line fallback applies (cut at the nearest lower char boundary). Behaviour of `line_boundary_cut` is unchanged; its doc comment, `fit_index_to_budget`'s doc comment and the inline comment now state that rule in the ruling's words, and a focused test pins the case: one LF followed by 65,536 ASCII `x` bytes -> result <= budget, valid UTF-8, marker as the final line, head non-empty and cut on a char boundary inside the second line. SELLER-QUICKSTART §3d no longer promises "never mid-line or mid-character": it now states the last-complete-line cut, the two shapes that fall back to a character-boundary cut (never mid-character), that the marker's bytes are reserved before the cut, and that the 64 KiB bound covers the index text plus the marker, not the surrounding prompt template. --- crates/maxplayer-core/src/seller_memory.rs | 63 +++++++++++++++++++--- docs/SELLER-QUICKSTART.md | 12 +++-- 2 files changed, 64 insertions(+), 11 deletions(-) diff --git a/crates/maxplayer-core/src/seller_memory.rs b/crates/maxplayer-core/src/seller_memory.rs index e19ea392a..d5a6db6e4 100644 --- a/crates/maxplayer-core/src/seller_memory.rs +++ b/crates/maxplayer-core/src/seller_memory.rs @@ -183,10 +183,13 @@ pub fn truncation_marker(shown_bytes: usize, total_bytes: usize) -> String { /// Fit an index into [`MAX_MEMORY_INDEX_BYTES`]. An index at or under the budget comes back /// trailing-trimmed and untouched. An index over it is cut at the LAST COMPLETE LINE at or before -/// the budget — never mid-line and never mid-UTF-8-character; a single line longer than the budget -/// is cut at the nearest lower char boundary — and [`truncation_marker`] is appended as the final -/// line. The marker's bytes are reserved BEFORE the cut, so the returned text is always -/// `<= MAX_MEMORY_INDEX_BYTES` including the marker. +/// the budget that leaves a NON-EMPTY head, and [`truncation_marker`] is appended as the final +/// line. When no such line exists — a single line longer than the budget, or a file whose only +/// newline at or before the budget sits at offset 0 (the head would be empty and the seat would +/// inject zero specialization) — the long-line fallback applies: the cut lands on the nearest lower +/// char boundary, never mid-UTF-8-character. The marker's bytes are reserved BEFORE the cut, so the +/// returned text is always `<= MAX_MEMORY_INDEX_BYTES` including the marker; the bound covers the +/// index text plus the marker, not the surrounding prompt template. pub fn fit_index_to_budget(index: &str) -> (String, Option) { let total_bytes = index.len(); if total_bytes <= MAX_MEMORY_INDEX_BYTES { @@ -210,13 +213,17 @@ pub fn fit_index_to_budget(index: &str) -> (String, Option) { } /// The byte offset to cut `text` at so the result is at most `budget` bytes: just after the last -/// newline at or before the budget when one exists (so the cut lands on a complete line), else the -/// nearest char boundary at or below the budget (a single line longer than the whole budget). +/// newline at or before the budget that leaves a NON-EMPTY head (so the cut lands on a complete +/// line), else the nearest char boundary at or below the budget — the long-line fallback, which +/// covers both a single line longer than the whole budget and a file whose only newline at or +/// before the budget sits at offset 0. fn line_boundary_cut(text: &str, budget: usize) -> usize { let budget = budget.min(text.len()); let window = &text.as_bytes()[..budget]; if let Some(newline) = window.iter().rposition(|&byte| byte == b'\n') { - // A newline at offset 0 would keep nothing; fall through to the char-boundary cut instead. + // The only newline at or before the budget sits at offset 0: cutting there would leave an + // EMPTY head and inject zero specialization. Contract (PR #983 addendum 1): apply the + // long-line fallback instead. if newline > 0 { return newline + 1; } @@ -641,6 +648,48 @@ mod tests { let _ = fs::remove_dir_all(&root); } + /// An index whose ONLY newline at or before the budget sits at offset 0 (one LF, then one + /// 65,536-byte line): the last-complete-line rule would leave an EMPTY head, so the long-line + /// fallback applies — the head is non-empty, cut on a char boundary inside the second line, the + /// marker is the final line and the whole result fits the budget as valid UTF-8. + #[test] + fn read_on_start_leading_blank_line_then_overlong_line_keeps_a_non_empty_head() { + let root = temp_dir("ros-leading-lf"); + let dir = memory_dir(&root); + fs::create_dir_all(&dir).expect("mkdir"); + let mut index = String::from("\n"); + index.push_str(&"x".repeat(MAX_MEMORY_INDEX_BYTES)); + assert_eq!(index.len(), MAX_MEMORY_INDEX_BYTES + 1); + assert_eq!(index.find('\n'), Some(0), "the only newline is at offset 0"); + fs::write(dir.join(MEMORY_INDEX_FILE), &index).expect("write index"); + let template = bare_index_template(&root); + + let read = read_on_start(&dir, Some(&template)) + .expect("a leading blank line is not an error") + .expect("and the index still injects"); + let truncation = read.truncation.expect("truncated"); + assert_eq!(truncation.total_bytes, index.len()); + assert!( + read.section.len() <= MAX_MEMORY_INDEX_BYTES, + "result incl. marker is {} bytes, over the budget", + read.section.len() + ); + assert!(std::str::from_utf8(read.section.as_bytes()).is_ok(), "valid UTF-8"); + let (head, marker) = split_head_and_marker(&read.section); + assert_eq!(marker, truncation_marker(truncation.shown_bytes, truncation.total_bytes)); + assert!(marker.starts_with("[maxplayer: MEMORY.md truncated"), "marker is the final line"); + // Non-empty head, cut inside the second line: the head keeps the file's leading LF and then + // a run of `x` from the second line — NOT the empty string an offset-0 cut would have given. + assert!(!head.trim().is_empty(), "the head carries specialization, not an empty line"); + assert!(head.starts_with('\n'), "the head is a prefix of the file, incl. its leading LF"); + let second_line = &head[1..]; + assert!(!second_line.is_empty(), "the cut landed inside the second line"); + assert!(second_line.chars().all(|c| c == 'x'), "and kept only that line's own bytes"); + assert!(index.is_char_boundary(head.len()), "the cut is on a char boundary"); + assert_eq!(head.len(), truncation.shown_bytes); + let _ = fs::remove_dir_all(&root); + } + /// The operator-surface inspector reports each state and creates nothing. #[test] fn inspect_index_reports_every_state_and_creates_nothing() { diff --git a/docs/SELLER-QUICKSTART.md b/docs/SELLER-QUICKSTART.md index e67ad2cfc..0dc63eeec 100644 --- a/docs/SELLER-QUICKSTART.md +++ b/docs/SELLER-QUICKSTART.md @@ -1009,15 +1009,19 @@ open them, so anything the agent must see goes in `MEMORY.md` itself, not in a f `operator-notes.md`) are the ones a future retro is bound to leave untouched; write yours that way. **The injection budget — 64 KiB.** The index is capped at `MAX_MEMORY_INDEX_BYTES` = 65,536 bytes per -job. An index **over** the budget is **truncated, never dropped**: the job gets the last complete line at -or before the budget, then one marker line — +job. An index **over** the budget is **truncated, never dropped**: the job gets everything up to the last +complete line at or before the budget that leaves a non-empty head, then one marker line — ``` [maxplayer: MEMORY.md truncated to the 65536-byte injection budget — of bytes shown, tail dropped] ``` -— so the agent reads a fragment as a fragment and the seat keeps its specialization *head*. The cut is -never mid-line or mid-character, and the injected block including the marker stays ≤ 64 KiB. You are told +— so the agent reads a fragment as a fragment and the seat keeps its specialization *head*. Two shapes get +no complete line to cut on: a single line longer than the budget, or a file whose first in-budget newline +is at offset 0 (a leading blank line, then one huge line). Those are cut at a character boundary inside +the long line — never mid-character, so the text stays valid — because an empty head would inject +nothing. The marker's bytes are reserved before the cut, so the index text plus the marker stays ≤ 64 KiB; +that bound is the index and marker only, not the surrounding prompt template. You are told three times: once at **boot** (`seller node WARNING: memory index … is N bytes, over the 65536-byte injection budget — every job prompt will get a TRUNCATED copy …`), once **per job** in the daemon log, and by `maxplayer doctor` on demand. Fix it by shortening `MEMORY.md` itself; moving text into a linked topic From d32c01894007924a03c409cd874cf92c630bec1b Mon Sep 17 00:00:00 2001 From: w-seller-memory-guard Date: Tue, 8 Sep 2026 12:43:42 -0700 Subject: [PATCH 5/7] seller memory: #983 r2: 17 in-hunk rustfmt sites Hand-lift of rustfmt's output for exactly the 17 `rustfmt --check --edition 2024` diff blocks that fall inside this branch's own added lines (seller_memory.rs 10, seller_node/run.rs 4, tests/seller_memory_read_on_start.rs 3, doctor.rs 0). The 481 blocks in the same files outside this branch's hunks are base drift and are untouched. Format-only: each file is byte-identical to c5dac39 once whitespace and commas are stripped. Method: rustfmt --emit stdout to scratch, diff -U0, apply only hunks whose original range lies wholly inside the added ranges of `git diff ec95eb2 -U0` (25 -U0 hunks, 0 straddling). --- crates/maxplayer-core/src/seller_memory.rs | 104 ++++++++++++++---- crates/maxplayer-core/src/seller_node/run.rs | 39 +++++-- .../tests/seller_memory_read_on_start.rs | 12 +- 3 files changed, 124 insertions(+), 31 deletions(-) diff --git a/crates/maxplayer-core/src/seller_memory.rs b/crates/maxplayer-core/src/seller_memory.rs index d5a6db6e4..c7eecbd7b 100644 --- a/crates/maxplayer-core/src/seller_memory.rs +++ b/crates/maxplayer-core/src/seller_memory.rs @@ -536,27 +536,43 @@ mod tests { .expect("an over-bound index is not an error") .expect("and it still injects"); let truncation = read.truncation.expect("the read reports what it cut"); - assert_eq!(truncation.total_bytes, MAX_MEMORY_INDEX_BYTES + 1, "the real size is reported"); + assert_eq!( + truncation.total_bytes, + MAX_MEMORY_INDEX_BYTES + 1, + "the real size is reported" + ); assert!( read.section.len() <= MAX_MEMORY_INDEX_BYTES, "injected text incl. marker is {} bytes, over the {MAX_MEMORY_INDEX_BYTES} budget", read.section.len() ); let (head, marker) = split_head_and_marker(&read.section); - assert_eq!(head.len(), truncation.shown_bytes, "shown_bytes is the surviving head"); + assert_eq!( + head.len(), + truncation.shown_bytes, + "shown_bytes is the surviving head" + ); assert!( head.chars().all(|c| c == 'x') && !head.is_empty(), "the head is the file's own text" ); - assert_eq!(marker, truncation_marker(truncation.shown_bytes, truncation.total_bytes)); - assert!(marker.contains(&MAX_MEMORY_INDEX_BYTES.to_string()), "marker names the budget"); + assert_eq!( + marker, + truncation_marker(truncation.shown_bytes, truncation.total_bytes) + ); + assert!( + marker.contains(&MAX_MEMORY_INDEX_BYTES.to_string()), + "marker names the budget" + ); assert!( marker.contains(&(MAX_MEMORY_INDEX_BYTES + 1).to_string()), "marker names the actual size: {marker}" ); // The section-only wrapper sees the same text. assert_eq!( - read_on_start_section(&dir, Some(&template)).expect("read").as_deref(), + read_on_start_section(&dir, Some(&template)) + .expect("read") + .as_deref(), Some(read.section.as_str()) ); let _ = fs::remove_dir_all(&root); @@ -602,8 +618,14 @@ mod tests { &head[head.len().saturating_sub(80)..] ); assert!(index.starts_with(head), "the head is a prefix of the file"); - assert_eq!(marker, truncation_marker(truncation.shown_bytes, truncation.total_bytes)); - assert!(marker.starts_with("[maxplayer: MEMORY.md truncated"), "marker is the last line"); + assert_eq!( + marker, + truncation_marker(truncation.shown_bytes, truncation.total_bytes) + ); + assert!( + marker.starts_with("[maxplayer: MEMORY.md truncated"), + "marker is the last line" + ); // Most of the budget is used: a cut that threw away far more than one line is a bug. assert!( read.section.len() > MAX_MEMORY_INDEX_BYTES - 256, @@ -639,9 +661,16 @@ mod tests { head.chars().all(|c| c == '日') && !head.is_empty(), "head is whole characters only" ); - assert_eq!(head.len() % 3, 0, "head length is a whole number of 3-byte chars"); + assert_eq!( + head.len() % 3, + 0, + "head length is a whole number of 3-byte chars" + ); assert_eq!(head.len(), truncation.shown_bytes); - assert_eq!(marker, truncation_marker(truncation.shown_bytes, truncation.total_bytes)); + assert_eq!( + marker, + truncation_marker(truncation.shown_bytes, truncation.total_bytes) + ); // Also directly: the pure fitter produces a String that round-trips as valid UTF-8 bytes. let (fitted, _) = fit_index_to_budget(&index); assert!(std::str::from_utf8(fitted.as_bytes()).is_ok()); @@ -674,18 +703,42 @@ mod tests { "result incl. marker is {} bytes, over the budget", read.section.len() ); - assert!(std::str::from_utf8(read.section.as_bytes()).is_ok(), "valid UTF-8"); + assert!( + std::str::from_utf8(read.section.as_bytes()).is_ok(), + "valid UTF-8" + ); let (head, marker) = split_head_and_marker(&read.section); - assert_eq!(marker, truncation_marker(truncation.shown_bytes, truncation.total_bytes)); - assert!(marker.starts_with("[maxplayer: MEMORY.md truncated"), "marker is the final line"); + assert_eq!( + marker, + truncation_marker(truncation.shown_bytes, truncation.total_bytes) + ); + assert!( + marker.starts_with("[maxplayer: MEMORY.md truncated"), + "marker is the final line" + ); // Non-empty head, cut inside the second line: the head keeps the file's leading LF and then // a run of `x` from the second line — NOT the empty string an offset-0 cut would have given. - assert!(!head.trim().is_empty(), "the head carries specialization, not an empty line"); - assert!(head.starts_with('\n'), "the head is a prefix of the file, incl. its leading LF"); + assert!( + !head.trim().is_empty(), + "the head carries specialization, not an empty line" + ); + assert!( + head.starts_with('\n'), + "the head is a prefix of the file, incl. its leading LF" + ); let second_line = &head[1..]; - assert!(!second_line.is_empty(), "the cut landed inside the second line"); - assert!(second_line.chars().all(|c| c == 'x'), "and kept only that line's own bytes"); - assert!(index.is_char_boundary(head.len()), "the cut is on a char boundary"); + assert!( + !second_line.is_empty(), + "the cut landed inside the second line" + ); + assert!( + second_line.chars().all(|c| c == 'x'), + "and kept only that line's own bytes" + ); + assert!( + index.is_char_boundary(head.len()), + "the cut is on a char boundary" + ); assert_eq!(head.len(), truncation.shown_bytes); let _ = fs::remove_dir_all(&root); } @@ -695,12 +748,18 @@ mod tests { fn inspect_index_reports_every_state_and_creates_nothing() { let root = temp_dir("inspect"); let dir = memory_dir(&root); - assert_eq!(inspect_index(&dir).expect("no dir"), IndexState::NoMemoryDir); + assert_eq!( + inspect_index(&dir).expect("no dir"), + IndexState::NoMemoryDir + ); assert!(!dir.exists(), "inspecting must not create memory/"); fs::create_dir_all(&dir).expect("mkdir"); assert_eq!(inspect_index(&dir).expect("no index"), IndexState::NoIndex); - assert!(!dir.join(MEMORY_INDEX_FILE).exists(), "inspecting must not create MEMORY.md"); + assert!( + !dir.join(MEMORY_INDEX_FILE).exists(), + "inspecting must not create MEMORY.md" + ); fs::write(dir.join(MEMORY_INDEX_FILE), " \n\t\n").expect("write blank"); assert_eq!(inspect_index(&dir).expect("blank"), IndexState::Empty); @@ -716,7 +775,12 @@ mod tests { ) .expect("write big"); let over = inspect_index(&dir).expect("over"); - assert_eq!(over, IndexState::OverBudget { bytes: MAX_MEMORY_INDEX_BYTES + 7 }); + assert_eq!( + over, + IndexState::OverBudget { + bytes: MAX_MEMORY_INDEX_BYTES + 7 + } + ); assert_eq!(over.headroom_bytes(), None); let _ = fs::remove_dir_all(&root); } diff --git a/crates/maxplayer-core/src/seller_node/run.rs b/crates/maxplayer-core/src/seller_node/run.rs index 9cff1cd5e..d095536d3 100644 --- a/crates/maxplayer-core/src/seller_node/run.rs +++ b/crates/maxplayer-core/src/seller_node/run.rs @@ -9345,17 +9345,31 @@ mod tests { let boot = memory_index_budget_warning(path, IndexState::OverBudget { bytes: over }) .expect("an over-budget index must warn at boot"); - assert!(boot.contains(&over.to_string()), "names the real size: {boot}"); - assert!(boot.contains(&MAX_MEMORY_INDEX_BYTES.to_string()), "names the budget: {boot}"); - assert!(boot.contains("/seat/home/memory/MEMORY.md"), "names the path: {boot}"); - assert!(boot.contains("TRUNCATED"), "says what happens to the prompt: {boot}"); + assert!( + boot.contains(&over.to_string()), + "names the real size: {boot}" + ); + assert!( + boot.contains(&MAX_MEMORY_INDEX_BYTES.to_string()), + "names the budget: {boot}" + ); + assert!( + boot.contains("/seat/home/memory/MEMORY.md"), + "names the path: {boot}" + ); + assert!( + boot.contains("TRUNCATED"), + "says what happens to the prompt: {boot}" + ); for quiet in [ IndexState::NoMemoryDir, IndexState::NoIndex, IndexState::Empty, IndexState::Fits { bytes: 12 }, - IndexState::Fits { bytes: MAX_MEMORY_INDEX_BYTES }, + IndexState::Fits { + bytes: MAX_MEMORY_INDEX_BYTES, + }, ] { assert_eq!( memory_index_budget_warning(path, quiet), @@ -9366,15 +9380,24 @@ mod tests { let per_job = memory_index_truncated_warning( path, - IndexTruncation { shown_bytes: 65_000, total_bytes: over }, + IndexTruncation { + shown_bytes: 65_000, + total_bytes: over, + }, + ); + assert!( + per_job.contains(&over.to_string()), + "names the real size: {per_job}" ); - assert!(per_job.contains(&over.to_string()), "names the real size: {per_job}"); assert!( per_job.contains(&MAX_MEMORY_INDEX_BYTES.to_string()), "names the budget: {per_job}" ); assert!(per_job.contains("65000"), "names what survived: {per_job}"); - assert!(per_job.contains("/seat/home/memory/MEMORY.md"), "names the path: {per_job}"); + assert!( + per_job.contains("/seat/home/memory/MEMORY.md"), + "names the path: {per_job}" + ); assert!( !per_job.contains("without memory"), "the old 'running the job without memory' wording is gone: {per_job}" diff --git a/crates/maxplayer/tests/seller_memory_read_on_start.rs b/crates/maxplayer/tests/seller_memory_read_on_start.rs index f208a00ed..ca23c41ed 100644 --- a/crates/maxplayer/tests/seller_memory_read_on_start.rs +++ b/crates/maxplayer/tests/seller_memory_read_on_start.rs @@ -205,7 +205,9 @@ fn an_over_budget_index_degrades_instead_of_blocking_the_job() { let mut runaway = format!("# Memory\n\n{brand}\n"); let mut n = 0usize; while runaway.len() < MAX_MEMORY_INDEX_BYTES + 1 { - runaway.push_str(&format!("- lesson {n:06}: keep the buyer's task the subject of the reply\n")); + runaway.push_str(&format!( + "- lesson {n:06}: keep the buyer's task the subject of the reply\n" + )); n += 1; } runaway.truncate(MAX_MEMORY_INDEX_BYTES + 1); // ASCII filler ⇒ safe to cut anywhere @@ -240,11 +242,15 @@ fn an_over_budget_index_degrades_instead_of_blocking_the_job() { ); // The memory-off prompt is still the byte-for-byte prefix: truncation only APPENDS less. let baseline = job_prompt(&offer(), GIT_REMOTE, DEADLINE, None); - assert!(prompt.starts_with(&baseline), "the job is the normal job plus a (shorter) memory section"); + assert!( + prompt.starts_with(&baseline), + "the job is the normal job plus a (shorter) memory section" + ); // And the injected index text itself is within budget: the section is framing + index, so it is // bounded by the budget plus the default framing's own bytes (the template text and the memory // dir path it substitutes in). - let framing = DEFAULT_READ_ON_START_TEMPLATE.len() + memory_dir(&root).display().to_string().len(); + let framing = + DEFAULT_READ_ON_START_TEMPLATE.len() + memory_dir(&root).display().to_string().len(); assert!( section.len() <= MAX_MEMORY_INDEX_BYTES + framing, "truncated section is {} bytes; budget {MAX_MEMORY_INDEX_BYTES} + framing {framing}", From e8963eb6db3836bf187ae58edff6f7d747794d99 Mon Sep 17 00:00:00 2001 From: w-seller-memory-guard Date: Tue, 8 Sep 2026 13:58:35 -0700 Subject: [PATCH 6/7] seller memory: choose the truncation cut by surviving content, not newline offset Round-2 DENY finding 1: line_boundary_cut accepted any newline at offset > 0, but fit_index_to_budget trim_end()s the head AFTER that choice. Two LF bytes, a CRLF blank line or an indented blank opening therefore selected an all-whitespace prefix that trimmed to nothing, dropping every byte of specialization that could have fit. Decide on the head that survives the trim instead: a newline is a usable complete-line cut only when the text before it is not all whitespace. The rightmost in-window newline settles all of them, since every earlier one has a prefix of this prefix. When none qualifies, the authorized char-boundary fallback keeps the file own opening bytes plus as much of the long line as fits. Tests: two-LF, CRLF, indented-blank and multi-LF openings (retained specialization, prefix identity, marker last, UTF-8, budget); a blank opening followed by content still cuts on a complete line; and the documented limit, an all-whitespace in-budget window, which has no head to keep. The one-LF regression is unchanged. Finding 2: doc comments and quickstart 3d now state the surviving-head rule and the fallback, including that opening bytes are kept, not skipped. --- crates/maxplayer-core/src/seller_memory.rs | 208 +++++++++++++++++++-- docs/SELLER-QUICKSTART.md | 15 +- 2 files changed, 202 insertions(+), 21 deletions(-) diff --git a/crates/maxplayer-core/src/seller_memory.rs b/crates/maxplayer-core/src/seller_memory.rs index c7eecbd7b..75aab41be 100644 --- a/crates/maxplayer-core/src/seller_memory.rs +++ b/crates/maxplayer-core/src/seller_memory.rs @@ -32,7 +32,9 @@ pub const MEMORY_DIR_NAME: &str = "memory"; /// self-limiting. The section is appended LAST, so a larger block never pushes the buyer's task down. /// /// An index over this bound is TRUNCATED at the injection site — [`read_on_start_section`] inlines -/// the last complete line at or before the budget plus a marker line saying what was dropped, and +/// the head up to the last complete line at or before the budget that survives trimming (or, when +/// no such line exists, as much text as fits on a char boundary), plus a marker line saying what +/// was dropped, see [`fit_index_to_budget`] for the exact rule, and /// stays `Ok(Some(..))`, so the seat keeps its specialization head rather than silently running /// every job as a generalist. The daemon warns on the console (per job, and once at boot), and /// `maxplayer doctor` reports it; the seam never blocks a job. @@ -183,13 +185,18 @@ pub fn truncation_marker(shown_bytes: usize, total_bytes: usize) -> String { /// Fit an index into [`MAX_MEMORY_INDEX_BYTES`]. An index at or under the budget comes back /// trailing-trimmed and untouched. An index over it is cut at the LAST COMPLETE LINE at or before -/// the budget that leaves a NON-EMPTY head, and [`truncation_marker`] is appended as the final -/// line. When no such line exists — a single line longer than the budget, or a file whose only -/// newline at or before the budget sits at offset 0 (the head would be empty and the seat would -/// inject zero specialization) — the long-line fallback applies: the cut lands on the nearest lower -/// char boundary, never mid-UTF-8-character. The marker's bytes are reserved BEFORE the cut, so the -/// returned text is always `<= MAX_MEMORY_INDEX_BYTES` including the marker; the bound covers the -/// index text plus the marker, not the surrounding prompt template. +/// the budget WHOSE HEAD SURVIVES TRIMMING — the head is `trim_end`ed after the cut, so the line is +/// chosen by what is left standing, not by where a newline happens to sit — and +/// [`truncation_marker`] is appended as the final line. When no such line exists — a single line +/// longer than the budget, or a file whose in-budget newlines all sit inside an all-whitespace +/// opening (one LF, two LFs, a CRLF blank line, an indented blank line: the head would trim to +/// empty and the seat would inject zero specialization) — the long-line fallback applies: the cut +/// lands on the nearest lower char boundary, never mid-UTF-8-character, and the file's own opening +/// bytes are kept as-is rather than skipped. An index whose whole in-budget window is whitespace +/// has no non-empty head to keep and gets none: no byte-bounded rule can reach text that starts +/// beyond the budget. The marker's bytes are reserved BEFORE the cut, so the returned text is +/// always `<= MAX_MEMORY_INDEX_BYTES` including the marker; the bound covers the index text plus +/// the marker, not the surrounding prompt template. pub fn fit_index_to_budget(index: &str) -> (String, Option) { let total_bytes = index.len(); if total_bytes <= MAX_MEMORY_INDEX_BYTES { @@ -213,18 +220,22 @@ pub fn fit_index_to_budget(index: &str) -> (String, Option) { } /// The byte offset to cut `text` at so the result is at most `budget` bytes: just after the last -/// newline at or before the budget that leaves a NON-EMPTY head (so the cut lands on a complete -/// line), else the nearest char boundary at or below the budget — the long-line fallback, which -/// covers both a single line longer than the whole budget and a file whose only newline at or -/// before the budget sits at offset 0. +/// newline at or before the budget whose head SURVIVES `trim_end` (so the cut lands on a complete +/// line and the head still carries specialization), else the nearest char boundary at or below the +/// budget — the long-line fallback, which covers both a single line longer than the whole budget +/// and a file whose in-budget newlines all sit inside an all-whitespace opening. fn line_boundary_cut(text: &str, budget: usize) -> usize { let budget = budget.min(text.len()); let window = &text.as_bytes()[..budget]; if let Some(newline) = window.iter().rposition(|&byte| byte == b'\n') { - // The only newline at or before the budget sits at offset 0: cutting there would leave an - // EMPTY head and inject zero specialization. Contract (PR #983 addendum 1): apply the - // long-line fallback instead. - if newline > 0 { + // CONTENT, not offset. The caller trims the head, so a newline is only a usable complete-line + // cut when what precedes it is not all whitespace — `text[..newline]` is exactly that head + // minus its own trailing LF, and `head.trim_end()` is empty iff this is. Checking the + // RIGHTMOST newline decides every one of them: each earlier newline's prefix is a prefix of + // this one, so if this prefix is all whitespace, so are all of theirs. That covers a lone + // leading LF (offset 0), two LFs, `\r\n`, and any indented blank opening alike. Contract: + // PR #983 addendum 1 as revised by the round-2 verdict. + if !text[..newline].trim_end().is_empty() { return newline + 1; } } @@ -743,6 +754,171 @@ mod tests { let _ = fs::remove_dir_all(&root); } + /// Every all-whitespace opening — two LFs, a `\r\n` blank line, indented blank lines — followed by + /// one over-budget line takes the same long-line fallback the single leading LF takes. The head is + /// the file's OWN opening bytes plus a run of its long line, never the empty string a + /// newline-offset rule would have selected (round-2 finding 1). Each case checks retained + /// specialization, prefix identity, the marker as final line, UTF-8 validity and the total bound. + /// This goes red if `line_boundary_cut` ever decides on a newline's offset instead of on whether + /// the head it selects survives trimming. + #[test] + fn read_on_start_whitespace_opening_then_overlong_line_keeps_the_files_own_head() { + // (label, opening bytes) — every one trims away to nothing, so none of their newlines is a + // usable complete-line cut. + let openings = [ + ("two-lf", "\n\n"), + ("crlf-blank-line", "\r\n"), + ("indented-blanks", " \n\t\n"), + ("lf-space-lf", "\n \n"), + ("many-lf", "\n\n\n\n"), + ]; + for (label, opening) in openings { + assert!( + opening.trim_end().is_empty(), + "{label}: the fixture opening must be all whitespace" + ); + let root = temp_dir(&format!("ros-ws-open-{label}")); + let dir = memory_dir(&root); + fs::create_dir_all(&dir).expect("mkdir"); + let mut index = String::from(opening); + index.push_str(&"x".repeat(MAX_MEMORY_INDEX_BYTES)); + fs::write(dir.join(MEMORY_INDEX_FILE), &index).expect("write index"); + let template = bare_index_template(&root); + + let read = read_on_start(&dir, Some(&template)) + .expect("a whitespace opening is not an error") + .expect("and the index still injects"); + let truncation = read + .truncation + .unwrap_or_else(|| panic!("{label}: an over-budget index reports its truncation")); + assert_eq!(truncation.total_bytes, index.len(), "{label}: real size"); + assert!( + read.section.len() <= MAX_MEMORY_INDEX_BYTES, + "{label}: result incl. marker is {} bytes, over the {MAX_MEMORY_INDEX_BYTES}-byte budget", + read.section.len() + ); + assert!( + std::str::from_utf8(read.section.as_bytes()).is_ok(), + "{label}: valid UTF-8" + ); + + let (head, marker) = split_head_and_marker(&read.section); + assert_eq!( + marker, + truncation_marker(truncation.shown_bytes, truncation.total_bytes), + "{label}: marker is the final line and reports the real numbers" + ); + assert!( + !head.trim().is_empty(), + "{label}: the head carries specialization, not an all-whitespace opening" + ); + assert!( + head.contains('x'), + "{label}: the head reaches into the long line" + ); + assert!( + index.as_bytes().starts_with(head.as_bytes()), + "{label}: the head is a PREFIX of the file — no text skipped or reordered" + ); + assert!( + head.starts_with(opening), + "{label}: the file's own opening bytes are kept, not skipped" + ); + assert!( + index.is_char_boundary(head.len()), + "{label}: the cut is on a char boundary" + ); + assert_eq!( + head.len(), + truncation.shown_bytes, + "{label}: shown_bytes is the surviving head" + ); + let _ = fs::remove_dir_all(&root); + } + } + + /// The complement of the fallback: when a blank opening IS followed by in-budget content lines, a + /// complete line still wins — the fix decides on surviving content, so it must not push ordinary + /// indexes onto the char-boundary path. The head keeps the file's blank opening and ends on a whole + /// fixture line. + #[test] + fn read_on_start_blank_opening_still_cuts_on_a_complete_line() { + const LINE: &str = "- alpha: the seat's own specialization line\n"; + let root = temp_dir("ros-blank-then-lines"); + let dir = memory_dir(&root); + fs::create_dir_all(&dir).expect("mkdir"); + let mut index = String::from("\n\n"); + while index.len() < MAX_MEMORY_INDEX_BYTES * 2 { + index.push_str(LINE); + } + fs::write(dir.join(MEMORY_INDEX_FILE), &index).expect("write index"); + let template = bare_index_template(&root); + + let read = read_on_start(&dir, Some(&template)) + .expect("read") + .expect("injects"); + let truncation = read.truncation.expect("truncated"); + assert!(read.section.len() <= MAX_MEMORY_INDEX_BYTES, "within budget"); + let (head, marker) = split_head_and_marker(&read.section); + assert_eq!( + marker, + truncation_marker(truncation.shown_bytes, truncation.total_bytes) + ); + assert!( + index.as_bytes().starts_with(head.as_bytes()) && head.starts_with("\n\n"), + "the head is the file's own prefix, blank opening included" + ); + assert!( + head.ends_with(LINE.trim_end()), + "the cut landed on a COMPLETE line, not inside one: {:?}", + &head[head.len().saturating_sub(48)..] + ); + assert!( + head[2..].lines().all(|line| line == LINE.trim_end()), + "and kept only whole fixture lines" + ); + assert_eq!(head.len(), truncation.shown_bytes); + let _ = fs::remove_dir_all(&root); + } + + /// The documented limit, stated so nobody reads the non-empty-head rule as a promise it cannot + /// keep: when the whole in-budget window is whitespace, the content starts BEYOND the budget and no + /// byte-bounded rule can reach it. The index is still injected with its marker and still fits the + /// bound — it just has no head. + #[test] + fn read_on_start_all_whitespace_in_budget_window_has_no_head_to_keep() { + let root = temp_dir("ros-ws-window"); + let dir = memory_dir(&root); + fs::create_dir_all(&dir).expect("mkdir"); + let mut index = " \n".repeat(MAX_MEMORY_INDEX_BYTES); + index.push_str("tail-content-past-the-budget"); + assert!( + !index.trim().is_empty(), + "the file as a whole is not blank, so the seam accepts it" + ); + fs::write(dir.join(MEMORY_INDEX_FILE), &index).expect("write index"); + let template = bare_index_template(&root); + + let read = read_on_start(&dir, Some(&template)) + .expect("read") + .expect("injects"); + let truncation = read.truncation.expect("truncated"); + assert_eq!( + truncation.shown_bytes, 0, + "no non-empty head exists within the budget, and none is manufactured" + ); + assert!(read.section.len() <= MAX_MEMORY_INDEX_BYTES, "within budget"); + assert!(std::str::from_utf8(read.section.as_bytes()).is_ok()); + let (head, marker) = split_head_and_marker(&read.section); + assert!(head.is_empty(), "the head is empty: {head:?}"); + assert_eq!( + marker, + truncation_marker(0, index.len()), + "the marker is still the final line and still tells the truth" + ); + let _ = fs::remove_dir_all(&root); + } + /// The operator-surface inspector reports each state and creates nothing. #[test] fn inspect_index_reports_every_state_and_creates_nothing() { diff --git a/docs/SELLER-QUICKSTART.md b/docs/SELLER-QUICKSTART.md index 0dc63eeec..ed42d7500 100644 --- a/docs/SELLER-QUICKSTART.md +++ b/docs/SELLER-QUICKSTART.md @@ -1010,17 +1010,22 @@ open them, so anything the agent must see goes in `MEMORY.md` itself, not in a f **The injection budget — 64 KiB.** The index is capped at `MAX_MEMORY_INDEX_BYTES` = 65,536 bytes per job. An index **over** the budget is **truncated, never dropped**: the job gets everything up to the last -complete line at or before the budget that leaves a non-empty head, then one marker line — +complete line at or before the budget whose head still has content once trailing whitespace is trimmed, +then one marker line — ``` [maxplayer: MEMORY.md truncated to the 65536-byte injection budget — of bytes shown, tail dropped] ``` — so the agent reads a fragment as a fragment and the seat keeps its specialization *head*. Two shapes get -no complete line to cut on: a single line longer than the budget, or a file whose first in-budget newline -is at offset 0 (a leading blank line, then one huge line). Those are cut at a character boundary inside -the long line — never mid-character, so the text stays valid — because an empty head would inject -nothing. The marker's bytes are reserved before the cut, so the index text plus the marker stays ≤ 64 KiB; +no usable complete line to cut on: a single line longer than the budget, or a file that opens with nothing +but whitespace before one huge line — one blank line, several, a `\r\n` blank line or an indented one, all +alike — because every newline in the window would leave a head that trims away to nothing. Those are cut +at a character boundary inside the long line — never mid-character, so the text stays valid — and the +file's own opening bytes are kept, not skipped, because an empty head would inject nothing. What the rule +cannot do is reach text that only starts *after* the budget: an index whose whole first 64 KiB is +whitespace has no head to keep and gets none. The marker's bytes are reserved before the cut, so the +index text plus the marker stays ≤ 64 KiB; that bound is the index and marker only, not the surrounding prompt template. You are told three times: once at **boot** (`seller node WARNING: memory index … is N bytes, over the 65536-byte injection budget — every job prompt will get a TRUNCATED copy …`), once **per job** in the daemon log, and From 285e2f6af7f92d69b476d394e8405ef2d7884b96 Mon Sep 17 00:00:00 2001 From: w-seller-memory-guard Date: Tue, 8 Sep 2026 17:29:57 -0700 Subject: [PATCH 7/7] seller memory: rustfmt the two new-line assert wraps (in-hunk only) The two in-hunk rustfmt sites were both the same new assert line in the tests added by the previous commit. Wrapped by hand; no whole-file fmt, no base-drift cleanup. --- crates/maxplayer-core/src/seller_memory.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/maxplayer-core/src/seller_memory.rs b/crates/maxplayer-core/src/seller_memory.rs index 75aab41be..ec327d2e1 100644 --- a/crates/maxplayer-core/src/seller_memory.rs +++ b/crates/maxplayer-core/src/seller_memory.rs @@ -858,7 +858,10 @@ mod tests { .expect("read") .expect("injects"); let truncation = read.truncation.expect("truncated"); - assert!(read.section.len() <= MAX_MEMORY_INDEX_BYTES, "within budget"); + assert!( + read.section.len() <= MAX_MEMORY_INDEX_BYTES, + "within budget" + ); let (head, marker) = split_head_and_marker(&read.section); assert_eq!( marker, @@ -907,7 +910,10 @@ mod tests { truncation.shown_bytes, 0, "no non-empty head exists within the budget, and none is manufactured" ); - assert!(read.section.len() <= MAX_MEMORY_INDEX_BYTES, "within budget"); + assert!( + read.section.len() <= MAX_MEMORY_INDEX_BYTES, + "within budget" + ); assert!(std::str::from_utf8(read.section.as_bytes()).is_ok()); let (head, marker) = split_head_and_marker(&read.section); assert!(head.is_empty(), "the head is empty: {head:?}");