From c92e0f7b42dcf7e2e419bdd4c80192192f492661 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sat, 5 Sep 2026 18:16:48 +0530 Subject: [PATCH 01/10] wip(0.38.2): the field-test corrections deferred out of 0.38.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.38.1 adopted its fallback scope — the speller, the gated-run bound and the io-harness pin — so everything else the 2026-09-05 field test found moves here rather than being thrown away. None of this is finished work; it is carried so 0.38.2 starts from it instead of from nothing. What is here, and what state it is in: - README and `docs/CONTRACT.md` reach ACP, the tool mask, the schema contract and `RunOutcome::SchemaUnsatisfied`, and the tap and marketplace appear with the commands that use them. Four gates in `tests/docs.rs`. Context Collapse is deliberately absent: `grep -rn "Collapse" src/ tests/` is zero hits, so naming it would have been a false sentence. - The home line is drawn only on a run that created or moved something, instead of on every invocation. - The wizard names the configuration already in force rather than always claiming a first run. - A fenced block draws no bare language-tag line. - A thought is committed above the prose it produced, not under it. - A spawned child's outcome is drawn as words rather than as a Rust `Debug`. - `lock::sweep` removes the lock files of finished sessions at startup. It deliberately does NOT remove a session's own lock on exit — that reintroduces the inode-split race `src/lock.rs` is designed to avoid. - Timestamps carry a UTC marker in the session surfaces and in `/export`. Local time is not reachable: it needs a clock this module may not read and a timezone database that would be an eleventh dependency. - Help text no longer narrates release history, each subcommand's example names its own subcommand, and `io config get` on an unknown key says so. Known incomplete, and why: - `f9_plugin_search_with_no_match_says_nothing_matched` is RED. The speller `marketplace::nothing_matched` exists; both call sites are in `src/main.rs` and were not wired. - `io mcp disable` still prints the io-harness 0.69.0 note. It lives in `src/servers.rs` and `src/pluginview.rs`. - `io resume --list` gains nothing here. io-harness publishes no reader for a run's goal and none for a run's `created_at`, and `run_file` answers the same root for every run in a workspace. - `/commit` still authors as `agent@io-harness.invalid` when no `[run.commit_identity]` is set. Reading the operator's git identity needs either a third permitted process spawn or an INI parser for `.git/config` and `~/.gitconfig`. - The `io-harness-0.78.0/...` citations throughout `src/` are stale against the 0.79.0 pin, so `tests/docs.rs`'s `f19` fails on this branch. --- README.md | 13 +- docs/CONTRACT.md | 28 +- src/cli.rs | 68 +++-- src/configure.rs | 65 ++++- src/events.rs | 99 ++++++- src/export.rs | 15 +- src/home.rs | 46 +++- src/lock.rs | 75 ++++++ src/main.rs | 16 +- src/markdown.rs | 28 +- src/marketplace.rs | 18 ++ src/sessions.rs | 20 +- src/wizard.rs | 46 +++- tests/docs.rs | 659 +++++++++++++++++++++++++++++++++++++++++++++ tests/events.rs | 105 ++++++++ tests/home.rs | 66 +++++ tests/lock.rs | 68 +++++ tests/markdown.rs | 42 +++ tests/wizard.rs | 43 +++ 19 files changed, 1464 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index b0da6d7..23e4ea8 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ posture on the next.](assets/screenshot.png) | **A session you can read** | Every finished line in the terminal's own scrollback, designed rather than defaulted: a tool call as a verb and a path, a thought as a thought, an answer that ends the turn | | **A working view** | Two sticky rows while a turn runs — a word for the turn with its clock and spend, and a line under it saying what is happening *now* | | **The boundary, visible** | The posture on the footer, a refusal that names the act, the target, the rule and the layer, and `Shift+Tab` to change it from the next turn | +| **A tool held back** | `/context withhold ` builds the session's tool mask and keeps that tool refused until `/context allow`; io-harness sends a byte-identical catalogue either way, so the request grows by the one sentence naming what is withheld | | **Approvals in place** | A write stops the run and shows the diff it proposes; `y`, `a` or `n`, answered where it was asked | | **A fan-out you can watch** | Contained turns spawn children under one shared ceiling; `Ctrl+F` shows the tree and what it is costing | | **Your file, in force** | Every section of `io.toml` bounds a session turn as it bounds `io exec`; the budgets in force are on the status line with what is left of them, and `/status` commits the whole state — policy layers, sandbox backend, caps, budgets, connections — into the scrollback | @@ -54,7 +55,9 @@ posture on the next.](assets/screenshot.png) | **Conversations that survive** | `/resume` reopens an earlier session and answers whatever its last run stopped on, `/fork` continues from an earlier turn, `/clear` starts fresh without leaving | | **A paused run, answered** | A question, a plan or an interrupted call is decided where it was left and the run carries on from the step it stopped at — from a session, or from a script with `io resume` | | **Everything it asks, in one place** | Several questions asked at once arrive as one overlay, answered one at a time and sent together; an offer may explain itself and preview what taking it would do; `Space` marks where a question takes more than one answer | -| **Headless** | `io exec` runs one goal to completion with documented exit codes and `--json`, and `io resume` carries a parked one on | +| **Headless** | `io exec` runs one goal to completion with documented exit codes and `--json`, and `io resume` carries a parked one on. Exit `6` is a run that was judged and did not hold up: a verification gate that failed, or io-harness's `RunOutcome::SchemaUnsatisfied` — the schema contract, a run that never produced the shape `[run] output_schema` asked for | +| **From your editor** | `io acp` serves the Agent Client Protocol (ACP) as newline-delimited JSON-RPC 2.0 over stdio, so Zed or a JetBrains IDE runs io against this machine's own configuration; an action in the grey tier arrives as a `session/request_permission` you answer in the editor | +| **Bundles from a marketplace you named** | `/plugin marketplace add /` clones an index into your own home and `/plugin add ` installs a bundle out of it — the same words from a shell, as `io plugin marketplace add` and `io plugin add` — and what a bundle is allowed to do is shown before it may do it | | **Readable without seeing it** | `--plain` animates nothing and commits every state change as text, for a screen reader, a braille display or a log | | **Markdown, rendered** | Headings, bullets, code and emphasis drawn as themselves rather than printed as notation | | **Documents, read and written** | Spreadsheets, Word, slide decks, PDFs and barcodes through io-harness's own tools — twelve of them, six of which write, every one under the same gate as any other read or write | @@ -109,9 +112,11 @@ scoop bucket add io-cli https://github.com/initorigin/io-cli scoop install io ``` -The tap and the bucket live in this repository rather than in two of their own, -which is why `brew tap` and `scoop bucket add` name a URL. They install the same -checksum-verified artifacts the scripts do. +The tap and the bucket live in this repository rather than in two of their own — +[`Formula/io.rb`](Formula/io.rb) and [`bucket/io.json`](bucket/io.json) — which is +why `brew tap` and `scoop bucket add` name a URL: this repository is not named +`homebrew-io-cli`, so neither tool can derive the URL from the name. They install +the same checksum-verified artifacts the scripts do. Both scripts pick the right build for your machine, **verify it against the published `SHA256SUMS` before unpacking it**, and install into a directory you diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index 58fcb72..ffc1bbb 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -28,11 +28,11 @@ With no subcommand, `io` opens an interactive session. | `io exec ""` | Runs one goal to completion with no terminal interaction | | `io resume` | Carries on a run that stopped for a question, a plan or an interrupted call; `--list` shows what is waiting | | `io mcp …` | Manage MCP servers without opening a session | -| `io plugin …` | Manage capability bundles and marketplaces | +| `io plugin …` | Manage capability bundles and marketplaces — the verbs are `add`, `install`, `list`, `search`, `remove` and `marketplace`. `io plugin marketplace add /` clones an index, `io plugin marketplace list` and `remove` are its other two, and `io plugin add ` installs a bundle out of one | | `io config …` | Read and write configuration keys | | `io skill …` | Add, list and remove skills | -| `io acp` | Serves the Agent Client Protocol on stdin and stdout, for an editor | -| `io upgrade` | Prints the command that updates this binary, for the way it was installed | +| `io acp` | Serves the Agent Client Protocol (ACP) on stdin and stdout, for an editor — an ACP client such as Zed or a JetBrains IDE spawns it; see [From an editor](guide/editors.md) | +| `io upgrade` | Prints the command that updates this binary, for the way it was installed: `brew upgrade io` from the tap this repository is, `scoop update io` from the bucket, and the installer line from a script install. It prints and does not run | `--plain` reaches an interactive session and stops there. `io exec` builds no theme, draws nothing and animates nothing already, so there is no second thing for the flag to switch off. @@ -62,7 +62,20 @@ to the existing `3` would have moved exactly the runs `6` was invented for. | `3` | CEILING | A step, time, cost or budget ceiling was reached, and nothing judged the work | | `4` | PAUSED | The run is waiting on a question, a plan, an approval or an interrupted call — resumable with `io resume`, except an approval | | `5` | UNFINISHED | The run ended in a state this table does not name | -| `6` | UNVERIFIED | The work was judged by a verification gate and did not hold up | +| `6` | UNVERIFIED | The work was judged and did not hold up — a verification gate that failed, or a run that never satisfied its output schema | + +**Two different failures reach `6`, and a caller that has to tell them apart reads stderr rather +than the code.** A verification gate is `[app.io-cli.gates]`, this crate's own key, and a failing +one is io-harness's `VerificationFailed`. **The schema contract is io-harness's `[run] +output_schema`** — io-cli defines none and sets none, and `Config::apply_to` applies the operator's +to a session turn and to both headless doors alike — and a run that never produces the shape it +asks for is `RunOutcome::SchemaUnsatisfied`. Both mean the same thing, which is why they share a +code: the work produced an answer, something checked it, and it did not hold up. The line +`io exec` writes to stderr is what separates them, and the schema one says the run *never produced +the shape its output schema asked for*. Neither is a ceiling — reporting either as `3` would send +an operator to raise `[run] max_steps`, which buys more attempts at the same failure. Over ACP +both are `max_turn_requests`, because such a run exhausted the attempts it was allowed rather than +refusing anything. Exit `4` names the `question_id`, `plan_id` or `attempt_id` that `io resume` needs — for three of the four pauses. **An approval is the fourth and it names no invocation**, because there is none: @@ -210,6 +223,13 @@ withheld is the moment you watch the agent reach for it. It passes the rule abov bending it: no file is written, nothing is reassigned, and the mask reaches the **next** turn because this one's contract was built before the keystroke. +**The mask changes what the agent may call and not what a turn costs.** io-harness sends the same +catalogue either way and io adds one sentence naming what is withheld, so a masked request is the +longer of the two. `/context allow ` takes one name back and `/context allow` on its own +clears the mask; a name that matches nothing the last request carried is kept, because io-harness +keeps an unknown name deliberately so a mask stays portable between builds, and io says so on the +line. + **`/config` is admitted bare and refused the moment it carries a word.** Through 0.32.0 it was refused in every form, because the bare list carried a row that re-read the provider's catalogue, wrote the prices into a scope file and reassigned the configuration the running turn was holding — diff --git a/src/cli.rs b/src/cli.rs index f3f1d02..f0ca571 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -27,9 +27,13 @@ pub struct Cli { /// /// `[profile.]` is io-harness's own — a profile body is the file /// format again, applied over the merged scopes through the same merge they - /// use. It has been in the harness since its 0.27.0 and no io-cli release - /// selected one until 0.16.0. + /// use. /// + // The history this comment used to carry — which harness release added + // profiles, and which io-cli release first selected one — is a changelog + // entry rather than a description of the flag, and being on a `global` arg it + // printed on every subcommand's `--help` page. A `--help` page describes the + // binary in front of the reader; git and `CHANGELOG.md` hold the rest. /// `global` for the reason `-C` and `-m` are: a set of choices you want for /// one run is exactly the thing you type on either side of a subcommand, and /// `io exec --profile ci "…"` is the shape CI reaches for. Nothing is @@ -51,10 +55,14 @@ pub struct Cli { /// which a status line that only ever repaints is a state nobody can read. /// /// `global` for the same reason `-C` and `-m` are: `io --plain exec "…"` and - /// `io exec --plain "…"` are one command, and 0.5.0 shipped a defect where - /// `-m` after the subcommand was rejected. A flag whose acceptance depends on + /// `io exec --plain "…"` are one command. A flag whose acceptance depends on /// which side of a word it is typed is a flag that works on the author's /// machine. + // + // The release that shipped exactly that defect for `-m` is why the sentence + // above is written down at all, and naming it belongs in the changelog: this + // help page is read by an operator holding *this* binary, for whom a defect in + // a version they are not running is noise between them and the flag. /// /// It reaches an interactive session and stops there. `io exec` constructs no /// theme, draws nothing and animates nothing already — see `crate::exec` — @@ -81,11 +89,21 @@ pub enum Command { Resume(Resume), /// Add, list, inspect, change or remove an MCP server, without opening a /// session. + /// + /// For example: `io mcp add semlith -- semlith --store mcp` declares a + /// server io starts itself, and `io mcp probe semlith` goes and checks that it + /// answers. Mcp(Manage), /// Add, list, search for or remove a capability bundle, and manage the /// marketplaces bundles come from, without opening a session. + /// + /// For example: `io plugin search review` prints one line per bundle any + /// marketplace holds, and its first field is what `io plugin add` takes. Plugin(Manage), /// Read or write one configuration key, without opening a session. + /// + /// For example: `io config get app.io-cli.theme` says what a key is set to and + /// which file decided it, and `io config set app.io-cli.theme dark` changes it. Config(Manage), /// Serve the Agent Client Protocol on stdin and stdout, for an editor. /// @@ -102,13 +120,20 @@ pub enum Command { Acp, /// Install, list or remove a skill, without opening a session. /// - /// **Missing until 0.30.1, and `io skill add` did not exist because of it.** - /// `manage::parse` accepted the surface, `manage::plan` answered for it and - /// the session's own arm ran it — but clap knows only the subcommands named - /// in this enum, so the argv door answered `unrecognized subcommand 'skill'` - /// for a verb the README and the CHANGELOG both documented. Nothing under - /// `tests/` links `src/main.rs` and nothing tested clap's routing, so the - /// whole suite was green over a door that did not open. + /// For example: `io skill add ./reviewer.md` copies one skill file into io's + /// home, and `io skill list` names every skill installed there. + // + // **This variant was once missing, and `io skill add` did not exist because of + // it.** `manage::parse` accepted the surface, `manage::plan` answered for it + // and the session's own arm ran it — but clap knows only the subcommands named + // in this enum, so the argv door answered `unrecognized subcommand 'skill'` for + // a verb the README and the CHANGELOG both documented. Nothing under `tests/` + // links `src/main.rs` and nothing tested clap's routing, so the whole suite was + // green over a door that did not open. + // + // Kept as a comment and not as documentation: it is why the next author must + // not delete a variant that "looks unused", and it is not something the + // operator reading `io skill --help` has any use for. Skill(Manage), /// Print the command that updates this binary, for the way it was /// installed. @@ -122,7 +147,7 @@ pub enum Command { Upgrade, } -/// The three management subcommands' arguments, handed through untouched. +/// The management subcommands' arguments, handed through untouched. /// /// **Every token, verbatim, straight to `crate::manage::parse`** — which is what /// makes `io mcp add …` and `/mcp add …` one parse rather than two that agree @@ -136,13 +161,22 @@ pub enum Command { /// own and the line dies before `manage` sees it — F7's whole subject. Nothing in /// `manage.rs` can compensate; the tokens have to arrive intact. /// -/// The flags are not documented here on purpose. `io mcp --help` shows this -/// paragraph and `manage`'s refusals name the accepted shapes at the point of -/// getting one wrong, which is where an operator is actually looking; a flag list -/// repeated in a doc comment is a second place for the grammar to drift. +/// The flags are not documented here on purpose. `manage`'s refusals name the +/// accepted shapes at the point of getting one wrong, which is where an operator +/// is actually looking; a flag list repeated in a doc comment is a second place +/// for the grammar to drift. (Nothing on this struct reaches a help page anyway — +/// clap takes a subcommand's description from the *variant* above, not from the +/// `Args` type behind it. Only the field below is printed.) #[derive(Debug, clap::Args)] pub struct Manage { - /// The verb and its arguments — `add semlith -- semlith --store mcp`. + /// The verb and its arguments. + // + // **No example here, and that is the whole of F9's first clause.** This one + // field is shared by four subcommands, so an example written in it is an + // example of *one* of them printed on the help page of all four — which is + // exactly what `io mcp`, `io plugin`, `io config` and `io skill` did, each + // showing the same `mcp add` line. Each subcommand's own example is in its + // variant's description above, where clap prints it once, on its own page. #[arg(trailing_var_arg = true, allow_hyphen_values = true)] pub words: Vec, } diff --git a/src/configure.rs b/src/configure.rs index 5b1f8ce..2196a49 100644 --- a/src/configure.rs +++ b/src/configure.rs @@ -17,6 +17,13 @@ //! default to a file the operator never wrote it in. That is a lie a reader //! cannot detect, so [`Decided::Default`] is its own answer and names no path. //! +//! And there is a *fifth* answer under that one, because "no file named it" is +//! two facts wearing one word: a key of io-harness's that nobody has set, and a +//! key that does not exist. [`Decided::Unknown`] is the second — no file sets it +//! and no [`CATALOGUE`] entry names it — and it exists because reporting +//! `default` for a misspelling told an operator checking a typo that their +//! setting was in force. +//! //! This crate has paid for that distinction once already: 0.15.0's //! `home::origin` reported `IO_CONFIG_HOME` for io-cli's own default because //! `adopt` had set the variable itself, crediting the operator for a choice they @@ -47,6 +54,25 @@ pub enum Decided { /// No file named it, so io-harness's own default is in force. This names no /// path on purpose. Default, + /// No file named it **and** no catalogue entry does either, so there is + /// nothing here to have a default of. + /// + /// **Split off [`Decided::Default`] because answering `default` for a key that + /// does not exist is a wrong answer, not a thin one.** `Config::origin` + /// returns an empty slice for a misspelling exactly as it does for a real key + /// no file sets, so the two were indistinguishable and `io config get + /// nonexistent.key` reported that io-harness's own default was in force — an + /// operator checking a typo was told their setting was fine. + /// + /// The test is [`CATALOGUE`] membership, which is deliberately narrower than + /// "a key io-harness would accept": io-harness publishes no enumeration of its + /// schema, so no surface here can be sure a key is *absent* from it. What this + /// says is what it can prove — nothing names this key and nothing sets it — + /// and the cost is that a real key outside the catalogue and outside every + /// file (`[app.io-cli.keys]`'s rebindings, say) reads the same way. That is + /// the better error of the two: it sends a reader to look, where `default` + /// sent them away satisfied. + Unknown, } impl Decided { @@ -68,6 +94,11 @@ impl Decided { .. } => "local", Decided::Default => "default", + // Three words rather than one, because there is no scope to name and a + // one-word placeholder in this column would read as one. It reaches an + // operator through `io config get`, which prints this word as the + // origin field, so it has to be the answer and not a label for one. + Decided::Unknown => "no such key", } } @@ -75,7 +106,7 @@ impl Decided { pub fn path(&self) -> Option<&std::path::Path> { match self { Decided::File { path, .. } => Some(path), - Decided::Default => None, + Decided::Default | Decided::Unknown => None, } } @@ -89,7 +120,12 @@ impl Decided { pub fn scope(&self) -> Option { match self { Decided::File { scope, .. } => Some(*scope), - Decided::Default => None, + // `Unknown` answers `None` for the same reason `Default` does, and it + // matters that it is not a refusal: writing a key io-cli's catalogue + // has never heard of is a thing an operator is allowed to do — see + // [`source_for`]'s `None` arm — and it goes to the file a defaulted + // key would go to. + Decided::Default | Decided::Unknown => None, } } } @@ -798,7 +834,10 @@ pub fn priced_models(config: &Config) -> Vec { pub fn destination(config: &Config, key: &str) -> (Scope, bool) { match setting(config, key).decided { Decided::File { scope, .. } => (scope, true), - Decided::Default => (Scope::User, false), + // A key no file names has nothing to inherit whether or not the catalogue + // knows it, so the two answer alike here. Refusing to write an uncatalogued + // key would be io-cli enforcing a schema it does not own. + Decided::Default | Decided::Unknown => (Scope::User, false), } } @@ -1071,8 +1110,19 @@ pub fn settings(config: &Config) -> Vec { /// /// A key no file names reads `not set` rather than an empty value, because an /// empty string is a value an operator can actually write. +/// +/// A key nothing names at all gets a different sentence entirely, and not the +/// shape above with a third word in the bracket: "`x` is not set (no such key)" +/// asserts that `x` is a setting in the same breath as denying it, which is the +/// sentence [`crate::manage`]'s refusals are written to avoid. #[must_use] pub fn said(setting: &Setting) -> String { + if setting.decided == Decided::Unknown { + return format!( + "there is no such key as {} — no file sets it and it is not a key io names", + setting.path + ); + } let what = setting.value.as_deref().unwrap_or("not set"); format!("{} is {what} ({})", setting.path, setting.decided.word()) } @@ -1087,7 +1137,14 @@ pub fn setting(config: &Config, key: &str) -> Setting { scope: origin.scope, path: origin.path.clone(), }, - None => Decided::Default, + // **A file is asked first, and the catalogue only decides the leftover.** + // The order is the load-bearing part: a key an operator actually wrote is + // a real key of io-harness's whatever this crate's catalogue knows, and + // [`settings`] lists exactly those on purpose. So [`Decided::Unknown`] is + // reachable only for a key that no file set *and* no catalogue entry + // names, which is the pair of facts it claims and nothing more. + None if CATALOGUE.contains(&key) => Decided::Default, + None => Decided::Unknown, }; let value = decided.path().and_then(|path| { diff --git a/src/events.rs b/src/events.rs index 0e0529c..d2be2a6 100644 --- a/src/events.rs +++ b/src/events.rs @@ -741,6 +741,11 @@ impl Events { tokens, changed, } => { + // **A child's outcome arrives as a Rust `Debug` and is not drawn + // as one (0.38.1).** See [`said_plainly`]. Rebound before + // anything reads it, so every use below — the pairing, the step + // line, the emptiness test — sees the same sentence. + let decision = &said_plainly(decision); // This step closing is the next one opening, and a thought // belonging to the next step is measured from here. self.step_at = at; @@ -1514,8 +1519,23 @@ impl Events { // folds it into the next prompt. `/expand` is where it goes. let elapsed = format_millis(at.saturating_sub(self.step_at)); self.thought = Some(text.clone()); - let mut lines = self.flush_text(); - lines.push(Line::from(vec![ + // **The thought line goes ABOVE the prose it produced, not under + // it (0.38.1).** This arm flushed the buffered prose first and + // then pushed the footer, which is right only if the event + // arrives before the tokens it explains. It does not: the + // 2026-09-05 field test found `thought · 2.3s` drawn *under* the + // final reply, and the same ordering in the JSON stream — the + // `reasoning` event follows the `token` it produced. So a reader + // met the answer, then a note saying the model had been thinking, + // which reads as a thought about the answer rather than the + // thinking that reached it. + // + // Reordering here is the whole fix and it is safe in both + // directions: a provider that streams reasoning first leaves + // `live` empty, so `flush_text` returns nothing and the order + // cannot be observed. Nothing is asked of io-harness, which is + // reporting what its provider gave it in the order it arrived. + let mut lines = vec![Line::from(vec![ Span::styled(leader(separator), theme.style(Tone::Muted)), // Italic, which is what a thought is: the model's own voice // rather than the interface's, and set apart from the tool @@ -1528,7 +1548,8 @@ impl Events { format!("{separator}{elapsed}{separator}{tokens} tok"), theme.style(Tone::Muted), ), - ])); + ])]; + lines.extend(self.flush_text()); lines } // **Nothing in this process dialled anything.** The provider ran its @@ -2371,6 +2392,78 @@ pub fn outcome_help(outcome: &str) -> Option<&'static str> { /// So the names are kept and the arguments are dropped. 0.1.1's F5 asks that a /// step read as decision, then what it ran, then what came back, and it still /// does — the tool cell above it is where the arguments live now. +/// A step's decision sentence with any Rust `Debug` in it written out as words. +/// +/// **io-harness formats a spawned child's outcome with `{outcome:?}`** +/// (`run/tree.rs`), so the sentence it hands this crate is literally +/// `spawned child 149: Success { steps: 1 }`. Drawn verbatim — which is what the +/// 2026-09-05 field test saw — the fleet result line shows a reader a struct +/// literal from a language they may not write, in the one row that is supposed +/// to tell them what their children did. +/// +/// **This is a rendering of io-harness's own sentence, not a second opinion about +/// it.** Every fact drawn comes out of the text the harness produced: the variant +/// name it chose and the fields it printed, re-spelled as words. Nothing is +/// looked up, inferred, or corrected — the same discipline `skill_and_file` keeps +/// when it reads `read skill {label}` out of a decision rather than deciding for +/// itself which skill loaded. +/// +/// It is general over the variant rather than a table of the eighteen, for the +/// reason the wildcard in `acp_map` is a documented hazard: a `RunOutcome` the +/// next pin adds would fall through a table and be drawn as a struct again, and +/// the shape of a `Debug` is the one thing that does not change when a variant +/// does. +/// +/// Only segments that begin `spawned child ` are touched. A decision sentence is +/// prose the harness wrote and rewriting all of it would be exactly the second +/// opinion this crate refuses. +fn said_plainly(decision: &str) -> String { + decision + .split("; ") + .map(|part| match part.split_once(": ") { + Some((head, rest)) if head.starts_with("spawned child ") => { + format!("{head} {}", words_of_debug(rest)) + } + _ => part.to_string(), + }) + .collect::>() + .join("; ") +} + +/// `Success { steps: 1 }` as `success, steps 1`; `Interrupted` as `interrupted`. +/// +/// The variant name is split on its own capitals and lowercased, the braces and +/// parentheses go, and `field: value` becomes `field value`. A shape this does +/// not recognise is returned with its punctuation stripped rather than guessed +/// at, because a sentence that has lost a brace is still readable and a sentence +/// this function invented is not. +fn words_of_debug(text: &str) -> String { + let text = text.trim(); + let (name, fields) = match text.find(['{', '(']) { + Some(at) => (text[..at].trim(), text[at + 1..].trim_end_matches(['}', ')']).trim()), + None => (text, ""), + }; + + let mut spelled = String::new(); + for (index, c) in name.char_indices() { + if c.is_ascii_uppercase() && index > 0 { + spelled.push(' '); + } + spelled.push(c.to_ascii_lowercase()); + } + + if fields.is_empty() { + return spelled; + } + let said = fields + .split(',') + .map(|pair| pair.trim().replacen(':', "", 1).trim().to_string()) + .filter(|pair| !pair.is_empty()) + .collect::>() + .join(", "); + format!("{spelled}, {said}") +} + fn tool_names(tool_call: &str) -> String { tool_call .split(" | ") diff --git a/src/export.rs b/src/export.rs index 720d9e5..b6e21df 100644 --- a/src/export.rs +++ b/src/export.rs @@ -152,9 +152,20 @@ pub fn conversation(store: &Store, session: &io_harness::Session) -> Result, + /// Whether this run is what made the home directory. + /// + /// **The field exists so the home line can stop being unconditional + /// (0.38.1).** See [`Report::lines`]: without it, "did anything happen" and + /// "where do the files live" are the same question, and the answer to the + /// second was printed on every invocation of every subcommand because the + /// first had no way to be false. + pub created: bool, } impl Report { - /// One line per file, then one naming the home. + /// One line per file, then one naming the home — when there is something to + /// report. + /// + /// **The home line was last and unconditional, and that made it the whole + /// output of every run that had nothing to say (0.38.1).** The reasoning was + /// that on a run which moved nothing it is the product answering "where does + /// it live" without being asked. What the 2026-09-05 field test found is that + /// almost every run moves nothing — the migration happens once, ever — so + /// `io keeps its files in …` printed above the output of `io config get`, of + /// every `io mcp list`, of every invocation in a shell loop. Answering an + /// unasked question once is a courtesy; answering it every time is noise on a + /// surface a script reads. /// - /// The home line is last and unconditional: on a run that moved nothing it is - /// the whole report, which is the product answering "where does it live" - /// without being asked. Lines rather than a paragraph because the session - /// commits each one into the scrollback and `io exec` writes each to stderr. + /// So it is drawn when this run **did** something: created the home, moved a + /// file into it, or left one behind. `/status` is where the path is answered + /// on demand, and it always was. + /// + /// Lines rather than a paragraph because the session commits each one into + /// the scrollback and `io exec` writes each to stderr. #[must_use] pub fn lines(&self) -> Vec { if let Some(blocked) = &self.blocked { @@ -180,7 +201,9 @@ impl Report { left.display() )); } - out.push(format!("io keeps its files in {}", self.home.display())); + if self.created || !self.moved.is_empty() || !self.kept.is_empty() { + out.push(format!("io keeps its files in {}", self.home.display())); + } out } } @@ -383,6 +406,12 @@ pub fn adopt() -> Option { // and there is nothing left to migrate from. let previous = io_harness::config::user_path(); + // Asked before `create`, for the obvious reason and one less obvious: a home + // that already exists is the ordinary case — the migration happens once, + // ever — and it is what makes the report's home line noise on every run + // afterwards. `create` is `create_dir_all`, which succeeds either way and so + // cannot answer this itself. + let created = !home.exists(); // Created BEFORE the variable is set, and the variable is not set at all if it // cannot be: pointing io-harness at a directory that does not exist and then // returning `None` would move the configuration path with nobody told, which @@ -402,6 +431,7 @@ pub fn adopt() -> Option { moved: Vec::new(), kept: Vec::new(), blocked: None, + created, }; // **The variable is set only once every file that had to move has moved**, and @@ -445,6 +475,10 @@ pub fn adopt() -> Option { moved: Vec::new(), kept: Vec::new(), blocked: Some(source), + // The blocked report has its own two lines and never reaches the + // home line, so this is inert either way — and `false` is the + // honest value: the home in force here is the one it always was. + created: false, }); } report.moved.push((source, target)); diff --git a/src/lock.rs b/src/lock.rs index bdbb426..7f4e121 100644 --- a/src/lock.rs +++ b/src/lock.rs @@ -440,6 +440,81 @@ pub fn acquire(home: &Path, session: i64, root: &Path, started: SystemTime) -> i Ok(Taken::Held(Guard { file, owner, wrote })) } +/// Remove the lock files of sessions that are over, and answer how many went. +/// +/// **The leak is real and the obvious fix is the one thing this module must not +/// do.** The 2026-09-05 field test found thirty-one `session-N.lock` files in +/// `~/.io-cli` with nothing removing them. [`Guard::drop`] already takes the +/// owner record away; the lock file is left, and [`LOCK`]'s own documentation +/// says why — unlinking a file another process holds a lock on releases nothing, +/// so the next opener creates a fresh inode, locks that, and two processes then +/// hold two different locks while each believes it is alone. Removing one's own +/// lock on exit reintroduces exactly that, in the window between the unlink and +/// the unlock. +/// +/// So the removal happens from the outside, at startup, and only where **both** +/// pieces of evidence say the session is over: +/// +/// * no owner record beside it — [`Guard::drop`] removes that first and always, +/// so its absence is the holder having gone; and +/// * `try_lock` succeeds — nobody holds it *now*. +/// +/// **The inode split cannot happen here, and the reason is a property of this +/// crate rather than of the filesystem.** That race needs a process *waiting* to +/// acquire, so that it lands on the unlinked inode after the unlink. Nothing in +/// io-cli ever waits: [`acquire`] calls `try_lock`, which returns `WouldBlock` +/// rather than queueing, and the one caller turns that into a refusal. A +/// concurrent `acquire` during the sweep is refused and creates no waiter. +/// +/// The unlink happens **while the lock is held**, which is what makes a +/// concurrent acquirer see `WouldBlock` rather than a free file. +/// +/// Best effort throughout: a file that cannot be locked or cannot be removed is +/// left alone. A sweep is housekeeping, and housekeeping that fails is a file +/// still on disk rather than a session that could not start. +pub fn sweep(home: &Path) -> usize { + let Ok(entries) = std::fs::read_dir(home) else { + return 0; + }; + let mut gone = 0; + for entry in entries.flatten() { + let path = entry.path(); + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + // Read back through [`paths`] rather than by editing the filename, so + // there is one speller for these two names and not two. A file under this + // stem whose middle is not a session id is not this module's and is left + // alone. + let Some(id) = name + .strip_prefix(STEM) + .and_then(|rest| rest.strip_suffix(&format!(".{LOCK}"))) + else { + continue; + }; + let Ok(session) = id.parse::() else { + continue; + }; + let (lock, owner) = paths(home, session); + // The owner record sits beside it. Its presence means a holder wrote it + // and has not dropped its guard. + if owner.exists() { + continue; + } + let Ok(file) = File::open(&lock) else { + continue; + }; + if file.try_lock().is_err() { + continue; + } + if std::fs::remove_file(&lock).is_ok() { + gone += 1; + } + let _ = file.unlock(); + } + gone +} + /// The record as it is on disk, or an empty one. /// /// A record that is missing, unreadable, or not valid UTF-8 is an [`Owner`] with diff --git a/src/main.rs b/src/main.rs index e3bb9ca..1ce9e25 100644 --- a/src/main.rs +++ b/src/main.rs @@ -582,6 +582,12 @@ async fn drive( // The only clock read on this path, and it is here because // `src/main.rs` is the one file `tests/timing.rs` permits one in. let now = std::time::SystemTime::now(); + // **The stale ones go before this one is taken (0.38.1).** Thirty-one + // empty `session-N.lock` files had accumulated in `~/.io-cli` with + // nothing removing them; `lock::sweep` removes those whose holder is + // provably gone, and explains why the removal cannot happen at exit. + // Before `acquire`, so a session never sweeps around its own file. + io_cli::lock::sweep(&home); match io_cli::lock::acquire(&home, session.id(), root, now) { Ok(io_cli::lock::Taken::Held(guard)) => Some(guard), // Not reachable for a session created a line ago, and said @@ -9262,7 +9268,15 @@ async fn wizard( inputs: &mut UnboundedReceiver, theme: Theme, ) -> Result, String> { - let mut wizard = Wizard::new(theme); + // **What this wizard is about to write over (0.38.1).** The welcome screen + // said "No configuration found, so this is the first run" whatever was on + // disk, so `io setup` over a 36 KB `io.toml` told the operator nothing of + // theirs was involved — and then wrote that file. The user scope is the one + // the wizard writes, so it is the one worth naming, and it is reported as in + // force only when it is actually there. + let mut wizard = Wizard::new(theme).over( + io_harness::config::user_path().filter(|path| path.is_file()), + ); // **What the catalogue read already returned, kept instead of thrown away.** // The wizard reads the provider's catalogue to offer a model list, and until // 0.22.0 mapped every row down to its id and dropped the price on it. Holding diff --git a/src/markdown.rs b/src/markdown.rs index 07a1f20..a1955cb 100644 --- a/src/markdown.rs +++ b/src/markdown.rs @@ -46,17 +46,27 @@ impl Markdown { let indent = &text[..text.len() - trimmed.len()]; // A fence opens and closes on its own line, and the line itself is not - // content. Drawn as the muted rule it is rather than dropped, so a reader - // can see where the code begins and ends. + // content — so it draws a blank row, which is where the code begins and + // ends. + // + // **The language tag used to be drawn on that row and it read as content + // (0.38.1).** An opening ```` ```python ```` committed the bare word + // `python`, muted, on the line above the code. The intent was to show a + // reader where the block starts; what a reader saw was a one-word + // paragraph, indistinguishable from the model having written the word + // `python` on its own line — which is what the 2026-09-05 field test + // reported it as. Muting is not a distinction at that width: a short + // muted line and a short prose line are the same shape. + // + // Dropped rather than decorated, because the boundary was never carried + // by the word. Everything inside the fence is drawn `Tone::Literal` and + // everything outside it is not, so where the code begins and ends is + // already on the screen in the only way that survives a narrow terminal, + // `--plain`, and `NO_COLOR`. The language is notation about the block and + // the reader is looking at the block. if trimmed.starts_with("```") || trimmed.starts_with("~~~") { self.fenced = !self.fenced; - let language = trimmed.trim_start_matches(['`', '~']).trim(); - let rule = if self.fenced && !language.is_empty() { - format!("{indent}{language}") - } else { - String::new() - }; - return Line::from(Span::styled(rule, theme.style(Tone::Muted))); + return Line::from(Span::styled(String::new(), theme.style(Tone::Muted))); } if self.fenced { return Line::from(Span::styled(text.to_string(), theme.style(Tone::Literal))); diff --git a/src/marketplace.rs b/src/marketplace.rs index 5df58e3..1130cf8 100644 --- a/src/marketplace.rs +++ b/src/marketplace.rs @@ -880,6 +880,24 @@ pub fn fetched( pub const NOWHERE: &str = "io has no home directory of its own, so there is nowhere to keep a marketplace"; +/// What io-cli says when [`matching`] found nothing. +/// +/// **A search that prints nothing is indistinguishable from a search that did not +/// run**, and the two are worth different next moves: one is "no bundle is called +/// that", the other is "the command is broken or the marketplace is empty". An +/// operator staring at a returned prompt has to guess which, and the guess that +/// costs them the most — `plugin search` is quietly broken — is the one a silent +/// answer invites. +/// +/// A function beside [`NOWHERE`] for the same reason that is a constant: two +/// surfaces say it, and the sentence they say has to be one sentence. It carries +/// the term back because a shell scrolls, and an answer that does not repeat the +/// question is an answer to whichever search the reader last remembers typing. +#[must_use] +pub fn nothing_matched(text: &str) -> String { + format!("no bundle in any marketplace matches `{text}`") +} + /// How an act ended. /// /// **Three endings and not a `bool`, because the two that did not change the disk diff --git a/src/sessions.rs b/src/sessions.rs index 2aafee5..147ba05 100644 --- a/src/sessions.rs +++ b/src/sessions.rs @@ -222,15 +222,31 @@ pub fn resume(store: &Store, id: i64) -> Result String { let cut: String = created_at.chars().take(16).collect(); - cut.replace('T', " ") + format!("{}{ZONE}", cut.replace('T', " ")) } /// The mark a session's row carries, or `None` for one that finished. diff --git a/src/wizard.rs b/src/wizard.rs index eb29d54..97130b5 100644 --- a/src/wizard.rs +++ b/src/wizard.rs @@ -19,6 +19,8 @@ //! catalogue — are handed back to the driver rather than performed here, so this //! whole flow is drivable from a test with a scripted key sequence. +use std::path::PathBuf; + use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use io_harness::ProviderSpec; use ratatui::layout::Rect; @@ -197,9 +199,36 @@ pub struct Wizard { /// variable also keeps the theme step drivable from a test that never has to /// set a process-wide variable. no_color: bool, + /// The configuration file already in force, when there is one. + /// + /// **The welcome screen claimed a first run unconditionally (0.38.1).** It + /// said "No configuration found, so this is the first run" to an operator + /// running `io setup` over a 36 KB `io.toml`, which the 2026-09-05 field test + /// found and which is worse than merely wrong: the sentence is what tells + /// somebody nothing of theirs is at stake, and the wizard goes on to write + /// that exact file. + /// + /// Set through [`Wizard::over`] rather than through `new`, because `new` has + /// fifteen call sites in the suite and one in the driver, and a parameter + /// added for the driver's benefit would be `None` at every other one anyway. + /// The default is the first-run wording, so the honest reading is something + /// the one real caller supplies — and + /// `f8_the_wizard_is_told_what_configuration_is_already_in_force` holds it to + /// that, because nothing under `tests/` links the driver. + existing: Option, } impl Wizard { + /// What configuration this wizard is about to write over, if any. + /// + /// See the field: this is the driver's job to supply, and the gate over + /// `src/main.rs` is what makes it not optional in practice. + #[must_use] + pub fn over(mut self, existing: Option) -> Self { + self.existing = existing; + self + } + pub fn new(theme: Theme) -> Self { Self { step: Step::Welcome, @@ -224,6 +253,7 @@ impl Wizard { rejection: None, env_key_present: false, no_color: !theme.coloured, + existing: None, } } @@ -397,11 +427,19 @@ impl Wizard { .map(|kind| Row::with_detail(kind.label(), kind.detail())) .collect(), )); + // **What is at stake, said correctly (0.38.1).** The first line claimed a + // first run whatever was on disk, so an operator running `io setup` over + // a configuration they had spent months on was told nothing of theirs was + // involved — and then the wizard wrote that same file. Naming the file is + // also more useful than the old sentence ever was on a genuine first run, + // where "no configuration found" and "this is the first run" say the same + // thing twice. + let opening = match &self.existing { + Some(path) => format!("Configuration in force: {}.", path.display()), + None => "No configuration found, so this is the first run.".to_string(), + }; Progress::Commit(vec![ - Line::from(Span::styled( - "No configuration found, so this is the first run.", - self.theme.style(Tone::Normal), - )), + Line::from(Span::styled(opening, self.theme.style(Tone::Normal))), Line::from(Span::styled( "Nothing is written until the last screen says so.", self.theme.style(Tone::Muted), diff --git a/tests/docs.rs b/tests/docs.rs index ddb99f5..33e6dd2 100644 --- a/tests/docs.rs +++ b/tests/docs.rs @@ -2582,3 +2582,662 @@ fn n3_no_shipped_page_claims_a_mask_reduces_what_a_turn_costs() { bad.join("\n"), ); } + +// --------------------------------------------------------------------------- +// 0.38.1 F10 — the front page and the contract page reach the five releases of +// work that happened after them. +// +// The README was 240 lines that predated 0.34.0. ACP — two releases of work and +// this crate's whole answer to an editor — was named once, in a row of the guides +// table. The tool mask, the schema contract and `RunOutcome::SchemaUnsatisfied` +// were named zero times, and the last of those is an exit a scripted caller +// branches on. Nothing failed, because nothing asked. +// +// These four gates are the asking. Each one is bound to something the page cannot +// edit: a constant, a parse, an outcome's own mapping, or a file on disk. A page +// that stops being true has to break one of them. +// --------------------------------------------------------------------------- + +/// **F10 — both front pages name the editor door, and the door is still there.** +/// +/// `io acp` is 0.36.0's adapter and 0.38.0's permission round trip, and until this +/// release an operator arriving at the repository could learn of it only from a +/// six-word cell in the guides table. Zed and a JetBrains IDE are the two clients +/// the protocol work was written against, so naming the protocol without naming +/// them leaves a reader unable to tell whether their editor is one of them. +/// +/// The transport claim is the one most able to go quietly wrong: ACP is +/// **newline-delimited** JSON-RPC 2.0 and not LSP's `Content-Length` framing, and a +/// writer who has met LSP first will write the other one. Both pages say the +/// newline form and neither offers the header form. +/// +/// Bound to the code twice. [`io_cli::acp::PROTOCOL_VERSION`] is the version the +/// server answers with, so a v2 that moved past these pages fails here; and +/// `src/acp.rs` really does raise `session/request_permission`, which is the +/// sentence 0.38.0 earned and the one a page could otherwise claim on its own. +/// +/// Sabotage: delete any needle from either page; bump `PROTOCOL_VERSION`; or take +/// the permission request out of `src/acp.rs`. Each fails on its own. +#[test] +fn f10_the_front_page_and_the_contract_name_the_editor_door() { + // The permission round trip exists in the code the prose is describing — + // `f2_the_install_discloses_before_it_writes_and_says_so`'s shape, for its + // reason: the prose half alone would pass if the feature were ripped out. + let acp = std::fs::read_to_string(repo().join("src/acp.rs")).expect("src/acp.rs exists"); + assert!( + acp.contains("\"session/request_permission\""), + "both pages say an editor session is asked for permission, and nothing in \ + src/acp.rs sends the request that asks", + ); + assert_eq!( + io_cli::acp::PROTOCOL_VERSION, + 1, + "the pages describe the v1 protocol; the server now answers a different \ + version and both are describing a wire this build does not speak", + ); + + let readme = flat(&read("README.md")); + for needle in [ + // The protocol, its framing, and the two clients — in one cell, because a + // reader deciding whether this product reaches their editor is asking one + // question. + "`io acp` serves the Agent Client Protocol (ACP) as newline-delimited JSON-RPC 2.0 over stdio", + "Zed or a JetBrains IDE", + // 0.38.0's half. Without it the row describes 0.36.0. + "arrives as a `session/request_permission` you answer in the editor", + ] { + assert!( + readme.contains(needle), + "README.md is missing {needle:?}. ACP is this crate's answer to an \ + editor and two releases of work; a front page that names it once in a \ + table row has not reached it", + ); + } + + let contract = flat(&read("docs/CONTRACT.md")); + for needle in [ + "Serves the Agent Client Protocol (ACP) on stdin and stdout", + "an ACP client such as Zed or a JetBrains IDE spawns it", + // Already on the page and asserted here so it cannot leave with the rest: + // the framing is the claim a rewrite is most likely to get wrong. + "speaks newline-delimited JSON-RPC 2.0 at its stdin", + "session/request_permission", + ] { + assert!( + contract.contains(needle), + "docs/CONTRACT.md is missing {needle:?}; `io acp` is a subcommand a \ + script and an editor both address and this is the page that enumerates \ + what may be depended on", + ); + } + + // And the framing nobody may claim: ACP is newline-delimited, and a page that + // said `Content-Length` would be describing LSP. + for (name, text) in shipped_prose() { + assert!( + !text.contains("Content-Length"), + "{name} describes the ACP transport with LSP's header framing; ACP \ + delimits frames with newlines and forbids an interior one", + ); + } +} + +/// **F10 — both front pages name the tool mask, in the direction that is true.** +/// +/// 0.37.0 built `/context withhold` and neither front page mentioned it. The gate +/// that already exists — `n3_no_shipped_page_claims_a_mask_reduces_what_a_turn_costs` +/// — forbids the wrong sentence everywhere, and a page that says nothing satisfies +/// it perfectly. This is the positive half: the pages have to carry the claim, and +/// carrying it is what puts them under that sweep at all. +/// +/// Bound to [`io_cli::context::withheld_line`], which is the sentence the product +/// itself prints. A release that changed the mask's cost story would change that +/// function, and the pages would then be describing something else. +/// +/// Sabotage: delete either page's mask sentence, or teach `withheld_line` to +/// promise a saving. Each fails on its own. +#[test] +fn f10_both_pages_name_the_tool_mask_and_say_what_it_does_not_do() { + // What the product says when a tool is withheld. The needle is the *direction* + // — the catalogue is unchanged — rather than the whole sentence, because the + // sentence is prose and gets reworded. + let mask = io_harness::ToolMask::withholding(["docx_write"]); + let line = io_cli::context::withheld_line(&mask, "—") + .expect("a non-empty mask earns the line that explains it"); + assert!( + line.contains("The catalogue above is unchanged"), + "`withheld_line` stopped saying the catalogue is unchanged, so the pages \ + below are describing a cost story the product no longer tells: {line}", + ); + + let readme = flat(&read("README.md")); + for needle in [ + "`/context withhold ` builds the session's tool mask", + "keeps that tool refused until `/context allow`", + // The honest half, which is the whole reason this row is hard to write. + "io-harness sends a byte-identical catalogue either way, so the request \ + grows by the one sentence naming what is withheld", + ] { + assert!( + readme.contains(needle), + "README.md is missing {needle:?}. The mask is 0.37.0's capability and \ + the front page named it zero times; a row that named it without the \ + second half would be the exact sentence N3 exists to forbid", + ); + } + + let contract = flat(&read("docs/CONTRACT.md")); + for needle in [ + "**The mask changes what the agent may call and not what a turn costs.**", + "io-harness sends the same catalogue either way and io adds one sentence \ + naming what is withheld, so a masked request is the longer of the two", + // The portability rule, which is why a misspelling is kept rather than + // refused — a script author reading this page is the person most likely to + // write a name against the wrong build. + "because io-harness keeps an unknown name deliberately so a mask stays \ + portable between builds", + ] { + assert!( + contract.contains(needle), + "docs/CONTRACT.md is missing {needle:?}; the mask is the one mid-turn \ + command that changes something, and this page already says so without \ + saying what it changes", + ); + } +} + +/// **F10 — both front pages name the schema contract and the outcome it produces.** +/// +/// `RunOutcome::SchemaUnsatisfied` is io-harness 0.77.0's, undeclared in its own +/// changelog, and `RunOutcome` is `#[non_exhaustive]` — so it arrived here silently +/// and `src/exec.rs` maps it to `6`. Every documented account of `6` said "a +/// verification gate", which is one of the two failures that reach it. A CI job +/// branching on `6` and reading the gate story goes looking for a gate it never +/// configured. +/// +/// The three claims are asked of the code, not of a second copy of the list: the +/// mapping from [`io_cli::exec::code`], the stderr wording from +/// [`io_cli::exec::describe`], and the fact that a gate failure lands on the same +/// number — which is what makes "read stderr to tell them apart" the honest advice +/// rather than a hedge. +/// +/// Sabotage: remap `SchemaUnsatisfied`, reword `describe`, or drop either page's +/// paragraph. Each fails on its own. +#[test] +fn f10_both_pages_name_the_schema_contract_and_the_outcome_that_reaches_six() { + let schema = io_harness::RunOutcome::SchemaUnsatisfied { steps: 3 }; + let gate = io_harness::RunOutcome::VerificationFailed { steps: 3 }; + + assert_eq!( + io_cli::exec::code(&schema), + io_cli::exec::UNVERIFIED, + "an unsatisfied output schema no longer exits `6`, so both pages now \ + document a code a caller will not see for it", + ); + assert_eq!( + io_cli::exec::code(&gate), + io_cli::exec::UNVERIFIED, + "a failed verification gate no longer exits `6`, so the pages' \"two \ + different failures reach it\" is one failure", + ); + + // The line that separates them, which is what both pages tell a caller to + // read. Quoted on the contract page verbatim, so the advice is followable. + let said = io_cli::exec::describe(&schema); + const STDERR: &str = "never produced the shape its output schema asked for"; + assert!( + said.contains(STDERR), + "`describe` stopped naming the schema failure in the words the contract \ + page quotes, so a caller grepping stderr for it finds nothing: {said}", + ); + + let readme = flat(&read("README.md")); + for needle in [ + "io-harness's `RunOutcome::SchemaUnsatisfied`", + "a run that never produced the shape `[run] output_schema` asked for", + "a verification gate that failed", + ] { + assert!( + readme.contains(needle), + "README.md is missing {needle:?}. Exit `6` is a number a scripted \ + caller branches on and the front page described only one of the two \ + ways to reach it", + ); + } + + let contract = flat(&read("docs/CONTRACT.md")); + for needle in [ + "**Two different failures reach `6`, and a caller that has to tell them \ + apart reads stderr rather than the code.**", + "**The schema contract is io-harness's `[run] output_schema`**", + "a run that never produces the shape it asks for is `RunOutcome::SchemaUnsatisfied`", + // The table cell itself, so a reader who only skims the table is not told + // that `6` means a gate and nothing else. + "| `6` | UNVERIFIED | The work was judged and did not hold up", + ] { + assert!( + contract.contains(needle), + "docs/CONTRACT.md is missing {needle:?}; this is the page a script \ + author reads and `6` had one of its two meanings written down", + ); + } + assert!( + contract.contains(STDERR), + "docs/CONTRACT.md tells a caller to read stderr to tell the two `6`s \ + apart, so it has to quote the line they will be reading", + ); +} + +/// **F10 — the tap and the marketplace appear with the command that uses them.** +/// +/// Both were mentions rather than paths. The tap and the bucket live in *this* +/// repository, which is why `brew tap` and `scoop bucket add` have to name a URL — +/// the repository is not `homebrew-io-cli`, so neither tool can derive one — and a +/// reader who copies a bare `brew install io` gets somebody else's formula. The +/// marketplace had its name on four pages and its verb on none of the two a reader +/// arrives at. +/// +/// Bound to disk and to the parse. The two files are asserted to exist, because a +/// tap whose formula is gone installs nothing; the upgrade commands are taken from +/// [`io_cli::upgrade`]'s own constants, so a renamed formula fails here rather than +/// leaving the README printing a command that updates nothing; and the marketplace +/// verb is fed through [`io_cli::manage::parse`], which is the same parse both the +/// slash surface and the shell reach. +/// +/// Sabotage: delete `Formula/io.rb`; change either upgrade constant; drop the +/// explicit-URL form from the install section; or remove the marketplace verb from +/// either page. Each fails on its own. +#[test] +fn f10_the_tap_and_the_marketplace_appear_with_the_command_that_uses_them() { + for path in ["Formula/io.rb", "bucket/io.json"] { + assert!( + repo().join(path).is_file(), + "{path} is what makes this repository its own tap, and the README \ + links it", + ); + } + + let readme = flat(&read("README.md")); + for needle in [ + // The explicit-URL form, both halves. `brew install io` alone resolves to + // core, which is not this product. + "brew tap initorigin/io-cli https://github.com/initorigin/io-cli", + "brew install initorigin/io-cli/io", + "scoop bucket add io-cli https://github.com/initorigin/io-cli", + "scoop install io", + // Why the URL is there, which is the part a reader otherwise reads as + // ceremony and drops. + "this repository is not named `homebrew-io-cli`", + // And the two files, linked rather than described. + "[`Formula/io.rb`](Formula/io.rb)", + "[`bucket/io.json`](bucket/io.json)", + ] { + assert!( + readme.contains(needle), + "README.md is missing {needle:?}; the tap is a path with a command, \ + and a reader who cannot copy it has been told about it rather than \ + given it", + ); + } + + // The upgrade lines, from the constants the binary prints. A formula renamed + // without these moving would leave both pages naming a command that updates + // nothing. + let contract = flat(&read("docs/CONTRACT.md")); + for command in [io_cli::upgrade::HOMEBREW, io_cli::upgrade::SCOOP] { + assert!( + readme.contains(&format!("`{command}`")), + "README.md should quote `{command}`, which is what `io upgrade` prints \ + for that install", + ); + assert!( + contract.contains(&format!("`{command}`")), + "docs/CONTRACT.md should quote `{command}`; `io upgrade` prints and \ + does not run, so what it prints is the whole of its contract", + ); + } + + // The marketplace verb reaches the parse both doors share, so the command the + // pages print is a command that is actually routed. + for line in [ + "plugin marketplace add initorigin/io-cli", + "plugin marketplace list", + "plugin marketplace remove initorigin/io-cli", + "plugin add ultraship", + ] { + assert!( + io_cli::manage::parse(&io_cli::manage::tokens(line)).is_ok(), + "both pages print `io {line}` and the parse both doors share refuses it", + ); + } + + for needle in [ + "`/plugin marketplace add /` clones an index into your own home", + "`/plugin add ` installs a bundle out of it", + "as `io plugin marketplace add` and `io plugin add`", + ] { + assert!( + readme.contains(needle), + "README.md is missing {needle:?}. A marketplace was named on the front \ + page without the verb that adds one, which leaves a reader knowing the \ + word and not the command", + ); + } + for needle in [ + "`io plugin marketplace add /` clones an index", + "`io plugin add ` installs a bundle out of one", + ] { + assert!( + contract.contains(needle), + "docs/CONTRACT.md is missing {needle:?}; the argv surface is what this \ + page exists to enumerate and `io plugin …` was an ellipsis", + ); + } +} + +// --------------------------------------------------------------------------- +// 0.38.1 F9 — a help page describes the binary in front of the reader, and an +// answer that is not an answer is not one. +// --------------------------------------------------------------------------- + +/// Every `--help` page the binary can print, by the command that prints it. +/// +/// Rendered rather than assembled out of `get_about` and `get_help`, because the +/// claim under test is about what an operator *sees*: a global flag's help is +/// reprinted on every subcommand's page, and a sweep that read each string once +/// would check a paragraph in one place while it shipped in nine. +fn help_pages() -> Vec<(String, String)> { + use clap::CommandFactory; + + let mut cli = io_cli::cli::Cli::command(); + let names: Vec = cli + .get_subcommands() + .map(|sub| sub.get_name().to_string()) + .collect(); + + let mut pages = vec![("io".to_string(), cli.render_long_help().to_string())]; + for name in names { + let page = cli + .find_subcommand_mut(&name) + .expect("a subcommand clap has just named") + .render_long_help() + .to_string(); + pages.push((format!("io {name}"), page)); + } + pages +} + +/// Every `..` literal in `text`. +/// +/// Three components and not two, which is the difference between a version and a +/// protocol: `io acp --help` says "JSON-RPC 2.0" and must go on saying it. +fn versions_named(text: &str) -> Vec { + text.split(|c: char| !(c.is_ascii_digit() || c == '.')) + .map(|token| token.trim_matches('.')) + .filter(|token| { + let parts: Vec<&str> = token.split('.').collect(); + parts.len() == 3 && parts.iter().all(|part| !part.is_empty()) + }) + .map(str::to_string) + .collect() +} + +/// **No `--help` page dates itself against a version this binary is not, and none +/// of them tells the reader a story about a release.** +/// +/// A `--help` page describes the binary in front of the reader. Every version +/// number on one is therefore either the pin — which the reader can act on — or a +/// claim about a build they are not running, which at best costs them the time to +/// work out that it does not apply. The field test of 0.38.0 found three: the +/// harness release that added `--profile`, the io-cli release that first selected +/// one, and the release whose `-m` defect is why `--plain` is a global flag. +/// +/// The pin is read from `Cargo.lock` through [`pinned_harness`] and never typed +/// here. A gate carrying the number goes stale on the next pin, which is the exact +/// defect class this one exists to remove. +/// +/// Sabotage: put back either sentence `src/cli.rs` used to carry on `--profile` or +/// on `--plain`. The failure names the page and the number. +#[test] +fn f9_no_shipped_help_names_an_unpinned_harness_or_narrates_a_release() { + // Narration that carries no number at all. The version sweep below is the wide + // net; this is for the sentence that says a release happened without saying + // which, which the sweep cannot see. + const NARRATION: &[&str] = &[ + "shipped a defect", + "did not exist because", + "was missing until", + "was green over", + "no io-cli release", + "in an earlier release", + "until this release", + ]; + + let pinned = pinned_harness(); + let pages = help_pages(); + assert!( + pages.len() > 5, + "only {} help pages were rendered, so this sweep is reading almost \ + nothing and is not checking what it claims", + pages.len(), + ); + + for (command, page) in &pages { + for version in versions_named(page) { + assert_eq!( + version, pinned, + "`{command} --help` names {version}, which is neither the pinned \ + io-harness ({pinned}) nor anything the operator holding this \ + binary can act on; a version on a help page is a claim about \ + what is in front of the reader", + ); + } + // Whitespace flattened first, because clap wraps a help page to the + // terminal's width: a phrase that fell across a line break would be + // missed, and the gate would be quietly reading a different string than + // the one an operator sees. + let said = page + .split_whitespace() + .collect::>() + .join(" ") + .to_lowercase(); + for phrase in NARRATION { + assert!( + !said.contains(phrase), + "`{command} --help` says {phrase:?}, which is a changelog entry \ + rather than a description of this binary; `CHANGELOG.md` and git \ + hold the history", + ); + } + } +} + +/// **No subcommand's help example names a subcommand other than its own.** +/// +/// One `Args` type is shared by `io mcp`, `io plugin`, `io config` and `io skill`, +/// so an example written into its field is an example of one of them printed on +/// the help page of all four — which is what the 0.38.0 field test found, with an +/// `mcp add` line under `io config --help`. The example belongs on the variant, +/// where clap prints it once, on its own page. +/// +/// The distinctness assertion is the one that would have caught the original: four +/// identical paragraphs is precisely the shape of a copy-paste, and every other +/// check here passes on it as long as the one string names its own subcommand. +/// +/// Sabotage: move any one example back onto the `words` field of `Manage`, or copy +/// `io mcp`'s paragraph onto `io config`. The first empties the list and fails on +/// the four names below; the second fails on distinctness and on the name. +#[test] +fn f9_no_subcommands_help_example_names_another_subcommand() { + use clap::CommandFactory; + + // The marker the examples are written behind. A convention rather than a + // grammar, and it is what makes this gate about *examples* — `io acp --help` + // mentions `io exec` in a sentence contrasting the two, which is a + // cross-reference an operator wants and not an example of the wrong command. + const MARKER: &str = "For example:"; + + let cli = io_cli::cli::Cli::command(); + let names: Vec = cli + .get_subcommands() + .map(|sub| sub.get_name().to_string()) + .collect(); + + let mut examples: Vec<(String, String)> = Vec::new(); + for sub in cli.get_subcommands() { + let name = sub.get_name().to_string(); + let about = sub + .get_long_about() + .or_else(|| sub.get_about()) + .map(|about| about.to_string()) + .unwrap_or_default(); + // Flattened per paragraph, for the reason the sweep above flattens a page: + // where the source broke its lines is not a fact about the help text, and a + // needle that fell across a break would be missed. + let paragraphs: Vec = about + .split("\n\n") + .map(|part| part.split_whitespace().collect::>().join(" ")) + .collect(); + for paragraph in paragraphs.iter().filter(|part| part.contains(MARKER)) { + for other in &names { + if *other == name { + continue; + } + assert!( + !paragraph.contains(&format!("io {other} ")), + "`io {name} --help` gives an example of `io {other}`, so three \ + readers out of four are being shown a command they did not \ + ask about:\n{paragraph}", + ); + } + assert!( + paragraph.contains(&format!("io {name} ")), + "`io {name} --help` carries an example that never types \ + `io {name}`:\n{paragraph}", + ); + examples.push((name.clone(), paragraph.clone())); + } + } + + // The four that share one `Args` type, by name, because a gate made of + // negatives passes on a binary that shows no examples at all. + for wanted in ["mcp", "plugin", "config", "skill"] { + assert!( + examples.iter().any(|(name, _)| name == wanted), + "`io {wanted} --help` shows no example, and it is one of the four \ + subcommands that share a single argument type — the place an example \ + cannot be written once for all of them", + ); + } + + for (i, (name, paragraph)) in examples.iter().enumerate() { + for (other, another) in &examples[i + 1..] { + assert_ne!( + paragraph, another, + "`io {name}` and `io {other}` show the same example word for word, \ + which is one subcommand's example on two pages", + ); + } + } +} + +/// **`io config get` on a key nothing names says there is no such key.** +/// +/// `Config::origin` returns an empty slice both for a real key no file sets and +/// for a misspelling, so through 0.38.0 both answered `default` — and an operator +/// checking a typo was told io-harness's own default was in force, which is a +/// wrong answer rather than a thin one. +/// +/// The two assertions that must keep holding are the point of the other half: a +/// catalogue key no file sets is still a default, and no row of the listing reads +/// as unknown. A key a *file* sets which the catalogue does not name is a real key +/// the operator wrote, `configure::settings` lists it deliberately, and it is +/// unreachable here by construction — `Decided::Unknown` is only chosen when +/// `origin` came back empty. +/// +/// Sabotage: drop the `CATALOGUE` test from `configure::setting` so every unset key +/// is `Unknown`. The listing assertion fails on the first catalogue row. +#[test] +fn f9_config_get_on_a_key_nothing_names_says_there_is_no_such_key() { + // No files at all, which is the fixture this needs: every key is one no file + // sets, so the catalogue is the only thing left deciding the answer. + let config = io_harness::config::Config::default(); + + let missing = io_cli::configure::setting(&config, "nonexistent.key"); + assert_eq!( + missing.decided.word(), + "no such key", + "`io config get nonexistent.key` prints this word as its origin field, and \ + `default` there claims a setting exists", + ); + assert_eq!( + missing.value, None, + "there is nothing to quote for a key no file names", + ); + assert!( + io_cli::configure::said(&missing).contains("no such key"), + "the session's own `/config ` says: {}", + io_cli::configure::said(&missing), + ); + + let unset = io_cli::configure::setting(&config, "run.max_retries"); + assert_eq!( + unset.decided.word(), + "default", + "a catalogue key no file sets is a real key running on io-harness's own \ + default, and calling it unknown would be a second wrong answer", + ); + + for row in io_cli::configure::settings(&config) { + assert_ne!( + row.decided.word(), + "no such key", + "the listing offers {} and then denies it exists", + row.path, + ); + } +} + +/// **`io plugin search` with no match says nothing matched.** +/// +/// A search that prints nothing is indistinguishable from a search that did not +/// run, and the two want different next moves — "no bundle is called that" against +/// "this command is broken". The argv door printed nothing at all through 0.38.0; +/// the session door said so in a `format!` of its own, which is the second +/// implementation this crate keeps finding disagreeing later. +/// +/// Both call sites are asserted, and that is the half that makes this a gate rather +/// than a sentence: `marketplace::nothing_matched` existing proves nothing about +/// what either door prints. +/// +/// Sabotage: delete either call in `src/main.rs`, or re-spell one of them as a +/// literal. +#[test] +fn f9_plugin_search_with_no_match_says_nothing_matched() { + let said = io_cli::marketplace::nothing_matched("nothing-is-called-this"); + assert!( + said.contains("nothing-is-called-this"), + "the answer does not repeat the term, so a scrolled terminal cannot say \ + which search it answers: {said}", + ); + assert!( + said.contains("marketplace"), + "the answer should say where it looked: {said}", + ); + + let main = read("src/main.rs"); + let calls = main.matches("marketplace::nothing_matched").count(); + assert!( + calls >= 2, + "only {calls} of the two doors say it. `io plugin search ` and the \ + session's `/plugin search ` both answer an empty search, and both \ + call `marketplace::nothing_matched` — a door that prints nothing leaves \ + the operator unable to tell an empty result from a broken command", + ); + assert!( + !main.contains("no bundle in any marketplace matches"), + "src/main.rs spells the sentence itself instead of calling \ + `marketplace::nothing_matched`, which is two answers to one question \ + agreeing only until one of them is edited", + ); +} diff --git a/tests/events.rs b/tests/events.rs index 5da2a89..bbf1635 100644 --- a/tests/events.rs +++ b/tests/events.rs @@ -3226,3 +3226,108 @@ fn f7_a_withheld_call_is_one_refused_cell_that_names_the_mask() { describes a call that ran and changed nothing: {whole:?}" ); } + +/// **F8 — a thought is drawn above the answer it produced, not under it.** +/// +/// The arm flushed the buffered prose first and pushed its footer after, which is +/// right only if the event arrives before the tokens it explains. It does not: +/// the 2026-09-05 field test found `thought · 2.3s` under the final reply, and +/// the same order in the JSON stream — the `reasoning` event follows the `token` +/// it produced. A reader met the answer and then a note saying the model had been +/// thinking, which reads as a thought *about* the answer rather than the thinking +/// that reached it. +/// +/// The fixture is the broken order on purpose: tokens first, then the reasoning. +/// Asserting the fixed order against the *already correct* order would pass +/// before the change as well as after, which is the shape that proves nothing. +/// +/// Sabotage: put `flush_text` back at the top of the arm and this fails. +#[test] +fn f8_a_thought_is_committed_above_the_prose_it_produced() { + let mut events = Events::new(DARK); + started_at(&mut events, Duration::from_secs(1)); + + // The provider streamed the answer, and only then said it had reasoned. + let _ = events.event( + &event(EventKind::Token { + text: "the parser is the only caller".into(), + }), + Duration::from_millis(3_000), + ); + let committed = rows(events.event( + &event(thought("checking the callers", 120)), + Duration::from_millis(3_500), + )); + + let thought_at = committed + .iter() + .position(|row| row.contains("thought")) + .unwrap_or_else(|| panic!("the thought row is committed: {committed:?}")); + let answer_at = committed + .iter() + .position(|row| row.contains("only caller")) + .unwrap_or_else(|| panic!("the buffered answer is committed with it: {committed:?}")); + assert!( + thought_at < answer_at, + "the thought must be drawn above the answer it produced: {committed:?}", + ); +} + +/// **F8 — a spawned child's outcome is words, not a struct literal.** +/// +/// io-harness formats it with `{outcome:?}`, so the sentence handed to this crate +/// is literally `spawned child 149: Success { steps: 1 }` and the fleet result +/// line drew it verbatim — a Rust struct in the one row meant to tell an operator +/// what their children did. +/// +/// Asserted over the punctuation rather than over one expected string, and over +/// several `RunOutcome` shapes rather than one, because the point is that no +/// `Debug` survives — including a variant a later pin adds, which is precisely +/// what a table of the eighteen would miss. +/// +/// Sabotage: drop the `said_plainly` rebinding in the `Step` arm and every row +/// with a brace in it fails. +#[test] +fn f8_a_spawned_childs_outcome_is_drawn_as_words() { + for debug in [ + "Success { steps: 1 }", + "VerificationFailed { steps: 9 }", + "SchemaUnsatisfied { steps: 3 }", + "Interrupted", + ] { + let mut events = Events::new(DARK); + started_at(&mut events, Duration::from_secs(1)); + let whole = rendered( + &mut events, + EventKind::Step { + decision: format!("spawned child 149: {debug}"), + tool_call: "spawn_agent:{}".into(), + tokens: 8_800, + changed: true, + }, + ); + + assert!( + !whole.contains('{') && !whole.contains('}'), + "a struct body reached the transcript for `{debug}`: {whole}", + ); + // And the facts survived the rewriting — this is a rendering of + // io-harness's own sentence, so losing the child or its step count would + // be worse than the punctuation it removes. + assert!( + whole.contains("spawned child 149"), + "the child is still named for `{debug}`: {whole}", + ); + if debug.contains("steps") { + let steps = debug + .split("steps: ") + .nth(1) + .and_then(|rest| rest.split(' ').next()) + .expect("the fixture states a step count"); + assert!( + whole.contains(&format!("steps {steps}")), + "the step count is still reported for `{debug}`: {whole}", + ); + } + } +} diff --git a/tests/home.rs b/tests/home.rs index 783671c..55a3a39 100644 --- a/tests/home.rs +++ b/tests/home.rs @@ -503,3 +503,69 @@ fn the_origin_word_is_the_variable_the_harness_reads() { io_harness::config::CONFIG_HOME_VAR ); } + +/// **F8 — the home line is drawn when this run did something, and not otherwise.** +/// +/// It was last and unconditional, on the reasoning that a run which moved nothing +/// is the product answering "where does it live" without being asked. What the +/// 2026-09-05 field test found is that almost every run moves nothing — the +/// migration happens once, ever — so `io keeps its files in …` printed above the +/// output of every `io config get`, every `io mcp list`, every invocation in a +/// shell loop. Answering an unasked question once is a courtesy; answering it +/// every time is noise on a surface a script reads. +/// +/// The four rows are the whole rule. The last is the one that matters: an adopted +/// home with nothing to report says **nothing at all**, not a shorter something. +/// +/// Sabotage: drop the condition in `Report::lines` and the last row fails; drop +/// `created` from it and the first row fails. +#[test] +fn f8_the_home_line_is_drawn_only_when_this_run_created_or_moved_something() { + let home = PathBuf::from("/tmp/io-cli-home"); + let report = |created: bool, moved: bool| home::Report { + home: home.clone(), + moved: if moved { + vec![(PathBuf::from("/old/io.toml"), home.join("io.toml"))] + } else { + Vec::new() + }, + kept: Vec::new(), + blocked: None, + created, + }; + let says_home = + |r: &home::Report| r.lines().iter().any(|line| line.contains("keeps its files in")); + + assert!( + says_home(&report(true, false)), + "the run that made the home is the one invocation that should say where it is", + ); + assert!( + says_home(&report(false, true)), + "a file moved is a migration, and a migration names the destination", + ); + assert!( + says_home(&report(true, true)), + "created and moved is still one report", + ); + assert!( + !says_home(&report(false, false)), + "an ordinary run moves nothing and made nothing, and printing the home \ + there is the noise on every invocation this criterion exists to remove", + ); + assert!( + report(false, false).lines().is_empty(), + "and it says nothing at all rather than something shorter", + ); + + // The kept case is a migration too — a file left behind is something the + // operator has to know about, and the home is where the one in force lives. + let kept = home::Report { + home: home.clone(), + moved: Vec::new(), + kept: vec![(PathBuf::from("/old/io.toml"), home.join("io.toml"))], + blocked: None, + created: false, + }; + assert!(says_home(&kept)); +} diff --git a/tests/lock.rs b/tests/lock.rs index 96130d2..e290b5e 100644 --- a/tests/lock.rs +++ b/tests/lock.rs @@ -536,3 +536,71 @@ fn n3_the_lock_and_its_record_are_readable_by_their_owner_alone() { drop(held); } + +/// **F7 — the sweep removes a finished session's lock and never a live one.** +/// +/// Asserted in both directions, because a sweep that removes a live lock is +/// worse than the leak it replaces: the leak is thirty-one empty files, and the +/// bug would be two `io` processes believing they are alone in one session. +/// +/// The live case is the one a naive implementation gets wrong. "No owner record" +/// is not on its own evidence that a session is over — it is also true for the +/// window before `acquire` writes the record, and true forever on a disk too full +/// to write one, which `acquire` deliberately treats as non-fatal. So the sweep +/// requires `try_lock` to succeed as well, and that is what this asserts by +/// holding the guard across the call. +/// +/// Sabotage: drop the `owner.exists()` check and the third row fails; drop the +/// `try_lock` check and the second fails. +#[test] +fn f7_the_sweep_takes_finished_locks_and_leaves_held_ones() { + let home = tempfile::tempdir().expect("a temporary home"); + let root = home.path().join("workspace"); + let now = SystemTime::now(); + + // 1. A session that finished: its guard is dropped, so the owner record is + // gone and nothing holds the lock. + let (finished_lock, finished_owner) = lock::paths(home.path(), 11); + match lock::acquire(home.path(), 11, &root, now).expect("the lock is takeable") { + Taken::Held(guard) => drop(guard), + Taken::Refused(_) => panic!("a fresh id cannot be refused"), + } + assert!( + finished_lock.exists(), + "the lock file outlives the guard, which is the leak this sweeps", + ); + assert!( + !finished_owner.exists(), + "the owner record is what `Guard::drop` already removes", + ); + + // 2. A session that is running: guard held across the sweep. + let (live_lock, live_owner) = lock::paths(home.path(), 22); + let held = match lock::acquire(home.path(), 22, &root, now).expect("the lock is takeable") { + Taken::Held(guard) => guard, + Taken::Refused(_) => panic!("a fresh id cannot be refused"), + }; + + // 3. A file under the stem that is not a session lock at all. + let stranger = home.path().join("session-notanumber.lock"); + std::fs::write(&stranger, b"").expect("the stranger is written"); + + let gone = lock::sweep(home.path()); + + assert_eq!(gone, 1, "exactly the finished session's lock was removed"); + assert!( + !finished_lock.exists(), + "the finished session's lock is what the sweep exists to take", + ); + assert!( + live_lock.exists() && live_owner.exists(), + "a lock a live process holds must survive the sweep — removing it splits \ + the inode and gives two processes two locks on one session", + ); + assert!( + stranger.exists(), + "a file this module did not name is not this module's to delete", + ); + + drop(held); +} diff --git a/tests/markdown.rs b/tests/markdown.rs index 0992646..6ec193c 100644 --- a/tests/markdown.rs +++ b/tests/markdown.rs @@ -168,3 +168,45 @@ fn a_rule_is_drawn_in_the_glyph_sets_own_character() { assert!(text(line).starts_with("──"), "{:?}", text(line)); assert!(!text(line).contains('-'), "{:?}", text(line)); } + +/// **F8 — a fence's language tag is not drawn as a line of its own.** +/// +/// An opening ```` ```python ```` committed the bare word `python`, muted, on the +/// row above the code. Muting is not a distinction at that width — a short muted +/// line and a short prose line are the same shape — so what the 2026-09-05 field +/// test saw was the model apparently writing the word `python` on its own line +/// above a code block. +/// +/// The boundary the tag was supposed to mark is carried by `Tone::Literal`, which +/// is on every row inside the fence and no row outside it, and which survives a +/// narrow terminal, `--plain` and `NO_COLOR` in a way a word does not. Both halves +/// are asserted here, because dropping the tag is only right if the styling is +/// genuinely doing the work. +/// +/// Sabotage: restore the `format!("{indent}{language}")` arm and the first +/// assertion fails. +#[test] +fn f8_a_fence_draws_no_bare_language_line() { + let mut md = Markdown::default(); + let opened = md.line("```python", &DARK); + assert!( + text(&opened).trim().is_empty(), + "the language tag was drawn as a line of its own: {:?}", + text(&opened), + ); + + // The block itself is literal, which is the boundary that replaced the word. + let inside = md.line("print('hi')", &DARK); + assert_eq!(text(&inside), "print('hi')"); + assert!( + inside.spans.iter().any(|span| span.style != DARK.style(io_cli::theme::Tone::Normal)), + "code inside a fence must not be styled as ordinary prose, or nothing \ + marks where the block is", + ); + + // Closing returns to prose, so the fence state still tracks. + let closed = md.line("```", &DARK); + assert!(text(&closed).trim().is_empty()); + let after = md.line("back to prose", &DARK); + assert_eq!(text(&after), "back to prose"); +} diff --git a/tests/wizard.rs b/tests/wizard.rs index 431e984..7c41c19 100644 --- a/tests/wizard.rs +++ b/tests/wizard.rs @@ -940,3 +940,46 @@ fn lines(wizard: &Wizard, newline: Newline) -> Vec { }) .collect() } + +/// **F8 — the welcome screen says what is at stake, and not always the same thing.** +/// +/// It said "No configuration found, so this is the first run" whatever was on +/// disk. The 2026-09-05 field test ran `io setup` on a machine with a 36 KB +/// `io.toml` and was told exactly that — and the wizard goes on to write that +/// same file. The sentence is the one that tells somebody nothing of theirs is at +/// stake, which makes being wrong about it worse than being silent. +/// +/// Both directions are asserted, because a fix that only ever printed the new +/// wording would be the same defect pointing the other way: an operator with no +/// configuration would be told a file is in force. +/// +/// Sabotage: drop the `existing` arm from `welcome` and the first half fails; +/// make `over` a no-op and the first half fails the same way. +#[test] +fn f8_the_welcome_screen_names_the_configuration_already_in_force() { + let commit = |wizard: &mut Wizard| match wizard.key(key(KeyCode::Enter)) { + Progress::Commit(lines) => lines + .iter() + .flat_map(|line| line.spans.iter().map(|span| span.content.to_string())) + .collect::(), + other => panic!("the welcome screen commits its lines: {other:?}"), + }; + + let mut told = Wizard::new(DARK).over(Some(std::path::PathBuf::from("/home/me/.io-cli/io.toml"))); + let said = commit(&mut told); + assert!( + said.contains("/home/me/.io-cli/io.toml"), + "the screen must name the file it is about to write over: {said}", + ); + assert!( + !said.contains("first run"), + "and must not call it a first run when a configuration is in force: {said}", + ); + + let mut fresh = Wizard::new(DARK).over(None); + let said = commit(&mut fresh); + assert!( + said.contains("first run"), + "an operator with no configuration is genuinely on their first run: {said}", + ); +} From 3554dd45438b8756785df064524e48778e974771 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sat, 5 Sep 2026 19:33:49 +0530 Subject: [PATCH 02/10] chore(packaging): point the tap and the bucket at 0.38.1 --- Formula/io.rb | 12 ++++++------ bucket/io.json | 8 ++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Formula/io.rb b/Formula/io.rb index 1721c39..29fb974 100644 --- a/Formula/io.rb +++ b/Formula/io.rb @@ -32,13 +32,13 @@ class Io < Formula on_macos do if Hardware::CPU.arm? - url "https://github.com/initorigin/io-cli/releases/download/v0.38.0/io-0.38.0-aarch64-apple-darwin.tar.gz" - sha256 "e38a9a4ac49b6149049d19b2d1398d42da6ed33f3b5d3c5f9f4f5933b725dc79" + url "https://github.com/initorigin/io-cli/releases/download/v0.38.1/io-0.38.1-aarch64-apple-darwin.tar.gz" + sha256 "ba2cc2b821c238686c835a886b5eb4eb392365b87873ffcd5960ef6560aa73d0" end if Hardware::CPU.intel? - url "https://github.com/initorigin/io-cli/releases/download/v0.38.0/io-0.38.0-x86_64-apple-darwin.tar.gz" - sha256 "2f7f8923a6c918a4f8bae9fc33f6c31888a91456a2184daf2eb86978a6777b68" + url "https://github.com/initorigin/io-cli/releases/download/v0.38.1/io-0.38.1-x86_64-apple-darwin.tar.gz" + sha256 "2d273e01d430de2d6f4d70f44c393d282646f421724fa78b5f4d023008d5efe7" end end @@ -47,8 +47,8 @@ class Io < Formula # rather than a URL that 404s halfway through an install. on_linux do if Hardware::CPU.intel? - url "https://github.com/initorigin/io-cli/releases/download/v0.38.0/io-0.38.0-x86_64-unknown-linux-musl.tar.gz" - sha256 "26a6e918526800047ada4050b5e6e296ee45c1a4ab5881d46741fc520b015a0c" + url "https://github.com/initorigin/io-cli/releases/download/v0.38.1/io-0.38.1-x86_64-unknown-linux-musl.tar.gz" + sha256 "2e99e143d1c0803d7e07e005146395e82d7ed20a4d883fc4bbb27be634d4040e" end end diff --git a/bucket/io.json b/bucket/io.json index a557176..f518825 100644 --- a/bucket/io.json +++ b/bucket/io.json @@ -1,13 +1,13 @@ { - "version": "0.38.0", + "version": "0.38.1", "description": "Terminal agent that shows what it may do, what it spends and what it refused", "homepage": "https://github.com/initorigin/io-cli", "license": "Apache-2.0", "architecture": { "64bit": { - "url": "https://github.com/initorigin/io-cli/releases/download/v0.38.0/io-0.38.0-x86_64-pc-windows-msvc.zip", - "hash": "6ec1b0db26a25188e49c5c7d20084ef3c5538b50be18ab74692f06eaaf4859c3", - "extract_dir": "io-0.38.0-x86_64-pc-windows-msvc" + "url": "https://github.com/initorigin/io-cli/releases/download/v0.38.1/io-0.38.1-x86_64-pc-windows-msvc.zip", + "hash": "f3cb3c0d6b31a8d37d9603e9faf1b38a8056c1558f0299cf2b6ac4eab689cbbf", + "extract_dir": "io-0.38.1-x86_64-pc-windows-msvc" } }, "bin": "io.exe", From a06fb7b78217e2738c8edbcde0580e2939ecee05 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 6 Sep 2026 13:14:31 +0530 Subject: [PATCH 03/10] chore(deps): pin io-harness 0.81.0 and re-anchor every source citation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.81.0 declares one behaviour break and it is Linux-only: the Landlock rung no longer grants the whole system temporary directory. This crate declares no writable root and its contained runs write inside their own workspace, so nothing here changes — stated rather than assumed, because an assumption is what this release exists to delete. Three kinds arrive with the pin and none of them fails to compile, because EventKind is non_exhaustive and a kind with no row falls through the wildcard drawn by nothing. triage::TRIAGE and acp_map::MAPPING gain context_ceiling, step_usage and image_attached; acp_map::TOOL_KINDS gains expand_tools. Two of the three dispositions are Silent and say why: the cached-versus-fresh split is a footer this release does not build, and a picture the run was handed needs a renderer rather than a line naming a picture it cannot show. Thirty-two io-harness-0.79.0 citations move to 0.81.0 with every cited line re-read against the new tree rather than the version string rewritten. Twelve had moved and were re-derived by finding the code the sentence is about; zero claims turned out to be false. Three more citations of withheld_sentence had no version prefix at all, so the gate had never seen them — and all three were already pointing at a closing brace rather than at the function. They are prefixed now, which puts them under the gate for the first time. --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- src/acp_map.rs | 38 ++++++++++++++++++++++++++++++++ src/adapt.rs | 12 +++++----- src/commands.rs | 6 ++--- src/configure.rs | 16 +++++++------- src/contract.rs | 6 ++--- src/home.rs | 2 +- src/import.rs | 2 +- src/manage.rs | 2 +- src/memory.rs | 4 ++-- src/reload.rs | 2 +- src/triage.rs | 51 +++++++++++++++++++++++++++++++++++++++++++ tests/adapt.rs | 2 +- tests/contract.rs | 2 +- tests/hooks.rs | 2 +- tests/instructions.rs | 2 +- tests/live.rs | 2 +- tests/reload.rs | 2 +- 19 files changed, 124 insertions(+), 35 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5ec879e..9577264 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1867,9 +1867,9 @@ dependencies = [ [[package]] name = "io-harness" -version = "0.79.0" +version = "0.81.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73599a2de1b1bf3630c72e24836f01354505d60522dc8f6d09e179687ad486f7" +checksum = "86e2c7ea3bc197d56862b6acf2c65ee32c07e7096174cf11233f56a328f48b7a" dependencies = [ "base64 0.23.1", "calamine", diff --git a/Cargo.toml b/Cargo.toml index 49cd8c2..78bdd6e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,7 +63,7 @@ path = "src/lib.rs" # still correct about what this *renderer* can be handed; it is simply no longer # load-bearing on the build. Turning `barcode` back off is what would restore # it, and `documents` would then have to be spelled out as its other four. -io-harness = { version = "0.79", features = ["media", "browser", "documents"] } +io-harness = { version = "0.81", features = ["media", "browser", "documents"] } # **`tui-textarea` is gone as of 0.18.0, and it is why this pair was frozen.** # The comment that stood here said ratatui stayed at 0.29 and crossterm at 0.28 # because the composer was built out of that widget and the 0.30 line carried diff --git a/src/acp_map.rs b/src/acp_map.rs index 4296e79..fd70e07 100644 --- a/src/acp_map.rs +++ b/src/acp_map.rs @@ -81,6 +81,16 @@ pub const MAPPING: &[(&str, Update, &str)] = &[ Update::None, "the client already knows: it sent the prompt that started this", ), + // 0.81.0, declared immediately after `started` and placed there, per this + // table's ordering rule. + ( + "context_ceiling", + Update::None, + "the window this run assembles inside is io-cli's own status line and its \ + `/context` page; ACP has no session-capacity notification, and the \ + nearest thing — a plan or an agent message — would put a number in the \ + conversation that is not part of it", + ), ( "recovery_paused", Update::ToolCallUpdate, @@ -98,6 +108,22 @@ pub const MAPPING: &[(&str, Update, &str)] = &[ "a per-step timing breakdown, which ACP has no vocabulary for; `io exec \ --json` and `Store::step_attributions` carry it, as `triage` records", ), + // Both 0.81.0, in declaration order: `step_usage` then `image_attached`, + // between `step_attributed` and `tool_call`. + ( + "step_usage", + Update::None, + "a per-step token breakdown, which ACP has no vocabulary for either — and \ + for the same reason as its neighbour above rather than a new one", + ), + ( + "image_attached", + Update::None, + "the image was handed to the run inside io-harness — an MCP reply, a \ + browser screenshot, `view_image` — and is already part of whatever \ + `tool_call` produced it. A second notification carrying the same picture \ + would draw it twice in a client that renders both", + ), ( "tool_call", Update::ToolCall, @@ -432,6 +458,18 @@ pub const TOOL_KINDS: &[(&str, &str)] = &[ ("ask_question", "other"), ("ask_questions", "other"), ("send_message", "other"), + // 0.81.0, and `other` is decided rather than defaulted, on the same standard + // as the three above. `expand_tools` asks the harness to put a withheld tool + // family into the next step's catalogue: it touches no file, runs no program + // and fetches nothing, so seven of the nine are simply wrong. `think` is the + // near miss and is the one worth arguing — a client folds that kind by + // default, which is the right *treatment* for bookkeeping — but it would file + // a request about the run's own shape beside `propose_plan` and `todo_write`, + // where a reader opening the fold is looking for the model's reasoning about + // the task and would find a capability request instead. Declared + // unconditionally by `tools/mod.rs`, so the row is needed whether or not a run + // ever turns tiering on; this crate does not set `tool_tiers`. + ("expand_tools", "other"), ]; /// Is `name` a tool this adapter has classified deliberately? diff --git a/src/adapt.rs b/src/adapt.rs index ba9b06e..2ee80b0 100644 --- a/src/adapt.rs +++ b/src/adapt.rs @@ -70,7 +70,7 @@ //! `self.root.join(d)` and `Path::join` of an absolute argument discards the base. //! io-harness 0.74.0 refuses that spelling for `skills` and for `templates` in //! **every** scope, `Scope::User` included -//! (`io-harness-0.79.0/src/plugin.rs:1097`), and the reason is the one this +//! (`io-harness-0.81.0/src/plugin.rs:1097`), and the reason is the one this //! product would give: every `*.md` under such a directory is read into the //! model's system prompt on every turn, so a manifest contributes a directory it //! *ships* rather than one it points at somewhere else on the machine. Copying is @@ -377,7 +377,7 @@ pub const SKILLS_DIR: &str = "skills"; /// io-harness calls the same thing a **template**, and the two formats are the /// same file: markdown, optional frontmatter, `$ARGUMENTS` for what the operator /// typed. `Templates::discover` takes every `*.md` in a directory -/// (`io-harness-0.79.0/src/template.rs:279`), so what is translated is the +/// (`io-harness-0.81.0/src/template.rs:279`), so what is translated is the /// directory and never the files in it — the bytes are copied verbatim into /// [`TEMPLATES_DIR`] and nothing in them is rewritten. pub const COMMANDS_DIR: &str = "commands"; @@ -578,7 +578,7 @@ pub fn at(root: &Path, owner: &str, repo: &str, name: &str) -> PathBuf { /// /// **The two directories are copied into `into` and named relative to it.** /// io-harness 0.74.0 refuses an absolute or a `..`-carrying `skills` or -/// `templates` in every scope (`io-harness-0.79.0/src/plugin.rs:1097`), because +/// `templates` in every scope (`io-harness-0.81.0/src/plugin.rs:1097`), because /// every `*.md` under one is read into the model's system prompt — so a bundle /// contributes a directory it ships. An adapter comes to ship one by copying it, /// and [`Adapter::copied`] reports which moved. The copy refuses a symbolic link @@ -714,7 +714,7 @@ pub fn generate(bundle: &Path, name: &str, into: &Path) -> Result Option { /// adapted, so expanding it reads nothing of the host — it writes down a path io /// already knew. `${env:…}`, `${file:…}`, `${cmd:…}` and every shell variable a /// bundle writes are the opposite: io-harness refuses each of them inside a -/// manifest in every scope (`io-harness-0.79.0/src/plugin.rs:977`), because a +/// manifest in every scope (`io-harness-0.81.0/src/plugin.rs:977`), because a /// bundle is a third party's directory even when the file naming it is the /// operator's own. /// @@ -1252,7 +1252,7 @@ fn unquoted(said: &str) -> &str { /// /// **A transport io-harness does not speak is a refusal naming the server**, not /// a server quietly left out. io-harness speaks stdio and streamable HTTP -/// (`io-harness-0.79.0/src/mcp.rs:301`), so a bundle declaring `sse` is declaring +/// (`io-harness-0.81.0/src/mcp.rs:301`), so a bundle declaring `sse` is declaring /// a capability no adapter can deliver — and a manifest carrying one fewer server /// than the bundle does is exactly the silent absence this module's docs exist to /// end. The whole generation stops, because a bundle is installed for what it diff --git a/src/commands.rs b/src/commands.rs index d5e142e..e0c343f 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1489,7 +1489,7 @@ pub const READ_MARK: &str = "+"; /// The case this page exists for. A file that is there and is not read looks, /// from everywhere else in this product, exactly like one that is read: nothing /// warns, and a missing or skipped instruction file is passed over in silence -/// (`read_instructions`, `io-harness-0.79.0/src/config.rs:2997`). +/// (`read_instructions`, `io-harness-0.81.0/src/config.rs:3120`). pub const UNREAD_MARK: &str = "-"; /// What one row of the memory page stands for. @@ -1705,8 +1705,8 @@ pub fn memory_notes(view: &crate::recall::View, glyphs: &crate::glyphs::Glyphs) /// writing: a file that exists and is not read. It names what would make it /// read rather than only reporting the fact, because `[instructions] files` /// **replaces** the default list rather than adding to it -/// (`DEFAULT_INSTRUCTIONS`, `io-harness-0.79.0/src/config.rs:190`, read at -/// `:3006`) and there is nothing on any +/// (`DEFAULT_INSTRUCTIONS`, `io-harness-0.81.0/src/config.rs:190`, read at +/// `:3129`) and there is nothing on any /// other surface to pull on. pub fn instruction_said( file: &crate::memory::Instruction, diff --git a/src/configure.rs b/src/configure.rs index cfd426d..550a49f 100644 --- a/src/configure.rs +++ b/src/configure.rs @@ -291,7 +291,7 @@ pub enum Kind { /// /// **Both halves are the dependency's since io-harness 0.71.0, and neither is /// written here any more**: the list is `Effect::ALL` -/// (`io-harness-0.79.0/src/policy.rs:129`) and each spelling is `Effect::as_str` +/// (`io-harness-0.81.0/src/policy.rs:129`) and each spelling is `Effect::as_str` /// (`:145`), which is the word io-harness's own deserializer reads. /// /// Until this release io-cli held a copy of both — an array naming three variants @@ -318,7 +318,7 @@ pub fn effects() -> Vec { /// The `ExecMode` variants, spelled by io-harness itself. /// -/// **The list is `ExecMode::ALL` (`io-harness-0.79.0/src/sandbox.rs:453`) and the +/// **The list is `ExecMode::ALL` (`io-harness-0.81.0/src/sandbox.rs:453`) and the /// spellings are `ExecMode::as_str` (`:460`).** io-cli wrote the variant list out /// by hand until this release for a reason that was the dependency's and not a /// choice made here: `ExecMode` is `#[non_exhaustive]` (`sandbox.rs:407`), and @@ -692,7 +692,7 @@ pub fn kind_of(key: &str) -> Option { /// preference — but half of the old reason is now false and the correction is /// worth writing down.** io-harness 0.71.0 names its own defaults: /// `DEFAULT_MAX_STEPS` = 8, `DEFAULT_WORKSPACE_MAX_STEPS` = 12 and -/// `DEFAULT_MAX_RETRIES` = 2 (`io-harness-0.79.0/src/contract.rs:704,722,738`), +/// `DEFAULT_MAX_RETRIES` = 2 (`io-harness-0.81.0/src/contract.rs:755,773,789`), /// re-exported at the crate root. "There is nothing to read" was true when this /// was written and is not true now. What is still true is that none of it anchors /// *this* ladder: @@ -843,7 +843,7 @@ pub fn shape_of(key: &str, config: &Config) -> Option { /// The models `[prices.models]` names, across every scope, sorted and deduplicated. /// /// **Read from the dependency's own table since io-harness 0.71.0, not scraped -/// out of the files.** `PriceTable::models` (`io-harness-0.79.0/src/pricing.rs:268`) +/// out of the files.** `PriceTable::models` (`io-harness-0.81.0/src/pricing.rs:268`) /// lists every model the table can actually price, and [`Config::prices`] has /// always built that table out of the three scopes — so the merged question this /// used to hand-roll is precisely the one the accessor answers, and the gap filed @@ -869,7 +869,7 @@ pub fn shape_of(key: &str, config: &Config) -> Option { /// /// **This takes the `Config` the caller already holds, and must never re-discover /// one.** `Config::discover` resolves every `${env:}`, `${file:}` and `${cmd:}` as -/// it reads (`io-harness-0.79.0/src/config.rs:627`), so a second discovery re-runs +/// it reads (`io-harness-0.81.0/src/config.rs:627`), so a second discovery re-runs /// an operator's credential commands — which for a `${cmd:}` fetching a key out of /// a keychain means a Touch-ID prompt raised in order to draw a menu, every time /// the picker opens. Taking a `&Config` is not an optimisation; it is the @@ -950,7 +950,7 @@ pub fn destination(config: &Config, key: &str) -> (Scope, bool) { #[must_use] pub fn widens_workspace(key: &str, value: &str) -> bool { /// The clause io-harness's widening refusal always carries - /// (`io-harness-0.79.0/src/config.rs:2826`). Matched rather than the whole + /// (`io-harness-0.81.0/src/config.rs:2949`). Matched rather than the whole /// sentence, which interpolates the path, the key and the destination scope. const WIDENS: &str = "widens the boundary"; @@ -1085,8 +1085,8 @@ fn is_credential(path: &str) -> bool { /// /// **There are three substitution forms and not two.** io-harness resolves /// `${env:...}`, `${file:...}` **and** `${cmd:...}` -/// (`substitute`, `io-harness-0.79.0/src/config.rs:3036`, the `cmd` arm at -/// `:3156`); this comment claimed two until +/// (`substitute`, `io-harness-0.81.0/src/config.rs:3159`, the `cmd` arm at +/// `:3279`); this comment claimed two until /// 0.21.0, and the sentence it claimed it in was the argument for which forms /// pass through here. The third is deliberately not one of them: a `${env:}` or /// `${file:}` reference is a *name*, and the name is the whole of what an diff --git a/src/contract.rs b/src/contract.rs index b047516..b447662 100644 --- a/src/contract.rs +++ b/src/contract.rs @@ -500,7 +500,7 @@ pub fn buying(contract: TaskContract, effort: Option) -> Tas /// sits ahead of a cache breakpoint and removing a definition would save its /// tokens once and pay a cache *write* on every later turn (`src/tools/mod.rs:40`). /// A mask in fact **adds** a sentence to the user prompt naming the withheld tools -/// (`src/run/prompts.rs:1133`, `withheld_sentence`), placed after the observations +/// (`io-harness-0.81.0/src/run/prompts.rs:1381`, `withheld_sentence`), placed after the observations /// precisely so it costs no cache entry. A turn that withholds three tools is /// marginally more expensive than the same turn without the mask, not less. #[must_use] @@ -657,7 +657,7 @@ pub fn skills_dir(config: &Config, capabilities: &Capabilities, root: PathBuf) - /// **The existence test is not caution, it is the whole of what makes this /// default safe.** `Skills::discover` does not return early on a directory that /// is not there — it returns `Error::Config("skills directory … does not exist")` -/// (`io-harness-0.79.0/src/skills.rs`), and `TaskContract::discover_skills` +/// (`io-harness-0.81.0/src/skills.rs`), and `TaskContract::discover_skills` /// propagates it from `run.rs` at run start, before the first completion. A /// contract that named this directory unconditionally would therefore fail every /// turn of every operator who has never made one, which is almost all of them. @@ -682,7 +682,7 @@ fn default_skills() -> Option { /// /// **One expansion for two keys, applied after both have had their say.** /// io-harness substitutes `${env:…}`, `${file:…}` and `${cmd:…}` and nothing else -/// (`substitute`, `io-harness-0.79.0/src/config.rs:3036` — there is no tilde +/// (`substitute`, `io-harness-0.81.0/src/config.rs:3159` — there is no tilde /// branch anywhere in it, and 0.71.0 narrowed the forms rather than widening /// them: a plugin manifest now refuses all three), so a `~` an operator wrote in /// `[run] skills` or `[app.io-cli] skills` diff --git a/src/home.rs b/src/home.rs index 6360638..78e2827 100644 --- a/src/home.rs +++ b/src/home.rs @@ -9,7 +9,7 @@ //! This module names one. It is deliberately **not** a second configuration //! system: io-harness resolves `$IO_CONFIG`, then `$IO_CONFIG_HOME`, then the //! platform's own place, reading the environment at call time -//! (`io-harness-0.79.0/src/config.rs:2311`, the Windows branch at `:2329`), and +//! (`io-harness-0.81.0/src/config.rs:2411`, the Windows branch at `:2429`), and //! there is no caller-supplied home //! anywhere in `Config`'s public surface — no `discover_in`, no builder. So the //! one lever is `$IO_CONFIG_HOME`, set once, before the first diff --git a/src/import.rs b/src/import.rs index a86c8c2..f4f003c 100644 --- a/src/import.rs +++ b/src/import.rs @@ -30,7 +30,7 @@ //! decision="allow")` and Claude's `permissions.ask` says `Bash(cargo yank *)` — //! both of which match a *command line*. io-harness's `Act::Exec` matches a //! **binary name and nothing else**; it has no argument matching at all -//! (`Act::Exec`, `io-harness-0.79.0/src/policy.rs:63`). So the nearest thing to a faithful +//! (`Act::Exec`, `io-harness-0.81.0/src/policy.rs:63`). So the nearest thing to a faithful //! import of `bun install` is a blanket allow on `bun`, which is a wider //! permission than the operator ever granted, written by a tool they were //! trusting to be careful. A boundary half-imported is worse than one left alone, diff --git a/src/manage.rs b/src/manage.rs index 0cf8cc6..ac8375c 100644 --- a/src/manage.rs +++ b/src/manage.rs @@ -1408,7 +1408,7 @@ fn mcp_add(args: &Args) -> Result { // Built by hand rather than through `McpServer::stdio(…).with_args(…)`, // because `env` and `headers` have no builder at all and `with_args` is a - // silent no-op on an HTTP server (`io-harness-0.79.0/src/mcp.rs:439-447`: the + // silent no-op on an HTTP server (`io-harness-0.81.0/src/mcp.rs:439-447`: the // body writes only into the `Stdio` arm) — a constructor chain here would drop // the arguments of half the servers it was handed and say nothing. // Asked of the harness rather than written as literals, the way `servers::add` diff --git a/src/memory.rs b/src/memory.rs index 7997e35..ec9f195 100644 --- a/src/memory.rs +++ b/src/memory.rs @@ -27,7 +27,7 @@ //! //! Worth knowing before a surface promises otherwise. `read_instructions` joins //! each name in `[instructions] files` to the **discovery root** -//! (`io-harness-0.79.0/src/config.rs:2997`), and the default list is exactly +//! (`io-harness-0.81.0/src/config.rs:3120`), and the default list is exactly //! `["AGENTS.md"]` (`DEFAULT_INSTRUCTIONS`, `config.rs:190`). So `AGENTS.md` is read //! by every project with no configuration at all; `AGENTS.local.md` is read only //! where a file names it; and `IO.md` is not reachable by a bare name at all, @@ -464,7 +464,7 @@ fn bullet(line: &str) -> Option { /// The order the three files are named in, and it is the order they reach the /// model in: `read_instructions` pushes one constraint per name in list order -/// (`read_instructions`, `io-harness-0.79.0/src/config.rs:2997`). +/// (`read_instructions`, `io-harness-0.81.0/src/config.rs:3120`). /// /// Project, then local, then user — widest audience first, so the operator's own /// standing note is the last thing said. It also matches the order the three diff --git a/src/reload.rs b/src/reload.rs index 70695ad..17618b6 100644 --- a/src/reload.rs +++ b/src/reload.rs @@ -2,7 +2,7 @@ //! //! **io-harness takes the instruction files as a snapshot and never takes //! another.** `read_instructions` runs inside `Config::discover` -//! (`io-harness-0.79.0/src/config.rs:2997`), composes each named file into a +//! (`io-harness-0.81.0/src/config.rs:3120`), composes each named file into a //! constraint, and stores the result in a private `Config.instructions` field. //! There is no `Config::reload`, and `Config::with_profile` clones the field //! rather than going back to disk — so a `Config` is exactly as old as the call diff --git a/src/triage.rs b/src/triage.rs index 4cb158d..dfeb069 100644 --- a/src/triage.rs +++ b/src/triage.rs @@ -327,6 +327,57 @@ pub const TRIAGE: &[(&str, Disposition, &str)] = &[ "the dial line, carrying the host as the command asked for it, the port, and whether the \ policy permitted it", ), + // **0.38.2 — the three kinds io-harness 0.80.0 and 0.81.0 added, arriving + // together on one pin bump.** None of them fails to compile: `EventKind` is + // `#[non_exhaustive]`, so a kind with no row falls through the wildcard and is + // drawn by nothing, silently. This table is the only thing that notices, and + // it noticed all three the moment the pin moved. + // + // **`context_ceiling` is a status field and not a line**, because the number + // it carries is a denominator rather than an event: `ctx N%` is the assembled + // request over exactly this ceiling, and until io-harness 0.81.0 the contract + // was the only thing that knew it. `Status::note_ceiling` takes it and + // `context::window` divides by it, so the share and the `/context` page move + // together — they disagreed once, on one screen, and the fix then was to make + // them one expression. A line would be a row per run restating a number + // already on the line, and the one case worth acting on — `source: + // "fallback"`, the harness guessing — is worth a release that renders it + // rather than a row that mentions it. + ( + "context_ceiling", + Disposition::Status, + "the `ctx` field's denominator, and the `/context` page's total, which are one expression \ + so that they cannot disagree", + ), + // **`step_usage` is silent for exactly `step_attributed`'s reason**, and it is + // the same shape of fact: a breakdown emitted beside every committed step, + // whose numbers no surface in this release renders. The cached-versus-fresh + // split it carries is the answer to a real complaint — the running `tok` + // figure reads as though every re-sent catalogue were paid in full — and + // answering it is a footer this release does not build. Recorded as a close + // call, like its sibling, rather than as an obvious silence. + ( + "step_usage", + Disposition::Silent, + "`io exec --json`, which forwards it verbatim, and the durable trace; the fresh-versus-\ + cached split it carries is what the footer's `tok` figure does not yet separate, and \ + separating it is a release rather than a row", + ), + // **`image_attached` is silent because the picture it announces is one this + // crate did not hand over.** `src/picture.rs` already draws every image the + // operator attached, at the moment they attach it; this event fires for one + // that reached the run through an MCP reply, a browser screenshot or + // `view_image`, which is plumbing inside io-harness that emitted nothing at + // all before 0.81.0. Drawing it is a renderer, and a renderer is the roadmap's + // work rather than a patch's. A line naming a picture without showing it would + // be the class of sentence this release exists to delete. + ( + "image_attached", + Disposition::Silent, + "`io exec --json`, which forwards it verbatim, and the durable trace; the image reached \ + the run inside io-harness rather than through the operator's own attachment command, \ + and no surface in this release draws one this crate did not hand over", + ), ( "finished", Disposition::Line, diff --git a/tests/adapt.rs b/tests/adapt.rs index 9f6dd9a..36bf1a8 100644 --- a/tests/adapt.rs +++ b/tests/adapt.rs @@ -510,7 +510,7 @@ fn loaded(into: &Path) -> Plugins { /// that io wrote until 0.35.0. The generator still runs and the fixture still /// builds — and `dropped()` carries the whole bundle, because io-harness 0.74.0 /// refuses an absolute `skills` or `templates` in every scope, `Scope::User` -/// included (`io-harness-0.79.0/src/plugin.rs:1097`): a manifest contributes a +/// included (`io-harness-0.81.0/src/plugin.rs:1097`): a manifest contributes a /// directory it ships rather than one it points at somewhere else on the machine, /// since every `*.md` under it is read into the model's system prompt. #[test] diff --git a/tests/contract.rs b/tests/contract.rs index d198565..5c85bf5 100644 --- a/tests/contract.rs +++ b/tests/contract.rs @@ -1829,7 +1829,7 @@ fn f5_servers_in_both_scopes_are_merged_and_a_collision_is_named() { /// **F7 — a tilde is a home directory, never a directory named `~`.** /// /// io-harness substitutes `${env:…}` and `${file:…}` and nothing else — there is -/// no tilde branch anywhere in `io-harness-0.79.0/src/config.rs` — so a `~` an +/// no tilde branch anywhere in `io-harness-0.81.0/src/config.rs` — so a `~` an /// operator writes in `[run] skills` reaches `Skills::discover` verbatim and the /// harness looks inside a directory whose name is one character long. The /// operator's skills sit exactly where they said they would, and the session diff --git a/tests/hooks.rs b/tests/hooks.rs index 7d88157..8efe008 100644 --- a/tests/hooks.rs +++ b/tests/hooks.rs @@ -410,7 +410,7 @@ fn a_hook_naming_an_event_that_does_not_exist_is_refused_rather_than_installed() // --------------------------------------------------------------------------- // // `io_harness::Hooks::new` creates every `append` path it is given, empty, as the -// value is constructed (`io-harness-0.79.0/src/hooks.rs`) — deliberately, so that +// value is constructed (`io-harness-0.81.0/src/hooks.rs`) — deliberately, so that // "the filter matched nothing" stays distinguishable from "the hook was never // installed". That makes *constructing* a `Hooks` a write to the filesystem, and // it resolves a relative `append` against the directory it was handed. Handed diff --git a/tests/instructions.rs b/tests/instructions.rs index 363c3ab..8c1ea32 100644 --- a/tests/instructions.rs +++ b/tests/instructions.rs @@ -5,7 +5,7 @@ //! other half and `tests/memory.rs` covers it. This file covers the half that //! decides whether any of it reaches a model: `[instructions] files`, which is //! the only mechanism io-harness has for finding two of the three -//! (`io-harness-0.79.0/src/config.rs:190` — the automatic set is exactly +//! (`io-harness-0.81.0/src/config.rs:190` — the automatic set is exactly //! `["AGENTS.md"]`). //! //! Every test sets `IO_CONFIG_HOME`, because that is the only way to move the diff --git a/tests/live.rs b/tests/live.rs index 143489f..9dffe13 100644 --- a/tests/live.rs +++ b/tests/live.rs @@ -4059,7 +4059,7 @@ async fn live_f6_a_withheld_tool_is_refused_by_the_mask_and_says_so() { // working mask, which is the finding worth keeping: io-harness *announces* the // mask in the user prompt — "Unavailable this turn — these tools are listed // above but calling one is refused and starts nothing: write_file" - // (`run/prompts.rs:1133`) — so a compliant model never attempts the call and + // (`io-harness-0.81.0/src/run/prompts.rs:1381`) — so a compliant model never attempts the call and // never produces the refusal. The run above said so in its own reasoning: "the // previous turns show write_file was refused, so I used a shell redirect // instead". That is the mask working at its best, not evidence of absence. diff --git a/tests/reload.rs b/tests/reload.rs index 87c5f19..ddf2e7c 100644 --- a/tests/reload.rs +++ b/tests/reload.rs @@ -79,7 +79,7 @@ fn workspace(io_toml: &str, agents: &str) -> (tempfile::TempDir, Configuration) /// Whether any composed instruction carries `line`. /// /// io-harness words each instruction file as `Project instructions from -/// `AGENTS.md`:\n` (`io-harness-0.79.0/src/config.rs:3020-3023`), so the +/// `AGENTS.md`:\n` (`io-harness-0.81.0/src/config.rs:3143-3146`), so the /// assertion is a containment and not an equality — this file is not the place /// that pins the harness's wording. fn instructs(held: &Configuration, line: &str) -> bool { From 1ee6855e5e99078b980bc36f9b91a9b47a389523 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 6 Sep 2026 13:14:43 +0530 Subject: [PATCH 04/10] fix(provider): delegate the window methods, and divide by the ceiling the run announced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit io-harness 0.81.0 added Provider::context_window and Provider::max_output_tokens so a run's context ceiling can follow the model it is running. Both default to None. This crate wraps Provider four times — Vendor, Chain, Watched, Printable — and every one of them inherited that default, which answers for the wrapper instead of for the provider it wraps. The tree compiled with no error and no warning, the whole suite passed, and every run went on assembling under the 24,000-token fallback that harness release exists to end. Nothing could have caught it. The gate added here reads the method list out of the trait declaration in the locked harness and the wrapper list out of this file, so a method the harness adds is in force the moment the pin moves and a fifth wrapper is covered by existing rather than by being remembered. Chain answers the minimum across its links rather than the head's, for the same reason accepts_images is a conjunction: a run assembles its context once, and sizing to a large head before falling through to a smaller link overflows the request exactly when the first link has already failed. Delegating those two then makes this crate's own denominator wrong in the other direction — the harness derives a real ceiling while the TaskContract still carries the budget it was built with, so ctx N% would read near 100% on a run a fifth of the way into its window. EventKind::ContextCeiling is what ctx N% and the /context page now divide by, through one expression, because those two surfaces disagreed once already and the fix then was to make them one. src/main.rs also carries the argv-door half of the empty plugin search here; the two changes are in one file and are not worth an unbuildable split. --- src/context.rs | 43 +++++++++++++++---- src/main.rs | 38 +++++++++++++---- src/provider.rs | 68 ++++++++++++++++++++++++++++++ src/status.rs | 45 +++++++++++++++++++- tests/context.rs | 81 ++++++++++++++++++++++++++++++++--- tests/provider.rs | 105 ++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 358 insertions(+), 22 deletions(-) diff --git a/src/context.rs b/src/context.rs index 5e6a390..eb0c6d4 100644 --- a/src/context.rs +++ b/src/context.rs @@ -128,10 +128,10 @@ pub const SKILLS_HEAD: &str = "Skills available to you"; /// **The directive is glued to the last catalogue line with no newline between /// them, so a line scan cannot see the boundary.** `compose` builds the prompt as /// `with_skill_catalog(..)` and then `out.push_str(&directive)` -/// (io-harness-0.79.0/src/run/prompts.rs:73-76); `Skills::catalog()` ends with its +/// (io-harness-0.81.0/src/run/prompts.rs:73-76); `Skills::catalog()` ends with its /// last `- name: description` line carrying **no trailing newline** -/// (src/skills.rs:470-476); and `planning_directive` returns a string beginning -/// with a space (src/run/gate.rs:282-286). So with a plan gate registered — which +/// (src/skills.rs:554-565); and `planning_directive` returns a string beginning +/// with a space (src/run/gate.rs:322-326). So with a plan gate registered — which /// is every contained turn — the final line on the wire reads /// `- bundle__skill: description Before you do anything else you must call …`, /// still starts with `- `, and a naive scan swallows the whole directive. @@ -786,14 +786,35 @@ pub fn total(sections: &[Section]) -> u64 { sections.iter().map(|section| section.tokens).sum() } -/// The window this contract declares, in tokens. +/// The window this run assembles inside, in tokens. /// /// `remaining` is what is left of `[run] max_tokens` for this turn, which is what /// makes the ceiling move: with a run budget the assembled section takes a share /// of what is *unspent*, so a run running low reports a smaller window rather /// than the flat maximum it can no longer afford. -pub fn window(contract: &TaskContract, remaining: Option) -> u64 { - contract.context.effective_tokens(remaining) +/// +/// **`announced` is the run's own answer and it wins outright (0.38.2).** Until +/// io-harness 0.81.0 the contract was the only thing that knew: `ContextBudget` +/// declared `24_000` unless the operator wrote `[run.context]`, so the contract +/// and the run agreed by construction and this function could read either. They +/// no longer agree. The harness now derives the ceiling from the model's own +/// window where the provider knows it and emits `EventKind::ContextCeiling` +/// saying which of `contract`, `model` or `fallback` decided — while the +/// `TaskContract` this crate built still carries the budget it was built with. +/// Dividing by the contract after that would report `ctx 100%` on a run a fifth +/// of the way into a 128,000-token window, which is worse than no field: the +/// number an operator folds a conversation over would be five times too small. +/// +/// **The precedence needs no re-deriving here, because the announcement already +/// carries it.** An explicit `ContextBudget` still wins inside io-harness and is +/// announced as `source: "contract"`, so taking the announced value is taking the +/// operator's own answer where they gave one. `None` is a run that has not +/// announced yet — before the first event, and every run against a harness that +/// does not emit this — and falls back to exactly the pre-0.38.2 expression. +pub fn window(contract: &TaskContract, remaining: Option, announced: Option) -> u64 { + announced + .filter(|ceiling| *ceiling > 0) + .unwrap_or_else(|| contract.context.effective_tokens(remaining)) } /// The page, committed into the scrollback. @@ -808,6 +829,12 @@ pub fn committed( seen: Option<&Request>, contract: &TaskContract, remaining: Option, + // The ceiling this run announced, if it has. Threaded to `window` rather than + // read here, because the page and `ctx N%` must divide by the same number: + // they disagreed once already — the page totalled 4,363 of 24,000 while the + // line said `ctx 0%` — and the fix then was to make them one expression. A + // second reader of the announcement would undo that. + announced: Option, // What the next turn may not call. Drawn on this page because this is where // the tools are named, and an operator who withheld one an hour ago should // not have to remember it — but drawn only when there is one, so the row is @@ -853,10 +880,10 @@ pub fn committed( ); } let total = total(§ions); - let window = window(contract, remaining); + let window = window(contract, remaining, announced); push( format!( - "total: {total} tokens of {window} {dash} the window this contract declares" + "total: {total} tokens of {window} {dash} the window this run assembles inside" ), Tone::Normal, ); diff --git a/src/main.rs b/src/main.rs index 1ce9e25..88b6c99 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4710,7 +4710,7 @@ async fn loop_over Result>( if hits.is_empty() { app.record( Tone::Muted, - format!("no bundle in any marketplace matches `{text}`"), + io_cli::marketplace::nothing_matched(&text), ); } for hit in hits { @@ -6563,6 +6563,7 @@ async fn loop_over Result>( seen.latest().as_ref(), &reading, reading.max_tokens, + app.status.ceiling, &mask, &app.theme, screen.width(), @@ -8095,6 +8096,7 @@ async fn turn( seen.latest().as_ref(), &contract, contract.max_tokens, + app.status.ceiling, mask, &app.theme, screen.width(), @@ -8573,6 +8575,15 @@ fn note_context( seen: &io_cli::context::Seen, contract: &io_harness::TaskContract, ) { + // **The run's own ceiling, taken before anything divides by it.** Inside this + // function rather than beside its three call sites, for the reason the + // function exists at all: `note_context` is what every door already calls per + // event, and a fourth arm at one of the three is how the headless path or the + // resume path silently keeps the contract's number. The event arrives once, + // beside `Started`, so this runs before the first share is computed. + if let io_harness::EventKind::ContextCeiling { max_tokens, .. } = &event.kind { + app.status.note_ceiling(*max_tokens); + } if let Some(request) = seen.latest() { // **What is LEFT of the run budget, not all of it.** io-harness assembles // against the unspent remainder — a run low on budget gets a smaller @@ -9274,9 +9285,8 @@ async fn wizard( // theirs was involved — and then wrote that file. The user scope is the one // the wizard writes, so it is the one worth naming, and it is reported as in // force only when it is actually there. - let mut wizard = Wizard::new(theme).over( - io_harness::config::user_path().filter(|path| path.is_file()), - ); + let mut wizard = + Wizard::new(theme).over(io_harness::config::user_path().filter(|path| path.is_file())); // **What the catalogue read already returned, kept instead of thrown away.** // The wizard reads the provider's catalogue to offer a model list, and until // 0.22.0 mapped every row down to its id and dropped the price on it. Holding @@ -9968,13 +9978,25 @@ async fn manage_main( // `marketplace::matching`'s, so the two doors cannot describe a hit // differently, and its first field is the qualified spelling `plugin add` // takes — a script piping this has the thing to install, not a name it has - // to go and disambiguate. Nothing at all is printed for no hits: a listing - // verb that wrote prose to stdout would put a sentence in the middle of - // somebody's pipeline. + // to go and disambiguate. + // + // **No hits says so, and says it on stderr.** The older reading — print + // nothing, because a listing verb writing prose to stdout puts a sentence + // in the middle of somebody's pipeline — was right about the stream and + // wrong about the silence: a search that printed nothing is + // indistinguishable from a search that did not run, which the field test + // found and reported as a broken command. Both halves hold at once by + // splitting the streams the way `io exec --json` and `io resume --list` + // already do — hits on stdout, everything else on stderr — so a pipeline + // reads exactly the hits and a person reads the answer. io_cli::manage::Request::Plugin(io_cli::manage::PluginVerb::Search { text }) => { let markets = io_cli::marketplace::installed() .ok_or_else(|| io_cli::marketplace::NOWHERE.to_string())?; - for hit in io_cli::marketplace::matching(&markets, text) { + let hits = io_cli::marketplace::matching(&markets, text); + if hits.is_empty() { + eprintln!("{}", io_cli::marketplace::nothing_matched(text)); + } + for hit in hits { println!("{hit}"); } } diff --git a/src/provider.rs b/src/provider.rs index 01419dc..62d48d0 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -173,6 +173,35 @@ impl Provider for Vendor { } } + // **The two window answers, and the reason they are written out rather than + // left to the trait.** io-harness 0.81.0 added `context_window` and + // `max_output_tokens` with `None` defaults, so every wrapper in this file + // compiles without them and answers "this provider is not saying" — which + // sends the run back to `context::FALLBACK_MAX_TOKENS`, the 24,000 the + // harness release exists to stop being universal. `Compatible` and + // `OpenRouter` both answer from a catalogue they have already fetched, so + // the answer is here and this crate was throwing it away. There is no + // compiler error and no test failure for a defaulted method: the only gate + // is `f2_every_provider_method_is_delegated_by_every_wrapper`, which reads + // the method list out of the locked harness rather than a literal. + fn context_window(&self) -> Option { + match self { + Self::OpenRouter(p) => p.context_window(), + Self::Anthropic(p) => p.context_window(), + Self::OpenAi(p) => p.context_window(), + Self::Compatible(p) => p.context_window(), + } + } + + fn max_output_tokens(&self) -> Option { + match self { + Self::OpenRouter(p) => p.max_output_tokens(), + Self::Anthropic(p) => p.max_output_tokens(), + Self::OpenAi(p) => p.max_output_tokens(), + Self::Compatible(p) => p.max_output_tokens(), + } + } + fn name(&self) -> &str { match self { Self::OpenRouter(p) => p.name(), @@ -370,6 +399,29 @@ impl Provider for Chain

