Skip to content

io-cli 0.23.0 - #60

Merged
aakashpawar1999 merged 16 commits into
mainfrom
develop
Aug 27, 2026
Merged

aakashpawar1999 merged 16 commits into
mainfrom
develop

Conversation

@aakashpawar1999

Copy link
Copy Markdown
Contributor

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 develop with 8/8 checks green on macOS, Ubuntu and Windows. git diff --quiet feat/0.23.0 develop is clean, so nothing landed on develop between the feature merge and this PR.

The sealed record is pinned in .ultraship/releases.lock at sha256:4aa8194599c7d3151e32c1e9853375da3791671dd14a36bb129ba687089dd8d6, written before this merge rather than after the Release.

  • suite: 85 binaries, 1,217 passed, 0 failed, 25 ignored, zero warnings
  • live: 25 passed, 0 failed, 1,069s against OpenRouter, including a new arm that resumes a real parked question
  • clippy -D warnings, cargo fmt --check, and the nightly rustdoc gate that is not in ci.yml: all exit 0
  • ten of twelve acceptance criteria sabotaged, each killing only its own arm
  • the adversarial review found fourteen defects behind a green suite, three severe, all fixed

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
@aakashpawar1999
aakashpawar1999 merged commit 7b4bd2f into main Aug 27, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant