Repository navigation
io-cli 0.23.0 - #60
Merged
Merged
Conversation
Both new modules ship their module-level rustdoc here rather than with their bodies, because the nightly rustdoc gate has caught a brand-new pub mod in five of the last six releases and the intra-doc links are the part that breaks. The version bump rides this commit because tests/docs.rs reads CARGO_PKG_VERSION to demand a CHANGELOG section for it.
An advisory whole-file lock through std::fs::File::try_lock: flock on unix, LockFileEx on Windows, released by the kernel on exit, on panic and on kill -9. No dependency, no liveness probe, no staleness heuristic for the ordinary case. Naming the holder cannot mean naming the OS process — there is no kill(pid,0), no ps and no tasklist available to this crate — so the refusal names what io-cli itself wrote beside the lock: pid, root, version and start instant, every field optional because the record can be missing, truncated, unreadable or written by another version. Scoped by workspace root rather than session id: the lock is taken before a session has an id, and Session::open answers the same session for two terminals in one repository, which is the collision guarded.
recent() has always walked run_status past without looking. It now reads it, and the pending rows beside it, and pairs each session with the newest run that served it during the same walk — so MAX_RUNS_SCANNED is untouched and there is no second scan. The state rides Row::mark and never label or detail: detail is the first thing a narrow terminal drops, and label is what the fuzzy matcher ranks. A finished session carries no mark at all. A question pause is stored with the status completed, not paused, so classification reads the pending row before the status.
Four re-entry paths kept deliberately apart — a question, a plan, an interrupted tool call, and a run whose process merely died — each with its own reader, its own harness entry point and its own id under its own name, so no bare integer crosses a surface. Session::drive is private, so the bookkeeping it does after the loop is done here: the turn is closed with the reply taken off the last assistant turn, and the head moves by compare-and-swap. A conflict is reported rather than forced. A turn the operator interrupted is terminal to every resume entry point in the pinned harness. It is reported as ended, and /fork from the preceding turn is offered instead.
Both overlays could only be built from a live turn holding a oneshot. A run that has already ended has no receiver to send into, so the answer has to come back as a value. One Destination<T> enum over the two resolutions, shared by both overlays. A oneshot is deliberately not reused for the stored path: a channel whose receiver is gone already means "nobody can answer, pause the run", so a stored answer sent down one would be indistinguishable from an abandoned question. key and render stay a single code path. The only branch is the intent footer, because a resumed decline leaves the run parked rather than deferring inside a turn that will pick it up.
rewind wrote the head unconditionally, so /undo in one terminal overwrote a head another had just advanced — no conflict, no error, and the other turn left in session_turns, answered and paid for but off the head path where Session::history can never reach it again. The doc argued for the direct write on the grounds that branch_from cannot express a None head. That was true of branch_from and never covered the unconditional write: set_session_head_if takes an Option on both sides. failure::advice gains a Conflict arm. The two shapes are not fully distinguishable — the head CAS leaves owner and expires_at empty, and so does a lease conflict whose row has already vanished — so an empty owner is treated as the head shape, the ambiguity is documented, and neither branch ever calls the id a run.
A paused headless run was a dead end: exit 4 printed the run id, which addresses none of the three things a resume needs, and said carrying on was not in this release. io resume is the third subcommand. --list enumerates the parked runs, NDJSON under --json; one is resumed by id with the decision on the command line. No exit code was renumbered — they were pre-numbered in 0.13.0 for exactly this, and that bet is now discharged. A bare run that served no session turn has no recoverable goal, since io-harness publishes no reader for it, so --goal is required rather than resuming against an empty one. Two comments in this module were false and are corrected with the code: an unanswered question does not become a refusal the agent adapts to, and a session turn does register MCP servers, so an attempt is journalable on an ordinary interactive turn.
/resume now asks what the reopened session's last run stopped on and offers it, an interactive turn that ends parked says so instead of saying nothing, and a session is locked against a second io. App::answer_intent and App::decide_plan returned () and dropped whatever no run was waiting for. Both now return it and are must_use, App::key hands it out as Command::Answered/Decided, and the compiler then demanded the driver handle both — which is what makes this structural rather than remembered. App::leave_resumed closes a parked decision without taking it. Esc on a plan is Cancel, a real decision that ends the run; an operator who opened a parked plan to read it had no way out that did not throw it away. Two existing gates refused this code and neither exemption was widened. tests/contract.rs counts contract::session sites by binding name, so the resume's is bound "continuing" and asserted on its own. tests/context_share.rs caught the resume drain reading per-step edits without reading the assembly, which would have left ctx N% describing the turn before the pause. /undo renders failure::said rather than the raw Display, and no longer claims nothing was undone: the files are restored before the head write is attempted, so a lost head race leaves them already changed.
The /resume row carries the new description verbatim, io resume is documented with the ten flags it actually takes, and the one pause that cannot be resumed gets a paragraph of its own rather than a footnote — it is the most common way a turn stops. Two new gates derive what they check instead of restating it: the flag list comes from clap, and the picker's state marks come from io_cli::sessions, so a sixth state or a renamed one fails here rather than shipping undocumented. Three things were already stale before this release: the /resume row, "What this release is not" still saying there is no io resume, and the [Unreleased] link definition, which had pointed at v0.1.1...HEAD for twenty-one releases.
The lock was built on a false premise. Its own doc claimed Session::open answers the same session for two terminals in one repository; the harness calls store.create_session unconditionally, so every terminal gets a new conversation and two of them contend over nothing. Keyed on the directory, that had two consequences. A second io in the same repository was refused outright, which is a regression for anyone who keeps two terminals open. And nothing in production could ever reach a refusal, so the whole surface was tested and unreachable — the defect 0.20.0 shipped seven of. /resume is where it earns its place: entering a session another io already holds is the one moment two processes contend, and the lock is taken before the switch so a refusal leaves the operator where they were. A refusal naming this process is not a refusal — the startup guard still holds the session we opened, and an advisory lock is per open file description, so resuming back into it would otherwise be refused by our own handle. What it does not cover is now written down: two io on different sessions, and io exec / io resume, which take no lock at all. For those the head compare-and-swap is the guard of last resort.
The comment on the PAUSED arm credited 0.13.0 with mapping the three Awaiting outcomes so that adding io resume later would renumber nothing. The CHANGELOG puts that sentence in 0.5.0's notes, written when exit 4 could not happen at all. Caught by the documentation pass against the file rather than by memory, and the correction says what it used to say so nobody re-derives it.
Both are this release's own code. answer_intent and decide_plan grew a return value and their let...else became a ? — clippy::question_mark. And a bare <root> in the lock's prose is an unclosed HTML tag to rustdoc, which is the nightly gate that is not in ci.yml catching a new pub mod for the sixth release in seven.
The offline suite proves the arithmetic against a seeded store, but "the answer reached the agent and the run went on to do the work" needs a provider, and it is the sentence the release is written around. The pause is produced honestly rather than by inserting a row: the responder's receiver is dropped, which is how io-harness is told nobody can answer, so it persists the question and ends the run at AwaitingAnswer exactly as it does for a headless io exec. Asserts the resume carried on from the step the run stopped at, one resume marker and one skipped per committed step read back off the store, the turn closed, and the head still the one this process believed in. Conditional on the model choosing to ask, and it says so on stderr when it did not, so a vacuous run cannot read as a real one.
Fourteen findings, three of them severe, and all three in the driver — which is the one file nothing under tests/ can link, and therefore the one place 1,215 passing tests could say nothing about. A named binding lives to the end of its scope; only a bare _ drops. Written `let (answerer, _parked)`, the responder's receiver stayed open, so a resumed run that asked a second question sent into a channel nobody drains and awaited a reply nobody would send. The session froze with nothing on screen and no way out — and the comment two lines above claimed the receiver was dropped. The resume loop never told the interface a run was in flight, so the mode stayed Idle. At Idle App::compose does not queue: a prompt typed during a resume was taken out of the composer and destroyed. And interrupt_or_quit took its quit branch, so Ctrl+C promised the next press would exit and then did nothing, twice over. Now the run is announced, the stop key reaches the observer's flag, and anything typed is queued and reported rather than lost. A Some(Event::Key(_)) arm consumes a resize and drops it, so the Screen kept a width the terminal no longer had for the rest of the run. Both sibling loops handle it; this one did not. Entering a session returned early on "that pid is ours" without releasing the guard for the session being left, so resuming A to B and back left this process holding B's lock while working in A — and a second io asking for B was sent to a terminal showing something else. The owner record also named the workspace of the session being left, because the root was read before the switch. Smaller: io resume reported the step it resumed at one too low and disagreed with the session about the same run; leave_resumed's || meant a plan was never taken when a question was open; the lease was public, tested and reached from nothing until the refusal started consulting it; and two documented claims did not match the code — a third recovery decision the TUI has no key for, and a scan bound whose cost sentence stopped being true when the walk started classifying each session.
cargo fmt --check went red because the code written for this release was never run through rustfmt; six files reformatted, no behaviour changed. And Windows found a defect in a test rather than in the product, which is the 0.19.0 lesson recurring: a Windows checkout has CRLF, so the "\n}\n" needle that finds where a function ends matched nothing and the new driver gate panicked on a green product. The driver's text is normalised where it is read.
io-cli 0.23.0 — a run that paused is answered rather than abandoned
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Release PR for io-cli 0.23.0 — a run that paused is answered rather than abandoned.
Contents are exactly PR #59, already merged into
developwith 8/8 checks green on macOS, Ubuntu and Windows.git diff --quiet feat/0.23.0 developis clean, so nothing landed ondevelopbetween the feature merge and this PR.The sealed record is pinned in
.ultraship/releases.lockatsha256:4aa8194599c7d3151e32c1e9853375da3791671dd14a36bb129ba687089dd8d6, written before this merge rather than after the Release.-D warnings,cargo fmt --check, and the nightly rustdoc gate that is not inci.yml: all exit 0