{ self.head().model_hint() } + /// **The smallest window any link declares, for `accepts_images`'s reason.** + /// + /// The head's window is the wrong answer for the same reason the head's image + /// support is: a run assembles its context once, and the call that matters is + /// the fall-through. Sizing to a 200,000-token head and falling through to a + /// 32,000-token link overflows the request precisely when the first link has + /// already failed, which is the worst moment to discover it. + /// + /// A link that says nothing is not a link that says zero — it is skipped, and + /// `None` here means no link in the chain declared a window at all, which + /// sends the run to io-harness's own fallback exactly as before. + fn context_window(&self) -> Option { + self.links.iter().filter_map(Provider::context_window).min() + } + + /// The smallest reserved answer any link declares, for the same reason. + fn max_output_tokens(&self) -> Option { + self.links + .iter() + .filter_map(Provider::max_output_tokens) + .min() + } + fn name(&self) -> &str { self.head().name() } @@ -623,6 +675,14 @@ impl Provider for Watched

{ self.inner.model_hint() } + fn context_window(&self) -> Option { + self.inner.context_window() + } + + fn max_output_tokens(&self) -> Option { + self.inner.max_output_tokens() + } + fn name(&self) -> &str { self.inner.name() } @@ -729,6 +789,14 @@ impl Provider for Printable

