diff --git a/crates/maxplayer-core/src/seller_memory.rs b/crates/maxplayer-core/src/seller_memory.rs index 6c3e4adcf..ec327d2e1 100644 --- a/crates/maxplayer-core/src/seller_memory.rs +++ b/crates/maxplayer-core/src/seller_memory.rs @@ -31,9 +31,13 @@ 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 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. 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 +154,109 @@ 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 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 { + 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 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') { + // 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; + } + } + 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 +264,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 +513,457 @@ 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); + } + + /// 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!( + 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!( + 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 = 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); + + 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); + } - 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(); + /// 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!( - message.contains(&MAX_MEMORY_INDEX_BYTES.to_string()), - "error names the bound: {message}" + 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!( - message.contains(&(MAX_MEMORY_INDEX_BYTES + 1).to_string()), - "error names the actual size: {message}" + !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); + } + + /// 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() { + 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..d095536d3 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,77 @@ 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/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 diff --git a/crates/maxplayer/tests/seller_memory_read_on_start.rs b/crates/maxplayer/tests/seller_memory_read_on_start.rs index 1fc2a753a..ca23c41ed 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,69 @@ 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); diff --git a/docs/SELLER-QUICKSTART.md b/docs/SELLER-QUICKSTART.md index 4be067266..ed42d7500 100644 --- a/docs/SELLER-QUICKSTART.md +++ b/docs/SELLER-QUICKSTART.md @@ -977,6 +977,78 @@ 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 everything up to the last +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 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 +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 +1529,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/) ```