{ self.inner.model_hint() } + fn context_window(&self) -> Option { + self.inner.context_window() + } + + fn max_output_tokens(&self) -> Option { + self.inner.max_output_tokens() + } + fn name(&self) -> &str { self.inner.name() } diff --git a/src/status.rs b/src/status.rs index 3342ae1..5a44056 100644 --- a/src/status.rs +++ b/src/status.rs @@ -331,6 +331,27 @@ pub struct Status { /// number never measured. The window beside it is not cleared, because the /// file did not change. pub context: Option, + /// The ceiling this run announced it would assemble under, in tokens (0.38.2). + /// + /// **The denominator, taken from the run rather than guessed from the + /// contract.** io-harness 0.81.0 emits `EventKind::ContextCeiling` once per + /// run, beside `Started`, carrying the number and one word for what decided + /// it. Before that release the contract was the only thing that knew and it + /// was always right; now the harness derives the ceiling from the model's own + /// window, and a `TaskContract` this crate built with io-harness's default + /// budget says `24_000` about a run assembling inside 128,000. + /// + /// It is a **run** fact and is cleared by [`Status::forget_run`] with the + /// others: a resumed or cleared conversation may run on a different model + /// entirely, and carrying the old ceiling forward would divide the new run's + /// pressure by the previous model's window — a wrong percentage that looks + /// exactly like a right one. + /// + /// `None` is "this run has not said", which is every run against a harness + /// older than 0.81.0 and every moment before the first event of one that is + /// not. [`crate::context::window`] falls back to the contract there, which is + /// the pre-0.38.2 expression byte for byte. + pub ceiling: Option, /// How this run's commands are contained: the mode asked for and the backend /// that actually answered on this host. /// @@ -635,6 +656,7 @@ impl Status { run_tokens: None, cost: None, context: None, + ceiling: None, containment: None, boundary: None, branch: None, @@ -715,6 +737,9 @@ impl Status { self.provider = None; self.steps = None; self.context = None; + // Beside the share it is the denominator of. A ceiling belongs to the run + // that announced it, and the next run may be a different model. + self.ceiling = None; self.containment = None; // Beside the containment word it qualifies, and for the same reason: a // measurement belongs to the run that took it, and carrying it onto the @@ -977,6 +1002,24 @@ impl Status { /// is what enforces it, so a section briefly over its window is ordinary. /// `ctx 137%` would read as a bug in this line rather than as the pressure it /// actually is. + /// The ceiling this run announced, from `EventKind::ContextCeiling` (0.38.2). + /// + /// **One setter, called once per run, and it overwrites rather than fills.** + /// A resume announces again; so does the next turn of a session whose model + /// changed under `/model`. Taking the first answer and keeping it would be the + /// same class of defect as the field this replaces — a denominator that was + /// true when it was written and is not now. + /// + /// A zero is refused rather than stored. `crate::context::window` filters it + /// too, and both are deliberate: a window that divides is a number somebody + /// eventually divides by, and this crate has already spent two releases on + /// exactly that. + pub fn note_ceiling(&mut self, max_tokens: u64) { + if max_tokens > 0 { + self.ceiling = Some(max_tokens); + } + } + pub fn note_context(&mut self, est_tokens: u64) { let Some(window) = self.budgets.window.filter(|window| *window > 0) else { return; @@ -1040,7 +1083,7 @@ impl Status { contract: &io_harness::TaskContract, remaining: Option, ) { - let window = crate::context::window(contract, remaining); + let window = crate::context::window(contract, remaining, self.ceiling); if window == 0 { return; } diff --git a/tests/context.rs b/tests/context.rs index d1ec2af..81ca0e6 100644 --- a/tests/context.rs +++ b/tests/context.rs @@ -338,20 +338,86 @@ fn f7_the_sections_sum_to_the_total_and_the_total_is_stated_against_the_window() // The denominator is the contract's own budget, not `ContextBudget::default`: // an operator who tightened `[context]` must see the ceiling they set. - assert_eq!(context::window(&contract, None), 24_000); + assert_eq!(context::window(&contract, None, None), 24_000); let tightened = TaskContract::workspace("g", "/repo").with_context_budget(io_harness::ContextBudget { max_tokens: 8_000, share: 0.25, }); - assert_eq!(context::window(&tightened, None), 8_000); + assert_eq!(context::window(&tightened, None, None), 8_000); assert_eq!( - context::window(&tightened, Some(20_000)), + context::window(&tightened, Some(20_000), None), 5_000, "with a run budget the window is a share of what is left", ); } +/// **F1 — the ceiling the run announced is the denominator, and it wins.** +/// +/// Before io-harness 0.81.0 the contract was the only thing that knew the window, +/// so the two could not disagree and this assertion had nothing to say. They +/// disagree now: the harness derives the ceiling from the model's own window and +/// announces it, while the `TaskContract` this crate built still carries the +/// budget it was built with. Dividing by the contract after that reports `ctx +/// 100%` on a run a fifth of the way into a large window — a number five times +/// too small at exactly the moment an operator uses it to decide whether to fold. +/// +/// The three arms are the three things that can be wrong: the announcement +/// ignored, the announcement taken when the operator set a budget of their own +/// (it is not — io-harness announces an explicit budget as `source: "contract"`, +/// so taking the announcement *is* taking their answer), and a zero stored as a +/// denominator. +/// +/// Sabotage: drop the `announced` parameter from `context::window` and read the +/// contract unconditionally, which is the pre-0.38.2 body. +#[test] +fn f1_an_announced_ceiling_is_the_window_the_share_is_taken_against() { + let contract = contract(); + + assert_eq!( + context::window(&contract, None, Some(128_000)), + 128_000, + "the run said it assembles inside 128,000 and the contract's 24,000 is \ + the guess it replaces", + ); + + // A run that has not announced — every run against a harness older than + // 0.81.0, and every moment before the first event of one that is not — is the + // pre-0.38.2 expression byte for byte. + assert_eq!( + context::window(&contract, None, None), + 24_000, + "with nothing announced the contract is still the answer, unchanged", + ); + + // Zero is refused rather than divided by. A window that divides is a number + // somebody eventually divides by. + assert_eq!( + context::window(&contract, None, Some(0)), + 24_000, + "a zero ceiling falls back rather than becoming a denominator", + ); + + // And the same value reaches `ctx N%`, through `Status`, rather than being a + // second reader of the event. + let mut status = io_cli::status::Status::new("a-model"); + status.note_ceiling(128_000); + assert_eq!(status.ceiling, Some(128_000)); + status.note_ceiling(0); + assert_eq!( + status.ceiling, + Some(128_000), + "a zero announcement does not erase the ceiling already in force", + ); + status.forget_run(); + assert_eq!( + status.ceiling, None, + "a ceiling belongs to the run that announced it: the next run may be a \ + different model, and dividing its pressure by this window would be a \ + wrong percentage that looks exactly like a right one", + ); +} + // --------------------------------------------------------------------------- // The catalogue is the wire's, not io-cli's // --------------------------------------------------------------------------- @@ -375,6 +441,7 @@ fn f7_the_catalogue_names_a_tool_only_the_request_knew_about() { Some(&Request::of(&request())), &contract(), None, + None, &ToolMask::none(), &ascii(), 80, @@ -432,6 +499,7 @@ fn f7_the_system_block_is_tokens_of_its_own_text_and_not_a_byte_count() { Some(&seen), &contract, None, + None, &ToolMask::none(), &ascii(), 80, @@ -466,6 +534,7 @@ fn f7_the_page_draws_in_ascii_and_says_so_before_a_turn_has_run() { None, &contract(), None, + None, &ToolMask::none(), &ascii(), 80, @@ -482,6 +551,7 @@ fn f7_the_page_draws_in_ascii_and_says_so_before_a_turn_has_run() { Some(&Request::of(&request())), &contract(), None, + None, &ToolMask::none(), &ascii(), 80, @@ -525,7 +595,7 @@ fn f10_the_status_share_is_the_page_total_over_the_page_window() { let sections = context::sections(&seen, &contract); let total = context::total(§ions); - let window = context::window(&contract, contract.max_tokens); + let window = context::window(&contract, contract.max_tokens, None); assert!( total > 0 && window > 0, "the fixture has to put something in a window for this to mean anything", @@ -844,6 +914,7 @@ fn f9_the_withheld_row_is_absent_until_there_is_one_and_then_states_its_cost() { Some(&Request::of(&request())), &contract(), None, + None, &mask, &ascii(), 80, @@ -890,7 +961,7 @@ fn f9_allow_reports_a_no_op_and_a_clear_names_what_returned() { /// **The defect this replaces was the safety lever being silently inert.** /// io-harness keeps an unknown mask name rather than rejecting it, deliberately, /// so a mask stays portable across builds with different cargo features -/// (`io-harness-0.79.0/src/tools/mod.rs:55-58`). That means `mask_gate` matches on +/// (`io-harness-0.81.0/src/tools/mod.rs:55-58`). That means `mask_gate` matches on /// the exact string and a misspelling withholds nothing — while the operator was /// told "calling it will be refused before anything starts" and `/context` drew it /// on the withheld row. `/context withhold Docx_Write` and the file gets written. diff --git a/tests/provider.rs b/tests/provider.rs index e4c14cc..a1c1332 100644 --- a/tests/provider.rs +++ b/tests/provider.rs @@ -16,6 +16,8 @@ use std::path::{Path, PathBuf}; +mod support; + /// The four constructors that turn a credential into a provider. const CONSTRUCTORS: &[&str] = &[ "OpenRouter::new", @@ -571,3 +573,106 @@ fn f5_an_image_is_refused_unless_every_link_accepts_one() { assertion above is satisfied by a function that always answers false", ); } + +/// The text between a declaration's opening brace and the first `}` in column +/// zero, which is where every item in the harness's source ends. +fn body_after<'a>(source: &'a str, marker: &str) -> &'a str { + let start = source + .find(marker) + .unwrap_or_else(|| panic!("the locked source declares `{marker}`")); + let rest = &source[start + marker.len()..]; + let end = rest + .find("\n}\n") + .unwrap_or_else(|| panic!("`{marker}` is closed")); + &rest[..end] +} + +/// **F2 — every method io-harness's `Provider` declares is delegated by every +/// wrapper in this crate, and both lists are derived rather than written.** +/// +/// The defect this exists to catch neither fails to compile nor fails any other +/// test. `Provider`'s methods carry default bodies, deliberately, so that an +/// implementation written before a method existed keeps building — and a +/// *wrapper* that inherits one of those defaults answers for itself instead of +/// for the provider it wraps, which is the one place the default is never right. +/// +/// io-harness 0.81.0 added `context_window` and `max_output_tokens`, both +/// defaulting to `None`. With the pin moved to it and nothing else changed, this +/// crate's four wrappers compiled with no error and no warning, every existing +/// test passed, and every run went on assembling under +/// `io_harness::context::FALLBACK_MAX_TOKENS` on a model whose window the +/// provider catalogue already held — which is precisely the behaviour that +/// harness release exists to end. +/// +/// **The method list is read out of the locked harness, never written here.** A +/// literal list is a gate that goes stale at the next pin and is then repaired by +/// editing the literal, which is the failure this repository has already paid +/// for. Parsed from the trait declaration, a method the harness adds is in force +/// the moment the pin moves. +/// +/// **The wrapper list is derived from `src/provider.rs` itself**, so a fifth +/// wrapper is covered by existing rather than by being remembered. +#[test] +fn f2_every_provider_method_is_delegated_by_every_wrapper() { + let harness = support::harness_source_at(&["provider", "mod.rs"]); + let declared: Vec<&str> = body_after(&harness, "pub trait Provider {") + .lines() + .filter_map(|line| line.strip_prefix(" fn ")) + .filter_map(|rest| rest.split(['(', '<']).next()) + .map(str::trim) + .filter(|name| !name.is_empty()) + .collect(); + + // Not a count — an anchor. A parse that silently returned nothing would make + // every assertion below vacuously true, which is the one way this gate can + // fail open. + assert!( + declared.contains(&"complete") && declared.contains(&"context_window"), + "the trait parse found {declared:?}, which does not look like \ + `Provider` — the declaration moved and this gate is reading the wrong \ + thing rather than passing", + ); + + let ours = std::fs::read_to_string("src/provider.rs").expect("this crate's provider module"); + let mut wrappers: Vec<(&str, &str)> = Vec::new(); + for (at, _) in ours.match_indices("Provider for ") { + let line = ours[..at].rfind('\n').map_or(0, |n| n + 1); + if !ours[line..].starts_with("impl") { + continue; + } + let named = ours[at + "Provider for ".len()..] + .split(['<', ' ', '{']) + .next() + .unwrap_or_default() + .trim(); + let rest = &ours[line..]; + let end = rest.find("\n}\n").expect("the impl is closed"); + wrappers.push((named, &rest[..end])); + } + + // Derived against derived: an independent count of the impl headers, so a + // parse that dropped one narrows the gate loudly instead of quietly. + let headers = ours + .lines() + .filter(|l| l.starts_with("impl") && l.contains("Provider for ")) + .count(); + assert_eq!( + wrappers.len(), + headers, + "the impl sweep found {} of {headers} `impl … Provider for …` headers, so \ + some wrapper is not being checked at all", + wrappers.len(), + ); + + for (name, body) in &wrappers { + for method in &declared { + assert!( + body.contains(&format!("fn {method}(")), + "`{name}` does not delegate `Provider::{method}`, so it answers the \ + trait's own default on behalf of the provider it wraps. The method \ + list comes from the locked io-harness, so this is the pin having \ + moved rather than a test to update", + ); + } + } +} From 11d7a203e9f8665c06f2fd951c8b703fe2cff2a4 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 6 Sep 2026 13:14:57 +0530 Subject: [PATCH 05/10] fix: finish the field-test corrections, and gate the three that nothing held MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit io resume --list showed thirteen parked runs as thirteen rows differing only by a run id and a step number. It carries each run's start time, goal and root now. io-harness published a reader for none of the three until 0.81.0, which is why this was named as carried when the release was outlined rather than promised; Store::run_goal and Store::run_created_at closed io-harness#258 and it is delivered here instead. A run started before io-harness 0.7.0 has no stamp in the store, and renders unknown rather than a date — None there means either no such run or a run older than the column, and the oldest rows are exactly the ones somebody is trying to identify. The goal is folded to one line and never clipped: N6 holds src/exec.rs to composing nothing, because a headless stream is not a viewport, and it caught the first draft doing it. io plugin search with no match answers on both doors. The argv door writes to stderr, so a pipeline still reads exactly the hits — the older reading was right about the stream and wrong about the silence. The two older-binary warnings name io-cli 0.29.0, the release that first understood the key, instead of io-harness 0.69.0 and 0.70.0. Same boundary, stated in the version an operator can check, and it no longer goes stale every time the pin moves. Three mechanisms the branch had built were held by nothing, and one of them said otherwise: src/wizard.rs cited a test as holding the driver to calling Wizard::over and no such test had ever been written, so the feature was one deleted line from reverting in silence under a comment asserting it could not. That gate, the sweep's and the UTC marker's are written here, and n6 now fails the build for any comment naming a test that does not exist. Four pre-existing tests encoded the behaviour the branch changed and were left failing: the home line, the fence's language row, the session stamp's length, and a clock read in the sweep's own test. The stamp's other arm asserted the string contains no T, which the UTC marker falsifies by itself. A guide page said io-harness emits no event of any kind when the agent is handed an image. Since 0.81.0 it emits one, so the page became false on the pin bump. --- CHANGELOG.md | 66 ++++++++++++++++ docs/guide/limits.md | 16 +++- src/events.rs | 5 +- src/exec.rs | 113 +++++++++++++++++++++++++-- src/pluginview.rs | 22 +++--- src/servers.rs | 31 +++++--- src/sessions.rs | 5 +- tests/docs.rs | 4 +- tests/exec.rs | 163 +++++++++++++++++++++++++++++++++++---- tests/home.rs | 23 ++++-- tests/lock.rs | 38 ++++++++- tests/markdown.rs | 14 +++- tests/marketplace.rs | 10 ++- tests/servers_enabled.rs | 40 +++++++++- tests/sessions.rs | 30 +++++-- tests/timing.rs | 122 +++++++++++++++++++++++++++++ tests/wizard.rs | 51 +++++++++++- 17 files changed, 685 insertions(+), 68 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6f5d18..1d781b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,71 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +## [0.38.2] - 2026-09-06 + +The rest of what the 2026-09-05 field test found, and the io-harness pin that turned out to carry +two of its answers. + +`io resume --list` showed thirteen parked runs as thirteen rows differing only by a run id and a +step number, which is a listing nobody can choose from. It carries each run's start time and its +goal now. io-harness published a reader for neither until 0.81.0; both arrived there, which is why +this was named as carried when 0.38.2 was outlined and is delivered here instead. + +`io plugin search` for something that is not there printed nothing at all, which reads exactly like +a broken command. `io mcp disable` warned about io-harness versions the operator never installed +and could not check. `io setup` over an existing 36 KB configuration announced a first run — and +the comment beside that fix named a test holding the driver to it that had never been written, so +the feature was one deleted line from reverting in silence. + +The pin moves 0.79 → 0.81.0. Its two additions to the `Provider` trait default to `None`, so every +wrapper in this crate compiled clean, passed every test, and answered for itself instead of for the +provider it wraps — leaving every run on the 24,000-token fallback that harness release exists to +end. Nothing reported it and nothing could have; there is a gate now that reads the method list out +of the locked harness rather than out of a list somebody wrote down. + +### Added + +- `io resume --list` carries each run's start time and goal on the plain row, and `goal`, + `started_at` and `root` on the `--json` object. A run started before io-harness 0.7.0 has no + stamp in the store and renders `unknown` rather than a date. +- `io plugin search ` with no match says so — in the session, and on **stderr** from the argv + door, so a pipeline still reads exactly the hits and nothing else. + +### Changed + +- **Behaviour change for operators.** A run's context ceiling now follows the model where the + provider knows its window, so `ctx N%` and the `/context` page divide by a real number instead of + a flat 24,000. On a large-window model the percentage will read much lower than it did for the + same conversation, and that is the correction: the old figure began reporting pressure at roughly + a fifth of the window actually available. A `[run.context] max_tokens` you set still wins over + both. +- **Behaviour change for scripts.** The plain rows of `io resume --list` are wider — a script + slicing them by column position sees new fields at the end. The `--json` shape is the one this + product asks scripts to parse, and its three new keys are appended and nullable, so an existing + reader is unaffected. +- The two "older binary" warnings on `[[mcp]]` and `[[plugin]]` `enabled` keys name io-cli 0.29.0 — + the release that first understood the key — instead of io-harness 0.69.0 and 0.70.0. Same + boundary, stated in the version an operator can actually check, and it no longer goes stale every + time the pin moves. + +### Fixed + +- All four of this crate's `Provider` wrappers delegate `context_window` and `max_output_tokens`. + Both are new in io-harness 0.81.0 with `None` defaults, so without this the whole tree compiled + and tested green while discarding the window the provider catalogue already held. +- `io setup` over an existing configuration names the file it is about to write over, and the + driver's call is now held to it by a test — the one `src/wizard.rs` had been citing by name. +- A guide page said io-harness emits no event of any kind when the agent is handed an image. Since + io-harness 0.81.0 it emits one; the page said otherwise the moment the pin moved. +- A session row's timestamp carries its UTC marker without breaking the assertion that the stored + `T` separator was replaced by a space — `UTC` contains a `T`, and the check was being made over + the whole string. +- Finished sessions' lock files are swept at startup, a fenced block draws no bare language line, a + thought is committed above the prose it produced, a spawned child's outcome is drawn as words + rather than as a Rust `Debug`, the home line is drawn only on a run that created or moved + something, and no `--help` page narrates release history or shares one example across four + subcommands. + ## [0.38.1] - 2026-09-05 Two defects that switched a feature off without saying so, found by driving the released 0.38.0 @@ -3515,6 +3580,7 @@ client, tool, sandbox, policy engine or session store of its own. - No test in this release asserts on wall-clock time. [Unreleased]: https://github.com/initorigin/io-cli/compare/v0.38.0...HEAD +[0.38.2]: https://github.com/initorigin/io-cli/compare/v0.38.1...v0.38.2 [0.38.1]: https://github.com/initorigin/io-cli/compare/v0.38.0...v0.38.1 [0.38.0]: https://github.com/initorigin/io-cli/compare/v0.37.0...v0.38.0 [0.37.0]: https://github.com/initorigin/io-cli/compare/v0.36.0...v0.37.0 diff --git a/docs/guide/limits.md b/docs/guide/limits.md index 2b42298..ab7ee6f 100644 --- a/docs/guide/limits.md +++ b/docs/guide/limits.md @@ -106,6 +106,14 @@ no cache entry. Use it to bound what the agent may do, and use `/context` and `/plugin` to see what your extensions cost — those are two different questions and only the second one is about money. +That is a statement about the mask, and io-harness 0.81.0 added a different lever +that *is* about the catalogue's size: tiered tool exposure, which offers the core +tools and names the rest in one line until the model asks for a family. It makes +exactly the trade described above — a smaller catalogue against a rewritten cache +prefix — and the harness measures it as worth 23% of the description tokens. +**io does not turn it on**, and this page will say so until io does: a lever +nothing here reaches is not a feature this product has. + **A mask does not reach a spawned child.** With `[app.io-cli.containment]` configured, a contained turn's children get contracts io-harness builds fresh, and it carries no mask into them deliberately — the mask is a request about your @@ -114,8 +122,12 @@ called inside the fleet. See [The fleet](fleet.md). **An image the agent was *given* rather than asked for is not shown.** A picture returned by an MCP tool, and a browser screenshot, both become images inside -io-harness — but through private plumbing and with no event of any kind, so -nothing reaches this program to draw. +io-harness. Until 0.81.0 that happened through private plumbing with no event of +any kind, so nothing reached this program at all; since 0.81.0 there is an event +carrying the media type, the size, the digest and where the picture came from. +What is missing now is the drawing rather than the fact — io does not render a +picture it was not handed itself, and `src/picture.rs` draws only what you +attached with `/attach`. **A skill is listed by name and never pasted.** A template is expanded by io-cli into prompt text, so nothing but this program is involved; a skill is read by the diff --git a/src/events.rs b/src/events.rs index d2be2a6..80de9f9 100644 --- a/src/events.rs +++ b/src/events.rs @@ -2440,7 +2440,10 @@ fn said_plainly(decision: &str) -> String { fn words_of_debug(text: &str) -> String { let text = text.trim(); let (name, fields) = match text.find(['{', '(']) { - Some(at) => (text[..at].trim(), text[at + 1..].trim_end_matches(['}', ')']).trim()), + Some(at) => ( + text[..at].trim(), + text[at + 1..].trim_end_matches(['}', ')']).trim(), + ), None => (text, ""), }; diff --git a/src/exec.rs b/src/exec.rs index 272b5d3..47a0b21 100644 --- a/src/exec.rs +++ b/src/exec.rs @@ -1055,7 +1055,80 @@ fn gate_line(standing: &crate::gates::Standing) -> String { /// is how much that one answer has to cover. It is `1` for an ordinary question /// and `null` for the three pauses that are not questions, which is the shape /// `id` already uses for the run whose process went away. -pub fn listed(run_id: i64, pending: &Pending, json: bool) -> Option { +/// What a parked run is, beyond the pause it is parked on (0.38.2). +/// +/// **Three facts that tell two rows apart, and none of them was readable before +/// io-harness 0.81.0.** The 2026-09-05 field test found thirteen parked runs +/// listed as `run 41 question 12 step 3`, `run 42 question 13 step 4`, and so +/// on — differing only by numbers that mean nothing to the person who has to +/// choose one. `runs.goal` had been written since 0.1.0 with no public reader, and +/// there was no reader for a run's own start time either; io-harness#258 closed +/// both as `Store::run_goal` and `Store::run_created_at`. +/// +/// Every field is an `Option` and each `None` means something different from the +/// others, which is why they are not flattened into a formatted string here: +/// `started_at` is `None` for a run older than io-harness 0.7.0, when the column +/// did not exist, and rendering that as a time — any time — would put a false date +/// on the oldest rows in the one listing this exists to make legible. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Parked { + /// What the run was started to do, as `Store::run_goal` reports it. + pub goal: Option, + /// When it started, as the store recorded it — a stored string, never a clock + /// read. + pub started_at: Option, + /// The root it ran against, as `Store::run_file` reports it. + pub root: Option, +} + +impl Parked { + /// The three facts, read from the store and never guessed at. + /// + /// A read that fails is `None` rather than an error that stops the listing: a + /// row missing its goal is still a row an operator can resume, and refusing to + /// print the other twelve because the thirteenth could not be read would be + /// the worse failure. + #[must_use] + pub fn of(store: &Store, run_id: i64) -> Self { + Self { + goal: store.run_goal(run_id).ok().flatten(), + started_at: store.run_created_at(run_id).ok().flatten(), + root: store.run_file(run_id).ok().flatten(), + } + } + + /// The goal on one line of a listing, or `None` where there is none. + /// + /// **Folded to one line and never cut, and the difference matters here.** The + /// first draft kept sixty characters and put an ellipsis after them, which + /// reads well and is wrong on this door: N6 in `tests/exec.rs` holds this file + /// to composing nothing, because a headless stream is not a viewport and + /// clipping it loses data a machine was going to read. The gate caught it. A + /// long goal therefore makes a long row, and a terminal wraps it — which is the + /// terminal's decision to make and not this program's. + /// + /// N6 sweeps this file's whole text and strips no comments, so its four needles + /// are banned from prose here as well as from code. This sentence has been + /// rewritten twice for that reason: naming the gate spells one of them, and + /// saying what the first draft did spells another. + /// + /// The fold stays, because it is a different act from a cut: one row per run is + /// the shape being read, and a goal somebody typed across four lines would + /// silently make one row four. The verbatim goal, newlines and all, is on the + /// `--json` object, which is the stream that promises it. + #[must_use] + pub fn short_goal(&self) -> Option { + let one_line = self + .goal + .as_deref()? + .split_whitespace() + .collect::>() + .join(" "); + (!one_line.is_empty()).then_some(one_line) + } +} + +pub fn listed(run_id: i64, pending: &Pending, parked: &Parked, json: bool) -> Option { let (waiting_on, id, step, asked) = match pending { Pending::Question { question_id, @@ -1080,12 +1153,25 @@ pub fn listed(run_id: i64, pending: &Pending, json: bool) -> Option { Some(if json { // Built through `serde_json` rather than formatted, so an answer holding // a quote is escaped by the same code that escapes the event stream. + // The three new keys are APPENDED and every one is nullable, so a reader + // written against the 0.37.0 shape goes on working: `serde_json` ignores + // what it was not asked for, and a `null` is the shape `id` and + // `questions` already use for a row that has none. + // + // `started_at` is the store's own string, unformatted. A script sorting + // parked runs wants the sortable stamp io-harness stored, not a stamp cut + // to the minute for a human to read — that is the plain stream's job, and + // the two streams differing here is the same split `io exec --json` + // already makes. serde_json::json!({ "run_id": run_id, "waiting_on": waiting_on, "id": id, "step": step, "questions": asked, + "goal": parked.goal, + "started_at": parked.started_at, + "root": parked.root, }) .to_string() } else { @@ -1096,9 +1182,23 @@ pub fn listed(run_id: i64, pending: &Pending, json: bool) -> Option { Some(n) if n > 1 => format!(" {n} questions"), _ => String::new(), }; + // **`unknown` and never a date (0.38.2).** `Store::run_created_at` answers + // `None` for a run started before io-harness 0.7.0, when the column did + // not exist. Any rendered time here would be invented, and an invented + // date on the oldest rows is precisely the row an operator is trying to + // identify. + let when = parked + .started_at + .as_deref() + .map_or_else(|| "unknown".to_string(), crate::sessions::stamp); + let goal = parked + .short_goal() + .map_or_else(String::new, |goal| format!(" {goal}")); match id { - Some(id) => format!("run {run_id} {waiting_on} {id} step {step}{several}"), - None => format!("run {run_id} {waiting_on} step {step}"), + Some(id) => { + format!("run {run_id} {waiting_on} {id} step {step}{several} {when}{goal}") + } + None => format!("run {run_id} {waiting_on} step {step} {when}{goal}"), } }) } @@ -1412,7 +1512,8 @@ pub async fn resume_main( for run_id in store.runs().map_err(|error| error.to_string())? { let pending = crate::resume::pending_for(&store, run_id).map_err(|error| error.to_string())?; - if let Some(row) = listed(run_id, &pending, args.json) { + let parked = Parked::of(&store, run_id); + if let Some(row) = listed(run_id, &pending, &parked, args.json) { let _ = writeln!(out, "{row}"); } } @@ -1548,8 +1649,8 @@ impl WithProvider for Resuming { // other pause kind has both forms — `resume_tree_with_answer` beside // `resume_with_answer`, `resume_tree_with_plan_decision` beside its flat // one, `resume_tree_with_decision` beside `resume_with_decision` - // (`io-harness-0.79.0/src/run.rs:1811`, `:2145`, `:3189`). Recovery has - // `resume_with_recovery_observed` (`:2607`) and nothing tree-aware, so it + // (`io-harness-0.81.0/src/run.rs:1816`, `:2150`, `:3204`). Recovery has + // `resume_with_recovery_observed` (`:2617`) and nothing tree-aware, so it // is the one pause a contained run cannot be resumed from. Not an oversight // this crate can route around: a fleet's shared ceiling lives in the tree // entry points, and resuming through the flat one would drop it. diff --git a/src/pluginview.rs b/src/pluginview.rs index d2ecebf..5f8baed 100644 --- a/src/pluginview.rs +++ b/src/pluginview.rs @@ -1067,16 +1067,20 @@ pub fn add(dir: &Path) -> crate::edit::Edit { /// What writing an `enabled` key into a `[[plugin]]` costs an older binary. /// -/// **`enabled` on a `[[plugin]]` is io-harness 0.70.0's, and 0.69.0 does not -/// ignore it — it refuses the whole file.** The `[[mcp]]` case is the opposite and -/// that is exactly why this has to be said out loud: an operator who has seen a -/// forward-compatible key before will assume this one is too. A shared `io.toml` -/// or a second machine on the older binary loses every setting in the file, not -/// the bundle. +/// **`enabled` on a `[[plugin]]` is understood from io-cli 0.29.0, and an older +/// binary does not ignore it — it refuses the whole file.** The `[[mcp]]` case is +/// the opposite and that is exactly why this has to be said out loud: an operator +/// who has seen a forward-compatible key before will assume this one is too. A +/// shared `io.toml` or a second machine on the older binary loses every setting in +/// the file, not the bundle. +/// +/// **Named as an io-cli release rather than an io-harness one (0.38.2)** — see +/// [`crate::servers::OLDER_BINARY`] for the reasoning, which is the same on both +/// halves of the pair. pub const OLDER_BINARY: &str = - "this writes an `enabled` key into a `[[plugin]]` entry, which is io-harness 0.70.0's: an \ - io-cli built against 0.69.0 refuses the whole configuration file rather than ignoring the \ - key"; + "this writes an `enabled` key into a `[[plugin]]` entry, which io-cli understands from 0.29.0: \ + an older io-cli sharing this file refuses the whole configuration file rather than ignoring \ + the key"; // **`add_off` and `declared_off` were deleted in 0.30.0.** // diff --git a/src/servers.rs b/src/servers.rs index d2dc7fd..0a55306 100644 --- a/src/servers.rs +++ b/src/servers.rs @@ -84,8 +84,8 @@ //! //! **What writing it costs an older binary is not what the plugin key costs, and //! the two sentences are deliberately different.** See [`OLDER_BINARY`] beside -//! [`crate::pluginview::OLDER_BINARY`]: a 0.69.0 binary *refuses the whole file* -//! over `enabled` in a `[[plugin]]`, and *silently ignores it* in an `[[mcp]]` — +//! [`crate::pluginview::OLDER_BINARY`]: an io-cli older than 0.29.0 *refuses the +//! whole file* over `enabled` in a `[[plugin]]`, and *silently ignores it* in an `[[mcp]]` — //! running a server the operator switched off. One is loud and total, the other is //! quiet and exactly the failure mode this module exists to close. //! @@ -609,19 +609,28 @@ pub const KEYS: &[&str] = &[ /// What writing an `enabled` key into an `[[mcp]]` entry costs an older binary. /// /// **The mirror image of [`crate::pluginview::OLDER_BINARY`], and the dangerous -/// half of the pair.** `enabled` on a `[[plugin]]` makes an io-cli built against -/// io-harness 0.69.0 refuse the whole configuration file: loud, total, and -/// impossible to miss. `[[mcp]]` is one of the two sections that binary exempts -/// from `deny_unknown_fields`, so the same key there is **read, accepted and -/// ignored** — and the server the operator switched off starts and runs, on that -/// machine, with nothing on any surface saying so. +/// half of the pair.** `enabled` on a `[[plugin]]` makes an older io-cli refuse +/// the whole configuration file: loud, total, and impossible to miss. `[[mcp]]` +/// is one of the two sections that binary exempts from `deny_unknown_fields`, so +/// the same key there is **read, accepted and ignored** — and the server the +/// operator switched off starts and runs, on that machine, with nothing on any +/// surface saying so. /// /// So the two sentences are two constants. One string used for both would be /// telling an operator on the quiet path that they will notice. +/// +/// **The sentence names an io-cli release and never an io-harness one (0.38.2).** +/// The key is io-harness 0.70.0's and io-cli first pinned that in its own 0.29.0, +/// so both facts describe the same boundary — but an operator knows which `io` +/// they installed and has no reason to know which harness it was built against. +/// The field test read the old spelling as a stale note about a dependency and +/// not as a warning about their own second machine, which is the whole failure. +/// It also stops the sentence rotting at every pin, which is what +/// `f4_no_compatibility_note_names_an_io_harness_version` now holds it to. pub const OLDER_BINARY: &str = - "this writes an `enabled` key into an `[[mcp]]` entry, which is io-harness 0.70.0's: an io-cli \ - built against 0.69.0 does not refuse it — it ignores the key and starts the server anyway, \ - with nothing said"; + "this writes an `enabled` key into an `[[mcp]]` entry, which io-cli understands from 0.29.0: \ + an older io-cli sharing this file does not refuse the key — it ignores it and starts the \ + server anyway, with nothing said"; /// The edit that switches the server at `at` on or off. /// diff --git a/src/sessions.rs b/src/sessions.rs index 147ba05..b88cd62 100644 --- a/src/sessions.rs +++ b/src/sessions.rs @@ -244,7 +244,10 @@ pub fn resume(store: &Store, id: i64) -> Result String { +/// `pub` from 0.38.2, because `io resume --list` draws the same kind of stamp and +/// two spellings of a timestamp on two listings of the same store is exactly the +/// disagreement `ZONE` exists to end. +pub fn stamp(created_at: &str) -> String { let cut: String = created_at.chars().take(16).collect(); format!("{}{ZONE}", cut.replace('T', " ")) } diff --git a/tests/docs.rs b/tests/docs.rs index 078d301..e7fed03 100644 --- a/tests/docs.rs +++ b/tests/docs.rs @@ -2517,9 +2517,9 @@ fn f6_the_headless_guide_carves_out_the_provider_endpoint() { /// the risk is a sentence rather than a branch.** io-harness offers a masked turn a /// byte-identical catalogue on purpose: the tool array sits ahead of the provider's /// cache breakpoint, so dropping a definition would save its tokens once and pay a -/// cache *write* on every later turn (`io-harness-0.79.0/src/tools/mod.rs:33-42`). +/// cache *write* on every later turn (`io-harness-0.81.0/src/tools/mod.rs:33-42`). /// Withholding in fact makes the request marginally **larger**, by one sentence -/// naming what is withheld (`src/run/prompts.rs:1133`). +/// naming what is withheld (`io-harness-0.81.0/src/run/prompts.rs:1381`). /// /// The roadmap entry 0.37.0 was planned from assumed the opposite and said so in /// its headline. That framing is what a writer reaches for, because "withhold" means diff --git a/tests/exec.rs b/tests/exec.rs index 5e7cdf6..ab40cec 100644 --- a/tests/exec.rs +++ b/tests/exec.rs @@ -41,6 +41,17 @@ use io_harness::{ Observer, Policy, Provider, RunEvent, RunOutcome, Session, Store, }; +/// A parked run the store can say nothing about (0.38.2). +/// +/// The shape every row in this module had before `exec::Parked` existed, and +/// still the honest one for a run older than io-harness 0.7.0 — no goal, no +/// stamp, no root. The rows built from it are what an operator sees when the +/// three readers answer nothing, which is the case worth holding still: it must +/// render `unknown` and never a date. +fn unknown() -> exec::Parked { + exec::Parked::default() +} + /// A workspace and a store, with no configuration file anywhere near the /// developer's own. /// @@ -1647,10 +1658,10 @@ fn the_listing_names_the_handle_each_parked_run_is_answered_through() { questions: vec![], step: 12, }; - let plain = exec::listed(41, &question, false).expect("a question is a row"); + let plain = exec::listed(41, &question, &unknown(), false).expect("a question is a row"); assert!(plain.contains("41") && plain.contains("219"), "{plain}"); - let row = exec::listed(41, &question, true).expect("a question is a row"); + let row = exec::listed(41, &question, &unknown(), true).expect("a question is a row"); let value: serde_json::Value = serde_json::from_str(&row).expect("one JSON object"); assert_eq!(value["run_id"], 41); assert_eq!(value["waiting_on"], "question"); @@ -1664,6 +1675,7 @@ fn the_listing_names_the_handle_each_parked_run_is_answered_through() { steps: vec![], step: 5, }, + &unknown(), true, ) .expect("a plan is a row"); @@ -1678,6 +1690,7 @@ fn the_listing_names_the_handle_each_parked_run_is_answered_through() { tool: "charge".into(), step: 4, }, + &unknown(), true, ) .expect("an interrupted call is a row"); @@ -1687,8 +1700,8 @@ fn the_listing_names_the_handle_each_parked_run_is_answered_through() { // A run whose process went away has no second id, and says so with a null // rather than with a number it made up. - let died = - exec::listed(41, &Pending::Died { last_step: 6 }, true).expect("a died run is a row"); + let died = exec::listed(41, &Pending::Died { last_step: 6 }, &unknown(), true) + .expect("a died run is a row"); let value: serde_json::Value = serde_json::from_str(&died).expect("one JSON object"); assert_eq!(value["waiting_on"], "died"); assert!(value["id"].is_null(), "{died}"); @@ -1697,11 +1710,121 @@ fn the_listing_names_the_handle_each_parked_run_is_answered_through() { // Under `--json` every row is one object and nothing else, the same split // `io exec --json` makes. for pending in [&question, &Pending::Died { last_step: 6 }] { - let row = exec::listed(41, pending, true).expect("a row"); + let row = exec::listed(41, pending, &unknown(), true).expect("a row"); assert!(row.starts_with('{') && row.ends_with('}'), "{row}"); } } +/// **F3 — a parked row says when it started and what it was for.** +/// +/// The 2026-09-05 field test met thirteen rows reading `run 41 question 219 +/// step 12` and differing only by numbers, and could not tell which of them was +/// the work it wanted. `runs.goal` had been written since io-harness 0.1.0 with no +/// public reader; there was no reader for a run's own start time either. Both +/// arrived in io-harness 0.81.0 as `Store::run_goal` and `Store::run_created_at`, +/// closing io-harness#258, which is what makes this criterion possible at all — +/// it was named as carried rather than promised when 0.38.2 was outlined. +/// +/// **The `unknown` arm is the one that matters and it is not the empty case.** +/// `Store::run_created_at` answers `None` for a run started before io-harness +/// 0.7.0, when the column did not exist — so `None` here means *either* no such +/// run *or* a run too old to have been stamped, and the oldest rows in a long- +/// lived store are exactly the ones somebody is trying to identify. Any rendered +/// time would be invented. +/// +/// Sabotage: render `started_at` with a fallback of the epoch, or `unwrap_or_ +/// default()` the goal, and the first two arms hold while the third fails. +#[test] +fn f3_a_parked_row_carries_the_runs_stamp_and_its_goal() { + use io_cli::resume::Pending; + + let question = Pending::Question { + question_id: 219, + question: "which environment?".into(), + context: None, + choices: vec![], + questions: vec![], + step: 12, + }; + let known = exec::Parked { + goal: Some("fix the tokenizer".into()), + started_at: Some("2026-09-06T07:13:44".into()), + root: Some("/work/parser".into()), + }; + + let plain = exec::listed(41, &question, &known, false).expect("a question is a row"); + assert!( + plain.contains("2026-09-06 07:13 UTC"), + "the stamp is the store's own string, cut to the minute and marked — the \ + same spelling `/resume` uses, because two listings of one store must not \ + spell a timestamp two ways: {plain}", + ); + assert!( + plain.contains("fix the tokenizer"), + "the goal is what tells two parked runs apart: {plain}", + ); + + // The JSON keys are appended and nullable, so a reader written against the + // 0.37.0 shape goes on working — and `started_at` is the store's raw sortable + // string there rather than the cut one, which is the same stdout/stderr split + // of audiences `io exec --json` already makes. + let row = exec::listed(41, &question, &known, true).expect("a question is a row"); + let value: serde_json::Value = serde_json::from_str(&row).expect("one JSON object"); + assert_eq!(value["goal"], "fix the tokenizer"); + assert_eq!(value["started_at"], "2026-09-06T07:13:44"); + assert_eq!(value["root"], "/work/parser"); + assert_eq!(value["run_id"], 41, "and the old keys are untouched"); + assert_eq!(value["waiting_on"], "question"); + + // A run the store cannot stamp says so in a word, and the word is not a time. + let old = exec::Parked { + goal: Some("something from before the column existed".into()), + started_at: None, + root: None, + }; + let plain = exec::listed(41, &question, &old, false).expect("a question is a row"); + assert!( + plain.contains("unknown"), + "a run older than io-harness 0.7.0 has no stamp: {plain}", + ); + assert!( + !plain.contains("1970"), + "and the epoch is a date, which is the one thing this must never be: \ + {plain}", + ); + let row = exec::listed(41, &question, &old, true).expect("a question is a row"); + let value: serde_json::Value = serde_json::from_str(&row).expect("one JSON object"); + assert!( + value["started_at"].is_null(), + "null, the shape `id` already uses for a row that has none: {row}", + ); + + // A multi-line goal is folded to one line, because one row per run is the + // shape being read — and it is NOT cut. `n6_the_plain_output_is_never_ + // composed_to_a_width` holds this file to composing nothing: a headless stream + // is not a viewport, and clipping it loses data a machine was going to read. + // The first draft of this truncated to sixty characters and that gate caught + // it, which is the arm below. + let long = exec::Parked { + goal: Some(format!("{}\nand a second line", "w".repeat(200))), + ..exec::Parked::default() + }; + let one_line = long.short_goal().expect("a goal that is there is rendered"); + assert!(!one_line.contains('\n'), "one row per run: {one_line:?}"); + assert!( + one_line.contains(&"w".repeat(200)), + "the goal is folded and never clipped: a row this door writes is read by \ + machines as well as people, and the `--json` object carries it verbatim \ + because that is the stream which promises it", + ); + + assert_eq!( + unknown().short_goal(), + None, + "no goal renders nothing rather than an empty pair of spaces", + ); +} + /// 0.23.0 F8 — the two runs nobody is waiting on are not offered as work. #[test] fn a_run_that_cannot_be_carried_on_is_not_a_row_in_the_listing() { @@ -1712,8 +1835,16 @@ fn a_run_that_cannot_be_carried_on_is_not_a_row_in_the_listing() { io_cli::resume::Pending::Interrupted, io_cli::resume::Pending::Finished, ] { - assert_eq!(exec::listed(41, &pending, false), None, "{pending:?}"); - assert_eq!(exec::listed(41, &pending, true), None, "{pending:?}"); + assert_eq!( + exec::listed(41, &pending, &unknown(), false), + None, + "{pending:?}" + ); + assert_eq!( + exec::listed(41, &pending, &unknown(), true), + None, + "{pending:?}" + ); } } @@ -1977,7 +2108,7 @@ fn a_parked_batch_is_listed_and_refused_as_the_several_questions_it_is() { // The listing keeps naming what the run is waiting on. Renaming `question` // would describe a difference the resume door does not have and break every // script written against this stream. - let row = exec::listed(41, &batch, true).expect("a batch is a row"); + let row = exec::listed(41, &batch, &unknown(), true).expect("a batch is a row"); let value: serde_json::Value = serde_json::from_str(&row).expect("one JSON object"); assert_eq!(value["waiting_on"], "question"); assert_eq!( @@ -2000,7 +2131,7 @@ fn a_parked_batch_is_listed_and_refused_as_the_several_questions_it_is() { questions: vec![], step: 12, }; - let row = exec::listed(41, &single, true).expect("a question is a row"); + let row = exec::listed(41, &single, &unknown(), true).expect("a question is a row"); let value: serde_json::Value = serde_json::from_str(&row).expect("one JSON object"); assert_eq!(value["questions"], 1); // And the three pauses that are not questions say null rather than a count of @@ -2013,20 +2144,22 @@ fn a_parked_batch_is_listed_and_refused_as_the_several_questions_it_is() { }, Pending::Died { last_step: 6 }, ] { - let row = exec::listed(41, &pending, true).expect("a row"); + let row = exec::listed(41, &pending, &unknown(), true).expect("a row"); let value: serde_json::Value = serde_json::from_str(&row).expect("one JSON object"); assert!(value["questions"].is_null(), "{row}"); } // The plain stream says it too, and only when there is more than one: a // `1 question` on every row is the mark nobody reads. - let plain = exec::listed(41, &batch, false).expect("a batch is a row"); + let plain = exec::listed(41, &batch, &unknown(), false).expect("a batch is a row"); assert!(plain.contains("3 questions"), "{plain}"); assert_eq!( - exec::listed(41, &single, false).expect("a question is a row"), - "run 41 question 219 step 12", - "a single question's plain row is byte for byte what it was, so nothing new \ - appears on the rows an operator already reads", + exec::listed(41, &single, &unknown(), false).expect("a question is a row"), + "run 41 question 219 step 12 unknown", + "a single question's plain row carries the four fields it always did, plus \ + the stamp 0.38.2 added — `unknown` here because this fixture's store \ + answers nothing, which is also what a run started before io-harness \ + 0.7.0 gets. The one thing it must never be is a date", ); // The refusal an operator gets when they type no `--answer`. diff --git a/tests/home.rs b/tests/home.rs index 55a3a39..37607f7 100644 --- a/tests/home.rs +++ b/tests/home.rs @@ -434,12 +434,18 @@ fn f9_running_twice_moves_nothing_the_second_time() { second.kept.is_empty(), "and nothing to keep, because the source is gone" ); - assert_eq!( - second.lines().len(), - 1, - "a run that moved nothing reports the home and nothing else" + // **And says nothing at all (0.38.2).** This asserted the opposite — one line, + // naming the home — which is the line the field test met on every single + // invocation of every subcommand, for a directory that had existed since the + // operator's first run. A report is owed for work that happened; a second + // adoption does no work. The line is now drawn only where something was + // created or moved, which the F8 arm in this file holds directly and this arm + // holds on the idempotent path. + assert!( + second.lines().is_empty(), + "a run that moved nothing has nothing to report: {:?}", + second.lines(), ); - assert!(second.lines()[0].contains(".io-cli")); } /// **N3.** The home belongs to the operator alone. A credential sits in the file @@ -533,8 +539,11 @@ fn f8_the_home_line_is_drawn_only_when_this_run_created_or_moved_something() { blocked: None, created, }; - let says_home = - |r: &home::Report| r.lines().iter().any(|line| line.contains("keeps its files in")); + let says_home = |r: &home::Report| { + r.lines() + .iter() + .any(|line| line.contains("keeps its files in")) + }; assert!( says_home(&report(true, false)), diff --git a/tests/lock.rs b/tests/lock.rs index e290b5e..61c3986 100644 --- a/tests/lock.rs +++ b/tests/lock.rs @@ -556,7 +556,12 @@ fn n3_the_lock_and_its_record_are_readable_by_their_owner_alone() { fn f7_the_sweep_takes_finished_locks_and_leaves_held_ones() { let home = tempfile::tempdir().expect("a temporary home"); let root = home.path().join("workspace"); - let now = SystemTime::now(); + // A fixed instant, never a real clock read: N1 bans one in a test, and this + // one has no need of a real instant — the sweep decides on the owner record + // and `try_lock`, never on how old a stamp is. (N1's sweep does not strip + // comments, so naming the banned call even to say it is not used fails the + // gate. That is the right trade for a gate this cheap.) + let now = started(); // 1. A session that finished: its guard is dropped, so the owner record is // gone and nothing holds the lock. @@ -604,3 +609,34 @@ fn f7_the_sweep_takes_finished_locks_and_leaves_held_ones() { drop(held); } + +/// **F7's other half — the driver actually calls the sweep.** +/// +/// `lock::sweep` being correct is not evidence that anything runs it, and nothing +/// under `tests/` links `src/main.rs`, so the call has no gate but this one. The +/// arm above passes in full with the call deleted, and the symptom of deleting it +/// is a directory quietly filling with empty lock files again — the exact +/// 2026-09-05 field-test finding, reverted, under a green suite. +/// +/// A source-text gate, and it says so. The same instrument the wizard's and the +/// plugin search's driver calls are held by, for the same reason. +/// +/// Sabotage: delete the `lock::sweep` call in `src/main.rs`, or move it below +/// `acquire`, where this process's own lock is already held. +#[test] +fn f7_the_driver_sweeps_before_it_takes_a_lock() { + let main = std::fs::read_to_string("src/main.rs").expect("the driver is readable"); + + let sweep = main + .find("lock::sweep") + .expect("the driver never calls `lock::sweep`, so finished locks leak forever"); + let acquire = main + .find("lock::acquire") + .expect("the driver takes a session lock somewhere"); + assert!( + sweep < acquire, + "the sweep must run before this session takes its own lock — after it, the \ + file this process is about to hold is one of the candidates, and the \ + ordering is the whole of why the sweep is safe", + ); +} diff --git a/tests/markdown.rs b/tests/markdown.rs index 6ec193c..4c37492 100644 --- a/tests/markdown.rs +++ b/tests/markdown.rs @@ -139,8 +139,13 @@ fn a_bullet_becomes_the_themes_bullet_at_its_own_depth() { #[test] fn a_fenced_block_is_left_exactly_as_the_model_wrote_it() { let lines = render("```rust\nlet x = *p; // **not bold**\n```\nafter **bold**"); - // The opening fence draws the language and the closing one draws nothing. - assert_eq!(text(&lines[0]), "rust"); + // **Neither fence draws a word (0.38.2).** This asserted `"rust"` on the + // opening line, which is the defect the field test reported rather than the + // behaviour: a bare `rust` in the scrollback reads as something the model + // said. See `f8_a_fence_draws_no_bare_language_line` for the property; this + // arm holds the line still being *there*, because a fence that drew nothing at + // all would close the block up against the prose above it. + assert_eq!(text(&lines[0]), ""); assert_eq!(text(&lines[1]), "let x = *p; // **not bold**"); assert!(carrying(&lines[1], Modifier::BOLD).is_empty()); assert_eq!(text(&lines[2]), ""); @@ -199,7 +204,10 @@ fn f8_a_fence_draws_no_bare_language_line() { let inside = md.line("print('hi')", &DARK); assert_eq!(text(&inside), "print('hi')"); assert!( - inside.spans.iter().any(|span| span.style != DARK.style(io_cli::theme::Tone::Normal)), + inside + .spans + .iter() + .any(|span| span.style != DARK.style(io_cli::theme::Tone::Normal)), "code inside a fence must not be styled as ordinary prose, or nothing \ marks where the block is", ); diff --git a/tests/marketplace.rs b/tests/marketplace.rs index 4abbdc2..3216799 100644 --- a/tests/marketplace.rs +++ b/tests/marketplace.rs @@ -1974,7 +1974,15 @@ fn the_surface_where_consent_happens_never_shortens_a_hook_s_command() { #[test] fn f12_writing_enabled_says_what_it_costs_an_older_binary() { let said = io_cli::pluginview::OLDER_BINARY; - for named in ["enabled", "[[plugin]]", "0.70.0", "0.69.0", "whole"] { + // **The version named is io-cli's, not io-harness's (0.38.2).** This required + // `0.70.0` and `0.69.0` — the harness release the key arrived in and the one + // before it — which was true and was the wrong frame: an operator knows which + // `io` they installed and cannot check which harness it was built against. It + // also rotted, because the pin moved eleven times and the sentence could not + // follow. io-cli 0.29.0 is the release that first understood the key and it + // does not move; `f4_no_compatibility_note_names_an_io_harness_version` holds + // the other half, that no harness version comes back. + for named in ["enabled", "[[plugin]]", "io-cli", "0.29.0", "whole"] { assert!( said.contains(named), "the disclosure does not name `{named}`: {said}", diff --git a/tests/servers_enabled.rs b/tests/servers_enabled.rs index 02a2646..f588790 100644 --- a/tests/servers_enabled.rs +++ b/tests/servers_enabled.rs @@ -305,8 +305,8 @@ fn f10_widening_keys_by_one_name_did_not_widen_the_check() { /// **F12 — the two `enabled` writes cost an older binary opposite things.** /// -/// A `[[plugin]]` entry carrying `enabled` makes an io-cli built against io-harness -/// 0.69.0 refuse the **whole configuration file**: every setting in it, loudly, at +/// A `[[plugin]]` entry carrying `enabled` makes an io-cli older than 0.29.0 +/// refuse the **whole configuration file**: every setting in it, loudly, at /// startup. The same key in an `[[mcp]]` entry is **ignored** by that binary, and /// the server the operator switched off starts and runs with nothing said. /// @@ -594,3 +594,39 @@ async fn f14_a_probe_and_a_state_nobody_reached_are_different_words() { "a probe put its own result under a heading that says a turn produced it", ); } + +/// **F4 — a compatibility note names an io-cli release, never an io-harness one.** +/// +/// Both `OLDER_BINARY` sentences described the boundary as "io-harness 0.70.0's, +/// and a binary built against 0.69.0 …". Every word of that was true and it was +/// the wrong frame twice over. An operator knows which `io` they installed and +/// has no reason to know which harness it was compiled against, so the sentence +/// read as a note about somebody else's dependency rather than as a warning about +/// their own second machine — which is how the 2026-09-05 field test reported it, +/// as stale noise on a routine `io mcp disable`. +/// +/// And it rotted. The pin has moved eleven times since that sentence was written +/// and the sentence could not follow, because the fact it names is not the pin — +/// it is the io-cli release that first understood the key, which is 0.29.0 and +/// does not change. Naming the durable fact is what makes this gate possible at +/// all: there is no version here for a later release to have to update. +/// +/// Sabotage: put any `io-harness 0.NN.0` back into either constant. +#[test] +fn f4_no_compatibility_note_names_an_io_harness_version() { + for (which, sentence) in [ + ("pluginview", io_cli::pluginview::OLDER_BINARY), + ("servers", servers::OLDER_BINARY), + ] { + assert!( + !sentence.contains("io-harness"), + "`{which}::OLDER_BINARY` names io-harness, which an operator did not \ + install and cannot check: {sentence}", + ); + assert!( + sentence.contains("io-cli") && sentence.contains("0.29.0"), + "`{which}::OLDER_BINARY` must name the io-cli release that first \ + understood the key, which is the fact that does not move: {sentence}", + ); + } +} diff --git a/tests/sessions.rs b/tests/sessions.rs index 5ae51ca..e42a4d1 100644 --- a/tests/sessions.rs +++ b/tests/sessions.rs @@ -283,20 +283,38 @@ fn f1_a_row_carries_the_root_the_turn_count_and_the_first_prompt() { nothing on its own", ); - // The stamp is the store's string, sliced to the minute — not a relative - // age. "two minutes ago" would need the current time, and this module is not - // allowed to ask for it. + // The stamp is the store's string, sliced to the minute and marked — not a + // relative age. "two minutes ago" would need the current time, and this module + // is not allowed to ask for it. + // + // **The marker is the assertion, not the length (0.38.2).** io-harness stores + // UTC and hands the string over unformatted; io-cli drew it beside a local + // wall clock with nothing saying which, so a session started at 07:13 UTC read + // as 07:13 to somebody at 12:43. `sessions::ZONE` is what fixes it, and a bare + // length check is what let it ship unnoticed — the count moved from 16 to 20 + // and said nothing about why. + assert!( + row.at.ends_with(io_cli::sessions::ZONE), + "a stamp with no zone is read as the reader's own clock: {row:?}", + ); assert_eq!( row.at.chars().count(), - 16, - "a stamp cut to the minute: {row:?}" + "2026-09-06 07:13".chars().count() + io_cli::sessions::ZONE.chars().count(), + "a stamp cut to the minute, plus the marker: {row:?}", ); assert!( !row.at.contains("ago"), "a relative age would mean a clock read: {row:?}", ); + // **Over the stamp and not over the whole string (0.38.2).** This read + // `!row.at.contains('T')`, which the marker `ZONE` falsifies all by itself — + // `UTC` has a `T` in it. The claim being made is about the ISO separator at + // index 10, so it is made about the stamp, and the marker is asserted + // separately above. The length arm failed first, which is the only reason this + // did not ship as a green test asserting something that could not be true. + let stamp: String = row.at.chars().take(16).collect(); assert!( - row.at.chars().nth(10) == Some(' ') && !row.at.contains('T'), + stamp.chars().nth(10) == Some(' ') && !stamp.contains('T'), "the stored `T` separator is replaced by a space for reading: {row:?}", ); } diff --git a/tests/timing.rs b/tests/timing.rs index f63cadf..a6257fc 100644 --- a/tests/timing.rs +++ b/tests/timing.rs @@ -300,3 +300,125 @@ fn n1_the_driver_is_the_only_thing_that_reads_a_clock() { watching a string that is no longer written anywhere.", ); } + +/// Every test name this repository's own prose cites is a test that exists. +/// +/// **N6, and it is a criterion because 0.38.2 found one that was not.** The doc +/// comment on `Wizard::existing` said +/// "`f8_the_wizard…` holds it to that, because nothing under `tests/` links the +/// driver" — naming a gate that had never been written. Every *other* thing about +/// that feature was covered, so the suite was green, and the one line in the +/// driver that made the feature work could have been deleted with nothing going +/// red and a comment beside it asserting otherwise. +/// +/// That is worse than an uncovered mechanism. An uncovered mechanism is a gap +/// somebody may still find; a comment claiming coverage is the reason nobody +/// looks. It is the same class as a stale citation, and this release exists to +/// delete that class — so shipping the class in a new spelling would be its own +/// failure. +/// +/// **The convention is what makes this checkable.** Tests here are named +/// `f_…`, `n_…` or `o_…` after the acceptance criterion they hold, so a +/// token in that shape is a test name and nothing else — no variable, no field and +/// no function in `src/` is spelled that way. Anything matching it must resolve to +/// a `fn` under `tests/`. +/// +/// Sabotage: put a criterion-shaped name that no test defines into any comment — +/// spelled out here would fail this gate from inside its own documentation, which +/// is the shape working. +#[test] +fn n6_no_comment_cites_a_test_that_does_not_exist() { + let tests = test_sources(); + assert!( + tests.len() > 50, + "the walk found only {} test files, which means it is not walking", + tests.len(), + ); + + // Every test this repository actually defines. + // + // **Not `strip_prefix("fn ")`.** That was the first draft and it reported + // nineteen live tests as missing: `#[tokio::test] async fn` and `pub fn` both + // put a word in front, so every asynchronous test in the suite looked + // undefined. A gate whose first run produces nineteen findings is far more + // likely to be wrong about its own instrument than to have found nineteen + // defects, and it was. + // + // **Both trees, not just `tests/`.** The second draft still reported fourteen, + // and every one was a `#[cfg(test)] mod tests` unit test living in `src/` — + // `src/app.rs` alone holds nine. A test in the crate is a test that exists, and + // a citation of one is not a stale claim. + // + // Comment lines are skipped so that `// fn f99_…` cannot define a test by + // mentioning one, which would quietly make this gate permissive. + let sources: Vec<(PathBuf, String)> = crate_sources().into_iter().chain(tests).collect(); + let mut defined: Vec = Vec::new(); + for (_, source) in &sources { + for line in source.lines() { + let line = line.trim_start(); + if line.starts_with("//") { + continue; + } + let Some(at) = line.find("fn ") else { + continue; + }; + if let Some(name) = line[at + 3..].split(['(', '<']).next() { + defined.push(name.trim().to_string()); + } + } + } + + // A token is a criterion-named test if it is `f`/`n`/`o`, then digits, then an + // underscore, then more. `f2_x` is too short to be one of this repository's + // names and is excluded rather than guessed at. + // The length test comes first and is not decoration: splitting on delimiters + // yields empty strings between consecutive ones, and `word[1..]` on an empty + // string panics rather than answering false. + let cited_shape = |word: &str| { + if word.len() <= 12 || !matches!(word.as_bytes()[0], b'f' | b'n' | b'o') { + return false; + } + let digits = word[1..].chars().take_while(char::is_ascii_digit).count(); + digits > 0 + && word[1 + digits..].starts_with('_') + // A citation broken across two comment lines leaves a token ending in + // `_`, which is half a name rather than a missing test. + && word.ends_with(|c: char| c.is_ascii_alphanumeric()) + }; + + // **A prefix counts, and a deleted test is allowed to be named.** Both fell out + // of running this for the first time, and neither is a loophole: + // + // * prose elides a long name with an ellipsis — + // ``f11_the_permitted_spawn_set_is_exact_paths…`` in `tests/dependencies.rs` + // — and the token before the ellipsis is a prefix of the real test. The claim + // is true; only the spelling is short. + // * `tests/events.rs` records that a test **was deleted** and why. Naming it is + // the opposite of claiming it holds something, and losing that sentence would + // cost the reason the deletion was right. + // + // Anything else is a comment asserting a guarantee nothing enforces. + let mut missing: Vec = Vec::new(); + for (path, source) in &sources { + for line in source.lines() { + if line.contains("deleted") { + continue; + } + for word in line.split(|c: char| !(c.is_ascii_alphanumeric() || c == '_')) { + if cited_shape(word) && !defined.iter().any(|name| name.starts_with(word)) { + let where_ = format!("{}: {word}", path.display()); + if !missing.contains(&where_) { + missing.push(where_); + } + } + } + } + } + + assert!( + missing.is_empty(), + "these name a test that does not exist. A comment claiming a guarantee \ + nothing enforces is the reason nobody goes looking for the gap:\n{}", + missing.join("\n"), + ); +} diff --git a/tests/wizard.rs b/tests/wizard.rs index 7c41c19..790d1d5 100644 --- a/tests/wizard.rs +++ b/tests/wizard.rs @@ -965,7 +965,8 @@ fn f8_the_welcome_screen_names_the_configuration_already_in_force() { other => panic!("the welcome screen commits its lines: {other:?}"), }; - let mut told = Wizard::new(DARK).over(Some(std::path::PathBuf::from("/home/me/.io-cli/io.toml"))); + let mut told = + Wizard::new(DARK).over(Some(std::path::PathBuf::from("/home/me/.io-cli/io.toml"))); let said = commit(&mut told); assert!( said.contains("/home/me/.io-cli/io.toml"), @@ -983,3 +984,51 @@ fn f8_the_welcome_screen_names_the_configuration_already_in_force() { "an operator with no configuration is genuinely on their first run: {said}", ); } + +/// **F8 — the driver tells the wizard what it is about to write over.** +/// +/// This gate is written because `src/wizard.rs` already claimed it existed. Its +/// doc comment on `existing` said +/// "`f8_the_wizard_is_told_what_configuration_is_already_in_force` holds it to +/// that, because nothing under `tests/` links the driver" — and no such test was +/// ever written. Everything else about the feature was covered: `Wizard::over` +/// has arms, the wording has arms, and all of them pass with the one call in the +/// driver deleted, because `existing` defaults to `None` and `None` is the +/// first-run wording. So the whole feature was one line away from reverting to the +/// exact defect it fixes, in silence, with a comment asserting otherwise. +/// +/// A sentence claiming a guarantee that nothing enforces is worse than no +/// sentence: it is the reason nobody goes looking. That is the class of claim +/// 0.38.2 exists to delete, and the honest deletion here is to make the claim +/// true rather than to remove it. +/// +/// A source-text gate, and it says so. Nothing under `tests/` links +/// `src/main.rs`, so this reads the driver as text — the same instrument +/// `f9_plugin_search_with_no_match_says_nothing_matched` uses, for the same +/// reason. +/// +/// Sabotage: delete the `.over(...)` call in `src/main.rs`, or narrow it to +/// `user_path()` without the `is_file` filter. +#[test] +fn f8_the_wizard_is_told_what_configuration_is_already_in_force() { + let main = std::fs::read_to_string("src/main.rs").expect("the driver is readable"); + + assert!( + main.contains("Wizard::new(theme).over("), + "the driver builds a wizard without telling it what is already in force, \ + so `io setup` claims a first run over an operator's existing file — which \ + is the 2026-09-05 field-test finding, reverted", + ); + assert!( + main.contains("io_harness::config::user_path()"), + "the file named must be the one the wizard actually writes, which is the \ + user scope's — naming a discovered project file would point at something \ + this run is not about to touch", + ); + assert!( + main.contains(".filter(|path| path.is_file())"), + "a path that does not exist is not configuration in force. Without the \ + filter every operator is told their user file is at stake, which is the \ + same lie in the other direction", + ); +} From 900a0a1424661c99051c32ea87b58397a922db43 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 6 Sep 2026 13:25:03 +0530 Subject: [PATCH 06/10] fix: the six defects the adversarial review found, and withdraw the claim it falsified MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The suite was green, clippy was green, the record was unwritten and a read-only review of the diff found six. The largest is that this release's headline was false. Delegating context_window through the four wrappers does not move any number an operator sees. In io-harness 0.81.0 exactly one shipped provider implements it — Compatible — and it answers only from a catalogue models() has already fetched on that same instance, which this crate never does on the provider it hands a run. OpenRouter, Anthropic and OpenAi declare neither method. So the chain answers None, the harness reports source: fallback, and the ceiling is the same 24,000 it was in 0.38.1. The delegation is still right and still necessary: a wrapper that answers the trait's own default on behalf of the provider it wraps is wrong whatever the inner provider says, and that defect compiled clean and passed everything. What is withdrawn is the consequence. The CHANGELOG, the contract's outcome, its migrations note and its second risk all said an operator would see a different percentage; they now say what is true and why. Filed upstream as io-harness#266 with both halves of a fix. Recorded as US-IO-CLI-0.38.2-I01. Neither gate could have caught it. The delegation gate is a source-text sweep that proves a method is written and never that it answers, and the ceiling test supplies its own 128,000 by hand. That is the lesson worth the record. Five more, all real: - Status::note_context and the /status label still divided by the contract's budget, so a fold arriving on EventKind::Compacted and the window drawn beside the percentage would both have disagreed with the page the moment io-harness does answer — three denominators for one word on one screen, which is the defect this field has already been fixed for once. - Chain::max_output_tokens took the minimum across links by analogy with the window. The reserve is subtracted rather than divided into, so the minimum leaves the most room and overflows on the link that answers most — the same failure the window's minimum prevents, arriving through the arithmetic running the other way. It takes the maximum, with a behavioural test the text gate structurally could not provide. - The --json keys were described as appended. serde_json without preserve_order emits an object in key order, so goal now precedes run_id; nullability is what makes an existing reader safe, not position. - io config get answering "no such key" where it answered "default" is a script-facing behaviour change the CHANGELOG did not mention. --- CHANGELOG.md | 34 +++++++++------ src/exec.rs | 15 +++++-- src/provider.rs | 46 ++++++++++++++++---- src/status.rs | 22 +++++++++- tests/context.rs | 45 ++++++++++++++++++++ tests/provider.rs | 105 ++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 241 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d781b0..3196006 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,9 +24,14 @@ the feature was one deleted line from reverting in silence. The pin moves 0.79 → 0.81.0. Its two additions to the `Provider` trait default to `None`, so every wrapper in this crate compiled clean, passed every test, and answered for itself instead of for the -provider it wraps — leaving every run on the 24,000-token fallback that harness release exists to -end. Nothing reported it and nothing could have; there is a gate now that reads the method list out -of the locked harness rather than out of a list somebody wrote down. +provider it wraps. Nothing reported it and nothing could have; there is a gate now that reads the +method list out of the locked harness rather than out of a list somebody wrote down. + +Delegating them does not move the number yet, and the release says so rather than implying +otherwise. In io-harness 0.81.0 only `Compatible` implements those two methods, and only from a +catalogue that has to be fetched on the same instance first — so on OpenRouter, Anthropic and +OpenAI the run still assembles under the 24,000-token fallback. That is upstream's to answer and it +has been filed. What this release fixes is that the wrapper is no longer the thing losing it. ### Added @@ -38,16 +43,21 @@ of the locked harness rather than out of a list somebody wrote down. ### Changed -- **Behaviour change for operators.** A run's context ceiling now follows the model where the - provider knows its window, so `ctx N%` and the `/context` page divide by a real number instead of - a flat 24,000. On a large-window model the percentage will read much lower than it did for the - same conversation, and that is the correction: the old figure began reporting pressure at roughly - a fifth of the window actually available. A `[run.context] max_tokens` you set still wins over - both. +- `ctx N%`, the `/context` page and `/status` all divide by the ceiling the run announces + (`EventKind::ContextCeiling`, new in io-harness 0.81.0) rather than by the budget on the contract + io-cli built. **On the providers this release can construct, that is the same 24,000 it was**: + in io-harness 0.81.0 only `Compatible` answers `context_window`, and only from a catalogue + `models()` has already fetched on that same instance, so the harness reports `source: "fallback"` + and the number does not move. The plumbing is connected end to end and gated; what it waits on is + a provider that answers. Nothing you see changes yet, and that is stated rather than implied. - **Behaviour change for scripts.** The plain rows of `io resume --list` are wider — a script - slicing them by column position sees new fields at the end. The `--json` shape is the one this - product asks scripts to parse, and its three new keys are appended and nullable, so an existing - reader is unaffected. + slicing them by column position sees new fields. The `--json` shape is the one this product asks + scripts to parse, and its three new keys are nullable, so an existing reader is unaffected. They + are not appended: `serde_json` emits an object in key order, so `goal` now precedes `run_id`. + Anything parsing JSON is fine; anything matching the line by prefix is not. +- **Behaviour change for scripts.** `io config get` on a key that no file sets and that io-cli's + catalogue does not name answers `no such key` where it answered `default`. A misspelt key used to + be indistinguishable from an unset one, which is the whole reason to ask. - The two "older binary" warnings on `[[mcp]]` and `[[plugin]]` `enabled` keys name io-cli 0.29.0 — the release that first understood the key — instead of io-harness 0.69.0 and 0.70.0. Same boundary, stated in the version an operator can actually check, and it no longer goes stale every diff --git a/src/exec.rs b/src/exec.rs index 47a0b21..0a72036 100644 --- a/src/exec.rs +++ b/src/exec.rs @@ -1153,10 +1153,17 @@ pub fn listed(run_id: i64, pending: &Pending, parked: &Parked, json: bool) -> Op Some(if json { // Built through `serde_json` rather than formatted, so an answer holding // a quote is escaped by the same code that escapes the event stream. - // The three new keys are APPENDED and every one is nullable, so a reader - // written against the 0.37.0 shape goes on working: `serde_json` ignores - // what it was not asked for, and a `null` is the shape `id` and - // `questions` already use for a row that has none. + // The three new keys are all nullable, so a reader written against the + // earlier shape goes on working: it ignores what it did not ask for, and a + // `null` is the shape `id` and `questions` already use for a row that has + // none. + // + // **Not "appended", which is what this said first.** `serde_json` without + // `preserve_order` backs its map with a `BTreeMap`, so the object is + // emitted in key order and these three interleave — `goal` now comes out + // ahead of `run_id` as the first key of every row. That is safe for + // anything parsing JSON and is not safe for anything matching on a prefix, + // so the honest statement is nullability rather than position. // // `started_at` is the store's own string, unformatted. A script sorting // parked runs wants the sortable stamp io-harness stored, not a stamp cut diff --git a/src/provider.rs b/src/provider.rs index 62d48d0..70a7602 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -178,12 +178,27 @@ impl Provider for Vendor { // `max_output_tokens` with `None` defaults, so every wrapper in this file // compiles without them and answers "this provider is not saying" — which // sends the run back to `context::FALLBACK_MAX_TOKENS`, the 24,000 the - // harness release exists to stop being universal. `Compatible` and - // `OpenRouter` both answer from a catalogue they have already fetched, so - // the answer is here and this crate was throwing it away. There is no - // compiler error and no test failure for a defaulted method: the only gate - // is `f2_every_provider_method_is_delegated_by_every_wrapper`, which reads - // the method list out of the locked harness rather than a literal. + // harness release exists to stop being universal. + // + // **What this does NOT do today, stated because the first draft of this + // comment claimed otherwise.** In io-harness 0.81.0 exactly one shipped + // provider implements either method — `Compatible`, at + // `io-harness-0.81.0/src/provider/compatible.rs:512` — and it answers only + // from a catalogue `models()` has already fetched *on that instance*. + // `OpenRouter`, `Anthropic` and `OpenAi` declare neither and take the trait's + // `None`, and this crate never awaits `models()` on the provider it hands a + // run. So on every configuration io-cli builds today the chain answers `None`, + // the harness emits `source: "fallback"`, and the ceiling is the same 24,000 + // it was before. Delegating is still right and still necessary — it is the + // wrapper's whole job, and a wrapper that answers for itself is wrong whatever + // the inner provider says — but the operator-visible change waits on + // io-harness, and `US-IO-CLI-0.38.2-I01` records that. + // + // There is no compiler error and no test failure for a defaulted method: the + // only gate is `f2_every_provider_method_is_delegated_by_every_wrapper`, which + // reads the method list out of the locked harness rather than a literal — and + // it is a source-text gate, so it proves the method is written and never that + // anything answers. fn context_window(&self) -> Option { match self { Self::OpenRouter(p) => p.context_window(), @@ -414,12 +429,27 @@ impl Provider for Chain

{ self.links.iter().filter_map(Provider::context_window).min() } - /// The smallest reserved answer any link declares, for the same reason. + /// **The largest reserved answer any link declares — the opposite of the + /// window above, because this number is subtracted rather than divided by.** + /// + /// The instinct is that the two are the same question and take the same + /// conservative direction. They are not. `ContextBudget::for_window` computes + /// `max_tokens = window - (max_output + floor)`, so a *smaller* reserve leaves + /// a *larger* assembled section: taking the minimum reserves room for the link + /// that answers least and then overflows on the link that answers most. + /// + /// Concretely, two links on 128,000-token windows, the head allowing 4,096 + /// output tokens and the fall-through allowing 32,768. The minimum reserves + /// 4,096, leaving 115,712 for the prompt; the fall-through may then answer + /// 32,768 into a 128,000 window that already holds 115,712. That is the same + /// overflow the window's minimum exists to prevent, arriving at the same + /// moment — after the head has failed — through the arithmetic running the + /// other way. fn max_output_tokens(&self) -> Option { self.links .iter() .filter_map(Provider::max_output_tokens) - .min() + .max() } fn name(&self) -> &str { diff --git a/src/status.rs b/src/status.rs index 5a44056..76bf484 100644 --- a/src/status.rs +++ b/src/status.rs @@ -1021,7 +1021,19 @@ impl Status { } pub fn note_context(&mut self, est_tokens: u64) { - let Some(window) = self.budgets.window.filter(|window| *window > 0) else { + // **The announced ceiling first, and the contract only where nothing was + // announced (0.38.2).** This divided by `budgets.window` alone, which is + // the contract's own budget — so a fold arriving through + // `EventKind::Compacted` computed its share against 24,000 while the + // `/context` page one keystroke away used the run's real ceiling. Two + // denominators for one word on one screen is the failure this field has + // already been fixed for once; a third reader of the same question is how + // it came back. + let Some(window) = self + .ceiling + .or(self.budgets.window) + .filter(|window| *window > 0) + else { return; }; let share = (est_tokens as f64 / window as f64 * 100.0).round(); @@ -2378,7 +2390,13 @@ pub fn committed( // every contract declares one. The arm below is that type's `None` spelled out // rather than an `unwrap` — a committed page is not worth a panic — and it is // unreachable from here. - let window = match Budgets::in_force(contract).window { + // The same denominator the share was taken against, and not the contract's + // (0.38.2). `status.context` is a percentage of the announced ceiling; drawing + // it beside the contract's number would print a fill and a window that do not + // multiply out — `39% of a 24,000 window` on a run assembling inside 103,424. + // The page and the line were made one expression for exactly this reason and + // this is the third surface asking the same question. + let window = match status.ceiling.or(Budgets::in_force(contract).window) { Some(tokens) => format_tokens(tokens), None => "unknown".to_string(), }; diff --git a/tests/context.rs b/tests/context.rs index 81ca0e6..beeacc2 100644 --- a/tests/context.rs +++ b/tests/context.rs @@ -619,6 +619,51 @@ fn f10_the_status_share_is_the_page_total_over_the_page_window() { expected > 0, "a fixture where both read zero would pass this test and prove nothing", ); + + // **The same agreement once a ceiling has been announced (0.38.2), which is + // the arm this test was missing.** Everything above runs with nothing + // announced, so it agreed for the old reason and would have gone on agreeing + // while three other surfaces divided by the contract. An adversarial review + // found `/status`'s label and `Status::note_context` both still reading + // `budgets.window`; this is the arm that would have caught them. + const ANNOUNCED: u64 = 103_424; + let mut status = io_cli::status::Status::new("a-model"); + status.budgets = io_cli::status::Budgets::in_force(&contract); + status.note_ceiling(ANNOUNCED); + status.note_context_request(&seen, &contract, contract.max_tokens); + + let announced_window = context::window(&contract, contract.max_tokens, Some(ANNOUNCED)); + assert_eq!( + announced_window, ANNOUNCED, + "the announced ceiling is the page's denominator", + ); + let expected = (total as f64 / ANNOUNCED as f64 * 100.0).round() as u8; + assert_eq!( + status.context, + Some(expected), + "the share follows the announced ceiling, not the contract's budget", + ); + + // `note_context` is the OTHER writer of the same field — a fold arrives on + // `EventKind::Compacted` and goes straight through it — and it divided by + // `budgets.window` alone, so one screen carried two denominators for one word. + let mut folded = io_cli::status::Status::new("a-model"); + folded.budgets = io_cli::status::Budgets::in_force(&contract); + folded.note_ceiling(ANNOUNCED); + folded.note_context(30_000); + assert_eq!( + folded.context, + Some((30_000f64 / ANNOUNCED as f64 * 100.0).round() as u8), + "a fold's share is taken against the run's ceiling too — against the \ + contract's 24,000 this reads 100%, which is the pressure it is reporting \ + wrongly", + ); + assert_ne!( + folded.context, + Some(100), + "and 100% is what the contract's budget would have produced, so this is \ + the arm that tells the two apart", + ); } /// The window is forgotten with the conversation it described. diff --git a/tests/provider.rs b/tests/provider.rs index a1c1332..839cf33 100644 --- a/tests/provider.rs +++ b/tests/provider.rs @@ -574,6 +574,111 @@ fn f5_an_image_is_refused_unless_every_link_accepts_one() { ); } +/// A link that declares a window and an answer limit, and nothing else. +/// +/// Separate from [`Fake`] rather than a field on it: every one of `Fake`'s callers +/// would have to be given two more arguments to test a property none of them are +/// about, and the trait's `None` default is exactly what this fixture exists to +/// override. +struct Declaring { + window: Option, + output: Option, +} + +impl io_harness::Provider for Declaring { + async fn complete( + &self, + _request: io_harness::CompletionRequest, + ) -> io_harness::Result { + Ok(io_harness::CompletionResponse::default()) + } + + fn context_window(&self) -> Option { + self.window + } + + fn max_output_tokens(&self) -> Option { + self.output + } +} + +/// **F2's behavioural half — a chain's two window answers go opposite ways.** +/// +/// The text gate below proves the methods are written. It cannot prove either one +/// returns the right thing, and an adversarial review found `max_output_tokens` +/// returning the wrong one: it took the minimum "for the same reason" as the +/// window, and the reason inverts. +/// +/// `ContextBudget::for_window` computes `max_tokens = window - (max_output + +/// floor)`. The window is *divided into*, so the smallest is safe. The reserve is +/// *subtracted*, so the smallest leaves the most room and overflows on the link +/// that answers most — the same failure the window's minimum prevents, reached by +/// the arithmetic running the other way, and at the same moment: after the head +/// has already failed. +/// +/// The numbers are deliberately not symmetric, so an implementation that took the +/// same direction for both would fail one arm rather than passing both. +/// +/// Sabotage: swap either `min` for `max`. +#[test] +fn f2_a_chain_takes_the_smallest_window_and_the_largest_reserve() { + let chain = Chain::of(vec![ + Declaring { + window: Some(200_000), + output: Some(4_096), + }, + Declaring { + window: Some(32_000), + output: Some(32_768), + }, + ]) + .expect("a chain"); + + assert_eq!( + chain.context_window(), + Some(32_000), + "the smallest window: a run assembles its context once, and sizing to the \ + head then falling through to a smaller link overflows on the call that \ + matters", + ); + assert_eq!( + chain.max_output_tokens(), + Some(32_768), + "the largest reserve, which is the opposite direction and is why this arm \ + exists: the reserve is subtracted from the window, so the smallest one \ + leaves the most room and the fall-through's longer answer runs off the end", + ); + + // A link that says nothing is skipped rather than counted as zero, and a chain + // where nobody says anything is `None` — which is the pre-0.38.2 fallback, not + // a window of zero. + let quiet = Chain::of(vec![ + Declaring { + window: None, + output: None, + }, + Declaring { + window: Some(64_000), + output: None, + }, + ]) + .expect("a chain"); + assert_eq!(quiet.context_window(), Some(64_000), "silence is not zero"); + assert_eq!(quiet.max_output_tokens(), None); + + let silent = Chain::of(vec![Declaring { + window: None, + output: None, + }]) + .expect("a chain"); + assert_eq!( + silent.context_window(), + None, + "a chain nobody sized answers `None`, which sends the run to io-harness's \ + own fallback exactly as before", + ); +} + /// The text between a declaration's opening brace and the first `}` in column /// zero, which is where every item in the harness's source ends. fn body_after<'a>(source: &'a str, marker: &str) -> &'a str { From badb9ae387a1638db8e1036fc5e5321cc1e859ff Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 6 Sep 2026 13:27:47 +0530 Subject: [PATCH 07/10] test(lock): the driver-sweep gate matched its own sabotage as a substring lock::sweep_DELETED contains lock::sweep, so the arm renaming the call survived and the gate would have passed over a driver that no longer sweeps. The needle carries its opening paren now. Same family as the permitted-spawn set being compared with == rather than by substring: a needle that matches a longer name is a check that widens itself. --- tests/lock.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/lock.rs b/tests/lock.rs index 61c3986..0cfe49c 100644 --- a/tests/lock.rs +++ b/tests/lock.rs @@ -627,11 +627,18 @@ fn f7_the_sweep_takes_finished_locks_and_leaves_held_ones() { fn f7_the_driver_sweeps_before_it_takes_a_lock() { let main = std::fs::read_to_string("src/main.rs").expect("the driver is readable"); + // **The parens are the assertion, and their absence was a real defect.** This + // searched for `lock::sweep`, and the sabotage arm that renames the call to + // `lock::sweep_DELETED` *contains* that string — so the arm survived and the + // gate would have passed over a driver that no longer sweeps anything. Same + // family as the permitted-spawn set being compared with `==` rather than by + // substring: a needle that matches a longer name is a check that widens + // itself. let sweep = main - .find("lock::sweep") - .expect("the driver never calls `lock::sweep`, so finished locks leak forever"); + .find("lock::sweep(") + .expect("the driver never calls `lock::sweep(`, so finished locks leak forever"); let acquire = main - .find("lock::acquire") + .find("lock::acquire(") .expect("the driver takes a session lock somewhere"); assert!( sweep < acquire, From a2e55a5c52353c8e67955b335d1acc0897b3bd18 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 6 Sep 2026 13:28:45 +0530 Subject: [PATCH 08/10] test(lock): the driver-sweep gate read its own comments Commenting the call out leaves lock::sweep( in the file verbatim, so the second sabotage arm survived the first fix. The sweep now strips comment lines, which is what tests/dependencies.rs already does for its needles. Third gate in this release fooled by prose rather than by code. --- tests/lock.rs | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/tests/lock.rs b/tests/lock.rs index 0cfe49c..6958283 100644 --- a/tests/lock.rs +++ b/tests/lock.rs @@ -625,15 +625,27 @@ fn f7_the_sweep_takes_finished_locks_and_leaves_held_ones() { /// `acquire`, where this process's own lock is already held. #[test] fn f7_the_driver_sweeps_before_it_takes_a_lock() { - let main = std::fs::read_to_string("src/main.rs").expect("the driver is readable"); - - // **The parens are the assertion, and their absence was a real defect.** This - // searched for `lock::sweep`, and the sabotage arm that renames the call to - // `lock::sweep_DELETED` *contains* that string — so the arm survived and the - // gate would have passed over a driver that no longer sweeps anything. Same - // family as the permitted-spawn set being compared with `==` rather than by - // substring: a needle that matches a longer name is a check that widens - // itself. + // **Comments stripped, and parens required. Both were found by sabotage, one + // arm at a time.** + // + // The first draft searched for `lock::sweep`, and the arm that renames the + // call to `lock::sweep_DELETED` *contains* that string, so it survived. Adding + // the paren killed that arm and not the next one: commenting the call out + // leaves `lock::sweep(` in the file verbatim, and a gate reading raw text + // cannot tell a call from a mention of one. + // + // So the sweep runs over code with the comment lines removed, which is what + // `tests/dependencies.rs` already does for its own needles and for the same + // reason. Three separate gates in this release have now been fooled by their + // own prose; a source-text gate that does not strip comments is checking the + // documentation rather than the program. + let main: String = std::fs::read_to_string("src/main.rs") + .expect("the driver is readable") + .lines() + .filter(|line| !line.trim_start().starts_with("//")) + .collect::>() + .join("\n"); + let sweep = main .find("lock::sweep(") .expect("the driver never calls `lock::sweep(`, so finished locks leak forever"); From cec48a5d2839110a31c0e970cc0215e67f74af9f Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 6 Sep 2026 13:45:35 +0530 Subject: [PATCH 09/10] chore(release): bump to 0.38.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The doc gates that read the manifest are only meaningful after this, so the full suite, the doctests, fmt and clippy were re-run against the bumped tree, and the release binary was built from it — an artifact whose own --version names the previous release is not evidence of this one. --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9577264..ecc06d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1850,7 +1850,7 @@ dependencies = [ [[package]] name = "io-cli" -version = "0.38.1" +version = "0.38.2" dependencies = [ "clap", "crossterm", diff --git a/Cargo.toml b/Cargo.toml index 78bdd6e..99877d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "io-cli" -version = "0.38.1" +version = "0.38.2" edition = "2021" # io-harness declares 1.95, set by `libsqlite3-sys` calling the std `cfg_select!` # macro from its build script. Nothing here can be lower than what the harness From 27ea33899925b660656440a8bdd8e1a94c7e1f5c Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 6 Sep 2026 13:56:39 +0530 Subject: [PATCH 10/10] test(provider): the delegation gate could not read a CRLF checkout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI checks this repository out with CRLF on Windows, so the "\n}\n" needle that finds the end of an impl never matched and the sweep panicked with "the impl is closed" — green on macOS and Linux, red on the one platform that cannot be reproduced locally. support::harness_source already normalises for exactly this and its comment calls it the third instance of the class here. This is the fourth, and the first to hit a file in the crate's own tree rather than a dependency's unpacked source. The sealed record is not amended: it claims a macOS suite of 1,982 over 107 binaries, which is still true, and it already records under known_limitations that the three-platform matrix runs on the pull request and that a green macOS gate is not evidence about Windows. No test was added, so no count moves. --- tests/provider.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/provider.rs b/tests/provider.rs index 839cf33..5be210b 100644 --- a/tests/provider.rs +++ b/tests/provider.rs @@ -738,7 +738,17 @@ fn f2_every_provider_method_is_delegated_by_every_wrapper() { thing rather than passing", ); - let ours = std::fs::read_to_string("src/provider.rs").expect("this crate's provider module"); + // **Newlines normalised, because CI checks this repository out with CRLF on + // Windows.** The needle below is `"\n}\n"` and a Windows checkout has + // `"\r\n}\r\n"`, so the impl sweep found no closing brace at all and the gate + // panicked with "the impl is closed" — green on macOS and Linux, red on the + // one platform nobody can reproduce locally. `support::harness_source` carries + // this exact reasoning for the registry source and says it is the third + // instance of the class in this repository; this is the fourth, and the first + // to hit a file in the crate's own tree rather than a dependency's. + let ours = std::fs::read_to_string("src/provider.rs") + .expect("this crate's provider module") + .replace("\r\n", "\n"); let mut wrappers: Vec<(&str, &str)> = Vec::new(); for (at, _) in ours.match_indices("Provider for ") { let line = ours[..at].rfind('\n').map_or(0, |n| n + 1);