mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 18:47:40 +00:00
d91c1aaeeae55881d8ab88ee2ea42f14641f3813
4
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
3ae885be11 |
test: remove redundant cases and strengthen session boundary coverage (#33129)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> |
||
|
|
59b746ff3c |
feat(native-chat): one structured-chat journal database per host, owned by one process (#23613)
* feat(native-chat): one structured-chat journal database per host, owned by one process
Every structured chat on a state directory now lives in one SQLite file,
agent-session-journal.db, opened once by the process holding
agent-session-journal.owner: an empty SQLite file whose held BEGIN EXCLUSIVE is a
kernel byte-range lock, refused while another process holds it and released when
the holder dies.
- Stores own no connection: the per-chat handle, its close contract and the
close-retry registry are gone; closing a conversation drains its writes, and the
one connection closes last at teardown.
- The owner lock is taken at runtime start, before orca-runtime.json is written;
a process that does not own the chats is not published and refuses every
structured request with journalUnavailable and words that say what to do. It
retries the lock with backoff and runs the full install once it holds it.
- A journal that will not open fails the host install: every chat says "Unable
to load this chat." (journalCorrupt), and nothing is renamed, deleted or
rebuilt. A newer build's database is refused and left byte-identical.
- An append is one INSERT. The listing status is a column, written after the
rows it describes and keyed by (epoch, sequence).
- A per-chat journal from an earlier build is copied in verbatim (epoch UUID and
every sequence) on that chat's first open, and its directory is retired only
after the copy commits.
- auto_vacuum = INCREMENTAL, with freed pages handed back in bounded steps after
every delete.
* perf(native-chat): key journal rows by block so one chat's rows sit together
Each chat's live epoch owns a block of row ids, block * 2^32 + seq, so a chat's
rows share leaf pages with nobody else's, a replay is one range scan, and
replacing or rewinding a chat deletes one contiguous range. Measured on the
largest real chat (61 MB) beside 19 interleaved peers: 39 ms and 7.5 MB of WAL,
against 214 ms and 102 MB for a (session_id, epoch, seq) key.
- Ids are computed in Number arithmetic, never bitwise. A sequence is refused
outside [1, 2^32) and a block at 2^21, which keeps every id below 2^53.
- A replace, roll or import allocates a fresh block, moves the chat's pointer,
and deletes the old block in the same transaction, so no orphan block exists.
- The listing status write moves into its own writer beside the column.
* feat(native-chat): copy a chat's per-chat journal again when an older Orca wrote it after a downgrade
A per-chat journal.db that reappears after its chat was copied in is the newer
history: an older build, run after a downgrade, attached the chat and wrote it.
- journal_imports records the (epoch, tip) each chat was copied from, in the
copy's own transaction. A file already copied is never copied again, across
any number of restarts after a failed rename; a file that differs always is.
- Newest writer wins, per chat, with a row saying the chat was continued in an
older version of Orca. When both builds wrote past the recorded tip under one
epoch, the copy takes a fresh epoch, so readers reset instead of skipping rows.
- Each copied directory retires to its own .imported-<epoch8>-<ms> name, so a
second downgrade and re-upgrade never collides with the first.
* test(native-chat): fixture deps match the host journal database shape
Attach-flow and reconcile-attach fixtures stop passing a journal database those inputs do not take, and host and restore fixtures pass the one they now require instead of the removed journal root.
* test(native-chat): state why the runtime-state fixtures' existing casts are safe
* fix(native-chat): start up normally when this process cannot open the chat journal
A process refused the chat journal, because another Orca owns it or because its own journal will not open, failed startup restoration: the window booted in degraded no-save mode and a paired phone could not list any tabs. Startup restoration now treats the refusal structured requests are getting as having no structured host; terminals, tabs and saving go on, structured requests are still refused by the gate, and the install is retried on the next one. Any other install error fails startup as before.
* test(native-chat): name the owner-lock sweep test after the two sweeps it runs
* fix(native-chat): session history and terminal resume work while chats are refused
Session history (listing and preparing a resume) and a terminal typing a resume command only check whether a structured chat owns a provider session. In a process refused the chat journal they failed outright. They now take the refusal chats are getting as having no structured host, the same treatment startup restoration gets, through one shared helper; any other install failure still fails them. Chat requests keep the gate's refusal.
* test(native-chat): the first-work rename's fake journal saves the listing status
* fix(native-chat): open a chat whose per-chat journal file never got its schema
A crash between creating a chat's journal.db and creating its tables left an empty or schema-less file. Each chat used to open that file as an empty chat; the importer instead refused the open as "try again" forever. A file with no journal_sessions table is now read as never written, the same as one with no rows. A file that is not a database, or whose read fails, is still refused.
* fix(native-chat): let the event loop run between chats during startup restore
Opening a chat's journal is synchronous SQLite now that no per-chat directory
is created first, so the restore of every visible chat ran as one main-thread
task. Each chat now waits for a macrotask before it opens.
* fix(native-chat): import a per-chat journal in bounded batches
The one-time copy of an earlier build's per-chat journal ran as one
transaction, which blocked the main thread for 650 ms on the largest chat.
Rows now copy 512 at a time, each batch its own transaction, yielding to the
event loop between batches. The rows go into a block journal_import_blocks
reserves, which no reader follows and no other chat is allocated; the last
batch publishes the chat's pointer, repair marker and import marker together
and releases the reservation. A copy that stops midway leaves only that
block, which the next open clears and copies again. Two opens of one chat
import one after the other.
* fix(native-chat): refuse chats when the owner lock file cannot be opened
A lock file that is not a database, or cannot be opened, made the claim throw
before any refusal was recorded, so startup restoration failed on every
launch. The claim now sits in the same try as the database open and records
the same typed refusal.
* fix(native-chat): finish reclaiming pages a delete frees during a running pass
A reclaim pass ended as soon as the freelist stopped shrinking between steps,
so a second delete that freed more than one step's worth mid-pass ended it
early and left those pages on the freelist. A pass now ends only when a step
itself frees nothing, or the freelist is empty.
* test(native-chat): desktop session history is served while chats are refused
* fix(native-chat): a send to a chat holding a newer Orca's rows says to update
A chat opened read-only because a newer Orca wrote rows to it answered a send
with the generic write failure. It now refuses the way a database a newer Orca
wrote does, with the same reason and words.
* fix(native-chat): keep chat tabs while this process cannot list its chats
A process whose chats another Orca owns, or whose chat journal will not
open, has no structured host. Its session-tabs inventory still answered,
with no chat rows, and the renderer read that as "every chat was closed":
it removed the restored chat tabs and the next session save persisted
their placement away.
The inventory now says `agentSessionsUnverifiable` when the last tab
restore ran with chats on disk but no host to list them. The flag is set
and cleared at the per-client projection point beside the client-hosted
page hold, and the restore is memoised only once a host answered, so a
later lock takeover or journal open republishes the chats and clears it.
The renderer keeps agent-session tabs, and keeps cancellation tombstones,
against an inventory that does not affirm its chat set.
* fix(native-chat): say chats are open in another Orca, with this process's way past it
A process refused because another Orca owns the profile's chats sent the
generic `journalUnavailable` reason, so current desktop and phone surfaces
said "couldn't open this chat's history right now. Try again." — a step
that never helps while the other Orca runs.
The refusal now names its own reason, `journalOwnedElsewhere`, with the
refused process's kind (dev desktop, packaged, orcad) as a fact. Each kind
gets its own step: quit the other Orca, or give this one its own profile
(ORCA_DEV_USER_DATA_PATH) or data folder (ORCA_USER_DATA). The sentences
are added to the shared notice copy, the desktop catalogs in all six
locales, and the boot catalog.
A client that predates the reason reads it as none and keeps the code's
words; an unknown kind reads as the packaged app's step. The `message`
released clients print is unchanged. Which requests refuse does not change.
* fix(native-chat): restore chats on taking ownership, without a list to ask
A refused startup kept its hostless result, so after the owner quit this
process never installed a host, never reconciled restart leases, and kept
telling clients it could not list its chats until a desktop chat request.
Taking the lock now reruns startup restoration once and pushes the chats.
* test(native-chat): a navigation reply says chats are unverifiable while refused
* fix(native-chat): retry a refused owner lock at most every 5 seconds
The lock frees as its holder exits, but a refused process only learns that
on its next retry, and the 30 s cap left a second Orca refusing chats for up
to half a minute after the owner quit. One retry is an open and BEGIN
EXCLUSIVE on an empty file.
* fix(native-chat): install before deciding whether a takeover must republish chats
A list that landed on the refused startup after the lock was taken finished
after the takeover had already checked, so nothing republished. The takeover
now installs first, waits for any restore in flight, and restores only then;
the restore that clears "cannot tell" pushes the frames itself, so a list
that heals the inventory first reaches subscribers too.
* fix(native-chat): no takeover lands a host after the runtime stop
Quitting cancelled a refused claim's retry only at its end, so a retry firing
during the stop's awaits took the lock and installed a host the stop never
tore down, and the lock was then released under an open journal. The stop
now cancels the retry first, keeping the refusal, and repeats its teardown
while an install that began during it (a takeover already under way) is
pending, so no journal connection outlives the lock.
* fix(native-chat): show a thrown refusal in its own words, not its code
A refusal the host throws reaches the client as an RPC error whose message is
the bare code; its reason and facts ride only in the error's data, which no
client read. The chat pane's status line therefore printed
agent_session_journal_unreadable, a send took the bare "not sent" path, and
other writes said the outcome was unconfirmed.
One shared reader, agentSessionThrownRefusal, now reads the refusal from the
error data. A failed history read shows the refusal's read-history words, a
send keeps the refusal behind its Retry exactly as a returned refusal does, and
the other writes (desktop and phone) name the refusal instead of doubting the
outcome. The phone's read failure goes through the same reader.
* fix(native-chat): log a failed journal open once per distinct failure
Every chat request retries a journal open that failed, which is intended, but
each retry also logged the failure with its full stack: a junk database file
logged the same "file is not a database" error 189 times in a minute. The open
now logs a failure only when its code and message differ from the last one
logged, and forgets it once an open succeeds. The retry is unchanged.
The open moves to its own module beside the runtime, which had no room left.
* fix(native-chat): restore lists a chat from its per-chat file and copies it on first use
Startup restore opened every restored chat, and that open copied the chat's
per-chat file into the host database, so the first boot after an upgrade paid
the whole one-time copy before the chat list appeared.
A restore open now reads a chat that is still in its per-chat file straight
from that file, read-only, with the importer's own reader, and closes the file
before moving on. That read drives the listing, the status row and the
restart offer, as it did when every chat had its own file. The copy becomes
owed work on the chat's write queue: it runs before the chat's first write,
and a reader that reaches the chat awaits it. A chat the host already holds,
or that was copied before, still opens through the import and its reimport
rules, and so does a file whose read needs a repair written.
* fix(native-chat): no host stays registered after a stop an install spanned
Each teardown pass clears the registered host before it awaits an install in
flight, and that install registers its host when it finishes. The pass then
tore the host down but left it registered, so a request after the stop was
served by a host whose journal was closed. The stop now clears the slot once
its passes are done.
* fix(native-chat): checkpoint the journal with a full flush on macOS
synchronous = FULL fsyncs each commit, but macOS fsync leaves the drive cache
unflushed, so FULL alone does not survive a power loss there. With
checkpoint_fullfsync, each checkpoint uses F_FULLFSYNC; elsewhere it is a no-op.
The comment that said FULL alone was enough is corrected.
* fix(native-chat): delete a per-chat journal once its copy verifies
An imported chat's per-chat file was kept under an `.imported-*` name, which
doubled the disk its history takes. The copy now reads back from the host
database before it is published: its items, submissions, epoch and tip must
match the file's. Only then does one transaction publish the chat with its
import marker, and the file and its WAL files are deleted, the directory too
when nothing else is in it (a pre-SQLite transcript there is kept).
A copy that does not match is never published: the file stays, the chat is
refused as unreadable ("Unable to load this chat."), and the mismatch is logged
once. A file left behind by a failed delete or a crash matches the marker, so
the next open deletes it rather than copying it again; a file an older build
wrote after a downgrade still differs, and is still copied again.
* fix(native-chat): verify an imported chat a batch at a time
The check that a copied chat reads back as its per-chat file folded both whole,
each in one synchronous task: over half a second on the largest chat. Both
reads now go a batch at a time between turns of the event loop, like the copy
itself, and count rows as well, so a copy that lost a row with no item in it
is caught too.
* fix(native-chat): restore reads a chat's per-chat file a batch at a time
Restore folded a chat still in its per-chat file in one task, so the largest
chat's file held the main thread for about half a second at startup. The fold
now takes the file a batch of rows per turn of the event loop, into the same
fold a replay uses, and nothing reads it before it is done. The file is still
closed before restore moves on.
* fix(native-chat): end a per-chat copy on a turn of its own
A chat's first open ran the copy's last steps (the verified publish and the
per-chat file delete) and the replay of what was copied in one task. The copy
now yields before it returns, so the replay, which every open runs, is a task
of its own.
* fix(native-chat): commit a per-chat copy's batches without an fsync each
Each 512-row batch of a chat's first-use copy committed under synchronous =
FULL, so a large chat paid one fsync per batch, about a quarter of its first
open. The batches now commit under NORMAL, set and restored in the batch's own
task so no other chat's commit runs under it. The publish that makes the copy
visible still commits under FULL, and under WAL that sync makes every earlier
batch durable with it. A crash before it leaves only the unpublished block,
which the next open clears and copies again.
* fix(native-chat): roll back a chat journal transaction whose COMMIT fails
The shared connection's transaction rolled back only when its body threw. A
COMMIT that failed left the transaction open, so every later write, for any
chat, failed with "cannot start a transaction within a transaction", and reads
saw rows that never committed. Under the unsynced copy the failure also tried
to restore the sync level inside the open transaction, which SQLite refuses,
so the caller got that error instead of the COMMIT's.
One transaction helper now covers the body and the COMMIT, rolls back whatever
transaction survives, and rethrows the original error. Schema creation uses it
too. If that ROLLBACK fails as well, the connection is marked stranded: each
later use retries the ROLLBACK, and until one goes through every chat gets the
same "history unavailable, try again" refusal a journal that will not open
gives. The rollback that frees it also restores the FULL sync level.
* fix(native-chat): keep the chat journal connection until its close succeeds
Closing the journal dropped its connection handle before closing it. A close
that failed left the database reporting itself closed with the connection still
open, so the stop that retried the teardown found nothing to close and released
the owner lock over a live connection.
The handle is now dropped only once the close succeeds. A failed close keeps
the runtime pending and the lock held, and the next stop closes that same
connection before it releases the lock.
* fix(native-chat): publish the runtime only once it holds the chat journal lock
When this process could not open the owner lock file at all (a permission
error, or a file that is not a database), the runtime counted that as owning
the chats and wrote orca-runtime.json. That overwrote the real owner's entry,
so the CLI was sent to a process that cannot serve its chats.
A claim that throws is now refused like one another process holds: the runtime
starts but does not publish, the claim's existing retry keeps asking for the
lock, and discovery publishes once the retry takes it. Chats still get the
refusal for the failure itself, and startup restoration reruns on the takeover
the same way it does after another owner quits. A sole process whose lock file
never opens is not found by the CLI until it does.
* fix(native-chat): keep a chat's history when an older build started it over
The first copy deletes a chat's per-chat file, so an older build run after a
downgrade finds no file and starts the chat from nothing. On the re-upgrade that
fresh file was copied in as the newer history, replacing everything the shared
database held for the chat, and then deleted.
A file whose epoch is not the one last copied and that opens with
`session_created` is now kept: neither copied nor deleted, and the chat keeps
the history it has. A file that carried the copied epoch on is still copied
again, as before.
* test(native-chat): pin which chats startup restore copies
Restore copies a chat still in its per-chat file only when restore itself has
to write to it: settling what the last run left open, here a running tool call
or a send handed over and never answered. Every other restored chat stays in
its file until its first use.
* test(native-chat): pin the copy wait on a read that opens a chat restore opened
A read queued behind restore's open of the same chat reaches the conversation
through its own open rather than the listing. It must still wait for the
owed copy, or it reads the chat before its history is in the one database.
* fix(native-chat): record a set-aside per-chat file so no later open reads it
Setting aside a file an older build started over is decided once and kept in
the new `journal_set_aside` table (schema 2, additive), with the file's epoch
and tip as they were. Every later open of the chat skips the file without
opening it, across restarts and after the older build writes more to it:
anything written there grows from that build's own start, never from this
build's history.
The best-effort delete moves beside the per-chat file reader.
* fix(native-chat): set aside any per-chat file at an epoch this build never copied
A chat's per-chat file is deleted once its copy verifies, so a file that
reappears at another epoch was never this build's history, whatever its first
row says: an older build started the chat over, possibly rewinding it after
(`handle_forked`), or rolled the epoch of a file whose delete had failed.
Copying any of them would replace everything the chat holds, so each is set
aside. Only a file still at the copied epoch is copied again (it grew) or
deleted (it did not). The first-row check is gone.
* fix(native-chat): copy a reappearing per-chat file again only while this build has not written past the copy
A per-chat file that an older build carried on under the copied epoch was
copied again even when this build had also written to the chat since the
copy, or had rolled its epoch. The second copy replaced the chat's block,
so what was sent in this build after the copy was gone for good.
Now the file is copied again only when the chat still stands exactly as it
was copied: the same epoch and tip the import marker recorded. Otherwise it
is set aside like any other file that is not this build's history, left on
disk untouched and recorded so no later open reads it. A second copy
therefore never replaces rows this build wrote, keeps the file's own epoch,
and the fresh-epoch rewrite goes away. The row it adds now says the history
includes what the older version recorded, not that anything was replaced.
* test(native-chat): pin that a chat founded here keeps its history, and the v1 schema upgrade
A chat this build founded has a pointer and no import marker, so a per-chat
file an older build later starts for it is set aside. Nothing pinned that
half of the rule: letting such a chat be copied again replaced its history
and every test still passed. A second test pins that a database written at
schema version 1 upgrades in place, gaining the set-aside table and keeping
its import markers.
* test(native-chat): drop a lost copied row by patching the source, not wrapping it
* chore(mobile): restore the mobile lockfile to main's
* fix(native-chat): pass a classified journal refusal through a send or Stop unchanged
* fix(native-chat): refuse a read whose owed copy fails as a failed open does
* test(native-chat): measure only the replace's WAL in the block-key case
Opening the chats starts a free-page pass that waits one event-loop turn,
and the seed never yields one, so that pass was still pending when the
replace committed. It woke during the async stat and reclaimed the pages
the replace freed, adding ~500 KB of WAL whenever the stat lost the race
(Linux CI). Drain that pass before measuring and stub the replace's own.
* test(native-chat): the RPC fixture's status journal can save its listing status
The status feed now hands every projection to the journal, which decides whether it is worth saving.
* test(native-chat): state why the RPC fixture's status journal cast is safe
* fix(native-chat): refuse a per-chat copy whose rows differ from the file, not only its counts
* fix(bench): build the replay benchmark's baseline arm from the base tree and release its handles on failure
* fix(native-chat): retry a failed listing status save on the next read of a cached status
* refactor(native-chat): drop the chat journal owner lock; the process instance lock already guards the profile
The journal carried its own exclusive lock, with a retry loop, an in-process
takeover, lock-gated runtime discovery and a "chats are open in another Orca"
refusal. Every shipped process kind (packaged desktop, serve mode, orcad)
already refuses a second instance on one profile before the journal opens, so
the lock only ever mattered for dev desktops, which the next commit covers at
the process level instead.
The host now opens its one journal connection at install with no lock. What a
sole process whose journal will not open needs stays: the install refusal
recorded for the gate, the no-host startup path, and the unverifiable chat
inventory, now in structured-agent-session-host-refusal.ts. The unreleased
journalOwnedElsewhere reason, its processKind fact and their copy are removed.
* fix(startup): dev desktops take the single-instance lock, and a second one says why it quit
Dev skipped Electron's single-instance lock so parallel `pnpm dev` runs from
several worktrees would not quit silently, but two dev processes on the
default orca-dev profile then write the same stores at once. Dev now takes
the lock like packaged builds: a second launch on the same profile focuses
the first window and exits with code 3, printing one stderr line that names
the taken profile and how to run another copy (ORCA_DEV_USER_DATA_PATH).
Serve mode, the macOS diagnostic bypass and the E2E harness are unchanged:
an E2E launch still skips the lock unless it sets
ORCA_E2E_ENFORCE_SINGLE_INSTANCE_LOCK=1.
* refactor(native-chat): key journal rows by chat, epoch and sequence
Rows in the host's journal database are now addressed by the chat's own
identity, with `(session_id, epoch, seq)` as the primary key, the same
shape each per-chat file already used. The block-keyed layout goes with
everything built on it: the block column and its allocator, the 2^21
block ceiling, and the import's reserved block table.
A first-use copy writes its rows under the file's epoch, which the chat's
pointer does not name until the verified copy publishes it, so no reader
sees a half-copied chat. A try that stopped midway leaves only rows no
pointer names, and the next try deletes them before it copies again.
Replace, rollover and repair delete by (chat, epoch).
This build's history always wins: once a chat was copied or founded here,
any per-chat file that reappears is set aside, and the same-epoch copy
again after a downgrade is removed.
The bounded free-page reclaim after every delete is dropped;
`auto_vacuum = INCREMENTAL` stays at file creation, so a later periodic
reclaim can still be added. Session search keeps its own step.
The schema moves to version 3. Versions 1 and 2 were written only by
unreleased builds of this change and are refused as found, not migrated.
* fix(native-chat): open a chat journal a newer Orca wrote read-only instead of refusing it
After a downgrade, the host's journal database carries a newer user_version. It was refused
outright, so every chat's history disappeared. It now opens on a read-only connection, as the
per-chat journals did: each chat shows what this build can read, from the database or a per-chat
file never copied in, and every write is refused with "Chats were saved by a newer Orca. Update
Orca to keep using them." Nothing is written, copied, repaired or founded, and the file stays
byte-identical. A table the newer schema changed reads as the same read-only refusal, not damage.
* refactor(native-chat): leave the saved listing status to the change that reads it
Nothing in this change reads the per-chat listing status column: it was a stored copy of a fact
the status feed derives, written after every turn end and cleared on every epoch change. The
status_json / status_seq columns, their writer, the saved-status type, the status feed's save and
its retry on a cached projection all go, with their tests. The change that lists chats from a
saved status adds the column back beside its reader.
* fix(native-chat): a chat saved by a newer Orca says to update Orca, not to try again
When a newer Orca wrote the chat journal, this build opens it read-only. A send or a Stop was
refused with the reason `journalUnavailable`, so today's desktop and phone clients chose the
words for an open that can clear: "Orca couldn't open this chat's history right now. Try again."
Retrying never cleared it; only updating Orca does.
The refusal now names its own reason, `journalWrittenByNewerOrca`, whose words are "Chats were
saved by a newer Orca. Update Orca to keep using them." A read refused the same way names it
too. An older client does not know the reason, drops it, and falls back to the code's words
("Orca couldn't read this chat's saved history."), and released clients still print the message.
* fix(native-chat): a chat journal from an unreleased build reads as unusable, not as retryable
A chat journal database stamped with schema 1 or 2 was written only by unreleased development
builds of this change. Opening it threw a plain error, which every chat reported as "Orca couldn't
open this chat's history right now. Try again." Retrying never cleared it.
It now throws a named error that is classified as unusable, so every chat says "Unable to load
this chat." The one log line names the file, says an unreleased development build wrote it, and
says to move it aside. Nothing migrates or renames it.
* docs(native-chat): drop the second-Orca-owns-the-chats case from three comments
The chat-only owner lock is gone, so only a chat journal that will not open leaves a runtime
unable to list its chats.
* docs(native-chat): correct three chat-journal comments the redesign left behind
A per-chat file left without its WAL is set aside, not copied again; nothing runs an incremental
vacuum yet, so the auto_vacuum mode is kept for a later pass; and the idle sweep drops a chat's
in-memory fold, since a chat holds no journal connection.
* refactor(native-chat): stop exporting chat-journal names nothing imports
Each is used only inside its own module now; the teardown's export served a deleted test.
* test(native-chat): name the version-0 test for what it covers, and check every journal table
The test named 'migrates an older user_version forward' covers only a version-0 file that already
has its tables; versions 1 and 2 are refused. The table test now also checks journal_imports and
journal_set_aside.
* fix(startup): a second dev launch's exit line no longer claims it focused a window
The running dev instance may be a background launch or a server, which show no window. The line
now says only that this launch passed its request to that instance.
* fix(native-chat): a failed structured-chat install closes the journal connection it opened
The install opened the chat journal database and closed it only if the record store then failed
to open. A later failure, such as the model catalog wiring or the host constructor, left the
connection open, and the next install opened a second one in the same process. Every failure
after the open now closes it.
|
||
|
|
9d11a75cd3 |
fix(native-chat): each chat failure says why in plain words, on the thing that failed (#23608)
* fix(native-chat): a read whose history will not open is refused with its reason
History, subscribe, snapshot and options reads reach a chat through one accessor, whose open had no
catch: a journal that would not open reached every client as a runtime error carrying the storage's
own text (a path, "file is not a database"). The accessor, and the options read's own open, now throw
the classified journal refusal: journalCorrupt when SQLite reports damage, journalUnavailable
otherwise. The storage text goes to the log only.
The wire code stays runtime_error and the message becomes the bare code, as for every thrown
refusal; the reason rides in the error's data.
* refactor(native-chat): the idle sweep's stop of a hung start carries no hand-written reason
The sweep passed an English sentence as the stop's reason. It lived only in memory and nothing read
it: the delivery loop words the error row and the rejection from the hostStopped fact. Dropped, with
the display-name lookup that built it.
* fix(native-chat): a refusal the host throws is worded from its data, never its message
A thrown agent-session refusal reaches the client as runtime_error with the bare code as its message
and the typed refusal in error.data. Stop, answers, options and goals, a launch's held option pick,
the option picker's failure toast, and the Retry line of a chat that could not start now word it
from that refusal through the shared notice table. What each caller decides about the outcome is
unchanged: only the words move.
The Retry line of a failed start no longer prints the host's message or a thrown error's text; it
keeps the refusal as a fact and says the cause and step its reason names, or only that the chat
could not be started. The option toast keeps a local option surface's own sentence.
* fix(native-chat): an unreadable history is worded from its refusal, and damage stops the retry
The structured chat's read failure showed the host's text on the status line, and the pane always
said Orca keeps trying. The read transport now takes the refusal from the error's data (a stream
payload or a thrown RPC error), the reducer keeps it beside the failure text, and the pane and the
status line word it through the notice table, once: on the pane when the failure took it, else
beside the transcript that stays.
A damaged journal says "Unable to load this chat." and the read stops reconnecting for that run;
reopening the chat reads again. An open that can clear names its cause without "Try again", since
the pane retries on its own. A failure that names no reason keeps today's generic line. Finality
comes from the refusal's reason, never its message, which is the bare code for both.
* fix(native-chat): a rejected message is worded from its stored fact
A message the host recorded and then rejected keeps the host's typed fact beside its reason, but
the Retry words re-read the reason alone. Now the fact decides: a hand-over failure says Orca
couldn't reach the agent, a kind whose reason may be a legacy marker gets its fact's own sentence
(a full queue now says so instead of only "not sent"), and any other kind shows the sentence the
host wrote for it, which carries the agent's name and any words the provider wrote for a person. A
row with no fact reads as before.
* fix(native-chat): each message that did not go through says why on its own row
The structured chat showed one Retry strip under the transcript for whichever single entry it
picked, so a second failed message had no reason and no Retry of its own. The terminal-backed
chat's existing per-row delivery marker now carries a notice and an optional Retry, and the
structured pane derives one per message from the outbox on each render: every rejected message,
and the one the queue stopped on (read through the drain's own rule, so a Retry never names a
message waiting behind it). Each is worded from that message's stored failure. The single strip is
deleted. Nothing new is stored, and the shared message projection is untouched.
* fix(native-chat): say each chat failure's words where its own control already acts
Three wording rules for the desktop chat:
- A chat that could not start shows Retry beside its reason, so the reason stops at its cause
where the Retry is the step: a reason whose action is to retry, and a start failure's "send
your message again". Any other step stays (quit the terminal agent, start a new chat). The
start-failure sentences take a retryControl context for this; what the host writes is unchanged.
- A history that couldn't open right now still reconnects, so the pane keeps "Orca keeps trying
to load it" under its cause. Only a damaged history, which no retry reads past, drops it.
- A read failure that names no reason while the transcript is shown is only the pane
reconnecting: the status line says "Reconnecting to this chat…" in muted text, not an error.
New key components.native-chat.state.reconnecting, hand-translated for es/fr/ja/ko/zh.
* fix(native-chat): a rejected message offers Retry only once the queue is moving
Each rejected message's row offered its own Retry even while the queue was stopped on another
message. Any Retry clears the stopped queue, so pressing a rejected message's Retry also sent the
message the queue was holding, which the user had not retried; behind a message whose delivery is
unconfirmed, the retried one instead went back into the queue with no notice and waited there.
While the queue is stopped, only the message it stopped on offers Retry, as the single Retry strip
this replaced did. A rejected message keeps its words on its row and gets its Retry back once the
queue moves.
* fix(native-chat): a chat whose history will not open logs once, not on every reconnect
A reader reconnects every 750 ms while a journal open can clear, and each attempt logged the
failure with its full stack. The read door now logs a session's failure once until that session
opens, closes, or fails differently; every attempt is still refused with its reason.
* fix(native-chat): a message's own Retry is its resend step, so its notice stops at the cause
A rejected message offering Retry read "Claude stopped before it finished starting. Send your
message to try again." beside that button. Its row now takes the rule the launch strip already
follows: beside its own Retry the words leave out sending or trying again, worded from the stored
fact with the chat's agent name. The stored fact keeps less than the host wrote from (a refusal,
the provider's words), so a reason it cannot rebuild exactly is kept as written. A rejected
message without a Retry, while the queue is held, keeps the step. What the host writes and the
phone's notice are unchanged.
* fix(native-chat): a message's Retry sends only that message, never the one the queue is held on
Retry released the queue's refusal hold whichever message it was pressed on. While a queued message waited ahead of a held one, every rejected message offered Retry, and pressing it also sent the held message the person had not retried. Retrying an unconfirmed message ahead of a held one did the same. Retry now releases the hold only for its own message.
* fix(native-chat): a not-signed-in failure beside Retry still says to sign in first
Beside a Retry the notice dropped the whole next step, so a chat that could not start because the agent was not signed in read only the cause. Pressing Retry without signing in fails the same way again. The words now keep the sign-in step and leave out only the resend, which the Retry button is.
* test(native-chat): a rejected message's hidden Retry only avoids waiting unseen
* fix(native-chat): every Retry beside a notice leaves out the retry step the same way
A message the queue stopped on worded its refusal with no agent name and with its retry step, beside its own Retry, while a rejected message next to it named the agent and left the step to the button. The launch strip and the history pane each had their own copy of the same rule. One wording context now goes through the one notice table for every surface: a Retry beside the words, or a pane that reconnects on its own, is the step for a reason whose action is to retry, and every other step stays. What the phone and the host write is unchanged.
* test(native-chat): read the sent message id without a type assertion
* fix(native-chat): a rejected message is worded from the journal's own fact, never by comparing sentences
A message the host recorded and then rejected kept only the rejection's kind on the message, so its notice was reworded from that smaller copy only when it rebuilt the host's sentence word for word. A different agent name, an older host's wording, or anything the copy dropped (why a start failed, the provider's own words) left the host's sentence in place, beside a Retry that repeated its resend step. The notice now reads the journal's own rejection for that message, found by id, with the pane's agent name and Retry, and shows the provider's words only when they were written for a person. The message's smaller copy words it only when that journal row is not loaded. Nothing new is stored.
* fix(native-chat): a message rejected before a restart retries under a new id the first time
Whether a Retry needed a new message id was remembered in memory for one message, or read from the journal row when it was loaded. After a restart, or for an older message whose row was not loaded, the first Retry resent under the old id, the host answered with the same settled rejection, and nothing visibly happened. The message now says so itself: one the host recorded and rejected always retries under a new id, including after a restart. A refusal that already gave the message a fresh id, and a message whose delivery is unconfirmed or in flight, keep their id as before.
* fix(native-chat): a rejected message older than the loaded history keeps the provider's words
When the journal row that rejected a message is not loaded, the message's own copy of the
fact has no provider detail or start refusal. For the kinds worded from those, the row now
shows the sentence the host wrote for the person instead of a thinner rebuilt one.
* test(native-chat): the chat pane words a rejected message from its loaded journal row
Nothing covered the pane handing the journal's rows to the per-message notices, so a pane that stopped passing them would quietly fall back to the message's smaller copy of the rejection and show the host's sentence, resend step and all. The new case renders the pane with a rejected message whose journal row is loaded and checks that it reads that row's refusal in the chat's own agent name.
* fix(native-chat): a chat whose history won't load says why in one line
A read the host refused for a named reason put its sentence under the generic
"Could not load conversation" title, so a damaged history read as two lines
saying the same thing. The pane's own sentence now takes the title's place; a
history that can come back keeps its line saying Orca keeps trying. A failure
that names nothing keeps the generic title.
* fix(native-chat): a message a failed start rejected says only that it was not sent
When an agent stopped before it finished starting, the chat showed the start's
red row ("Claude stopped before it finished starting. Send your message to try
again.") and then repeated that cause under every message the start rejected.
Each of those messages now reads "Your message was not sent." beside its Retry.
The match is made on typed facts, not on the words: the host writes the start's
row and the rejection of its queued messages from the same failure fact, and the
row is keyed by the start. The pane finds the loaded start-failure rows by that
key and shortens a message's notice only when its loaded journal submission was
rejected with the same fact. Any other rejection, or one whose submission or row
is not loaded, keeps its full notice. The row key moves to a shared module so the
host that writes it and the pane that reads it use one definition.
* fix(native-chat): a chat whose history keeps failing to open retries less often
A read the host kept refusing (its history store could not be opened right now)
reopened every 750 ms for as long as the chat stayed open, about 40 opens every
30 seconds. Each reconnect now waits twice as long as the last, from 750 ms up
to 30 seconds, and never gives up; the first read that delivers anything starts
the wait over at 750 ms. A damaged history still stops reconnecting at once.
Reset happens on a delivered read, not on connect: a local subscribe resolves
before the host's open refuses, so resetting there would keep the 750 ms loop.
* test(native-chat): the pane harness types its journal rows without a cast
* test(native-chat): the admission test passes no start-failure rows to the notices
* test(native-chat): import the journal types once
* fix(native-chat): a remote chat reads again as soon as its host is back
The read retry doubles its wait up to 30 s during an outage, and nothing
reset it when the remote runtime reconnected, so the transcript could
lag the reconnect by up to 30 s. The read now watches the runtime
status store's contact-regained edges (hostContactEpoch for a
same-runtime return, connectionGeneration for a new runtime session)
and, when one lands, runs a waiting retry immediately with the wait
reset to its base.
|
||
|
|
a68d62911e |
fix(native-chat): the host writes chat failures for a person, with a typed fact beside them (#23116)
* refactor(native-chat): remove the unused terminal handoff No client ever called agentSession.requestHandoff or mounted the handoff chrome. Delete the handoff coordinator, the terminal-owner runtime, the proof write path and the unmounted UI. Keep agentSession.handoffStatus, which released desktop clients read for worktree activation, and let records an older build left mid handoff reconcile through the ordinary restart and recovery paths. * fix(native-chat): never let the pre-stop snapshot hold a chat's stop Eviction now drains delivered events before quit's resume-offer snapshot. An unbounded wait there sits ahead of the provider stop, so a sink whose journal write stalls kept the child running until the step deadline aborted the eviction. The offer is advisory: bound the drain and stop the child regardless. Co-Authored-By: Claude <noreply@anthropic.com> * refactor(native-chat): drop helpers only the terminal handoff called `claudeAuthEnvCarriedForward`, `isPathWithinDirectory` and `queryWindowsProcessRowsFresh` lost their last caller with the handoff. The fresh-scan tests now go through `queryWindowsProcessDescendants({ fresh: true })`, the teardown path that still depends on that contract. Co-Authored-By: Claude <noreply@anthropic.com> * docs(native-chat): stop citing the removed handoff in lifecycle comments Six comments still named the handoff coordinator, a handoff suspend, or a terminal-owned session as live participants in the flows they describe. Co-Authored-By: Claude <noreply@anthropic.com> * test(native-chat): type the stalled snapshot drain without a cast Co-Authored-By: Claude <noreply@anthropic.com> * test(native-chat): pin that a start dead before proving owes no settlement The removed restart handoff test pinned this branch; nothing else did. Co-Authored-By: Claude <noreply@anthropic.com> * fix(native-chat): keep the owner-status read behind an in-flight attach The handoff removal dropped the per-session queue from `handoffStatus`, so a read landing mid-start reported the reservation (no owner) instead of the settled chat owner, and shipped desktop clients blocked worktree activation on it. The read is queued again, as it was before the removal. Co-Authored-By: Claude <noreply@anthropic.com> * refactor(terminal): remove the agent-session PTY write gate The gate only refused a write when a PTY had been bound to a chat session, and the only code that ever bound one was the terminal handoff this branch removes. With it gone, every admit/readmit returned "admitted" unconditionally, so the checks on the renderer write path, the runtime controller backstop, terminal.send, agent prompts, preview input and orchestration pointers, the refusal fields on terminal.send and worker-start receipts, the plugin and CLI refusal copy, and the adopted-pane orchestration routing could no longer run. Ordinary writes take the same path in the same order as before. Co-Authored-By: Claude <noreply@anthropic.com> * refactor(native-chat): drop the transcript helpers only the handoff called appendLegacyTranscriptMessages fed the terminal transcript catch-up and proveClaudeTranscriptBranch backed the terminal owner's exit proof. Both lost their last caller with the handoff. Their tests now go through the live entry points instead: the roster bounds through the legacy import, the pinned-read and growth tests through the ancestry replay the history window uses, and the marker rules through the string proof in their own file rather than the session-file resolver's. Co-Authored-By: Claude <noreply@anthropic.com> * fix(native-chat): stop calling a starting chat "mid-handoff" A send refused because the chat's owner is not settled showed "The session is mid-handoff (<stage>)." in the composer. With the handoff gone, the stages that reach it are a chat that is still starting, or one whose previous agent process has not yet been confirmed stopped. The message now says which of the two it is. The refusal code is unchanged. Co-Authored-By: Claude <noreply@anthropic.com> * test(native-chat): type the stand-in roster decoder without a cast Co-Authored-By: Claude <noreply@anthropic.com> * refactor(codex): name the pinned rollout lookup for what it does With the terminal handoff gone, the module named codex-tui-rollout-proof holds only the pinned rollout lookup that structured Codex launches use to resume a thread, so the name described code that no longer exists. Rename the module and its options type. Also drop a mobile allowlist assertion that pinned the removed agentSession.requestHandoff method, which no longer exists to allow. * refactor(native-chat): type the owner-status reply as the host sends it The handoffStatus reply type still listed the terminal handoff's fields and states (terminal placement, host label, proof retry, queued and waiting phases, the to-terminal direction). No host writes them any more and the only client reader parses the reply as unknown, so they described nothing. The reply on the wire is unchanged. * refactor(native-chat): normalize terminal-handoff lease values once at decode Nothing in this build writes a terminal owner (`runtimeKind: 'tui'`) or the handoff's `preparing` / `old-owner-stopped` stages, but the in-memory types still admitted them, so readers across the host kept branches for values no path produces and the compiler could not point at them. The store now validates the on-disk shape, which still accepts those values so an older record is not quarantined, and maps them once while parsing: - `preparing` and `old-owner-stopped` become `recovering` - a `tui` lease becomes `native`; when it records a process it also becomes `conflicted`, the claim every build probes but never stops. A plain native owner would be stopped by restart recovery, here and in older builds. Revisions are taken over the normalized state on both sides of every compare, and the mapped record reaches disk with the store's first transaction, the same way the tab-id backfill does. The in-memory types narrow to what this build writes, and the branches that existed only for the removed values go. Structured-worker identity keeps its verdict for a former terminal owner by refusing a conflicted claim rather than a non-native kind. * refactor(native-chat): stop threading the owner kind through a reservation A reservation only ever names a native owner now, so the request no longer carries a kind and the reserved lease records `native` directly. The attach params keep `runtimeKind`: agentSession.ensure and create accept it, and the operation fingerprint stored in the ledger covers it. * test(native-chat): pin the legacy-lease rewrite with a transaction that changes nothing else Hiding a tab also committed the visibility index, so the no-op transaction wrote the file even when its open-time revision was wrong. Committing the index first leaves the pending rewrite as the only reason to write. * fix(native-chat): name a chat write by its target, not the owner generation A write carried the fence of the last frame the pane read, and the host refused it unless that fence was still current. An idle release and the restart after it each move the fence, and the release publishes nothing, so a send after a release was refused "Expected runtime fence 1; the session is at 3", and a Stop queued behind a cold start was refused as stale. Every write already names what it acts on: a send its conversation, a cancel its turn, a prompt answer its item revision, a rewind its epoch; an option is last-writer-wins. So admission stops comparing the client's fence, and the rebase that papered over one restart (admitAtResumedFence, resumedFromFence) goes with it. The writer-lease check stays, and so does the attach's compare-and-swap. Frames now stamp the fence read when each frame is sent instead of a copy each subscriber kept, which went stale on the same release. * fix(native-chat): every journal append reaches the chats that are open A journal write and its delivery to open readers were two calls, and some writers made only the first. A failed start whose lease could not be handed back, a provider revision with no frame behind it, and eviction's settlement were all journaled without reaching an open chat. A journal handle now reports every durable change, and the host's session map binds that report to the session's readers when the handle is set. Writers no longer publish what they append; the per-writer publish calls are deleted. * test(native-chat): an epoch replacement reaches the open chat * test(native-chat): each row reaches an open chat once, and a live handle enters only through the map * test(native-chat): give the legacy-lease store test a tab id so the backfill cannot supply its rewrite The seeded record had no surface tab id, so the next open backfilled one and that rewrite alone made the no-op transaction write. The test passed with the legacy-lease rewrite signal removed. * test(worktree-activation): restore the OMP surfaced-agent resume test The handoff removal deleted it alongside the terminal-owner tests, but it covers the surfaced-PTY block that still guards resume, including an agent whose ownership is unknown. * perf(native-chat): a publish behind a delivered commit reads nothing Each commit now delivers itself, so the publish a provider frame still sends afterwards found every reader caught up but still read rows and rebuilt the timeline for each one. A caught-up reader now skips the read. * test(native-chat): state why the teardown test's fake journal is safe to cast * docs(native-chat): say mutation admission checks only the writer lease * docs(native-chat): drop the send rebase from comments that still described it * fix(native-chat): a message is accepted, then delivered A send to a chat with no running agent restarted the agent inside the send call, before the message was recorded, so the client waited for the whole start and a failed restart refused the message. Claude held prompts sent during startup, and those could settle as "unconfirmed". A send is now accepted inside the session's serialized queue: one ledger row and one submission row marked handoverRecorded, published, answered pending. A per-session delivery loop exists while a message is queued. It starts the agent through the same serialized attach a hold uses, waits outside the queue for a Claude child to prove its start, and hands the oldest queued message over as its own serialized step, writing dispatch{pending} before the adapter call. A start it needed and did not get writes one error-tone row and rejects every queued message with the same words; a start Stop cancelled writes none. Settlement follows from the rows. A queued message is provably unwritten, so a close, an eviction or an exit rejects it. A handed-over message stays in doubt. A queued row at or below the sequence a handle found when it opened was left by an earlier process and is rejected at open, with no latch. Stop withdraws queued messages with no writer lease and no fence. An attach failure keeps the conversation open, and the attach adopts its journal. Owed work counts the loop and queued rows. A compaction or rewind found prepared when a conversation opens was started under a child this process no longer has, so the open settles it rather than leaving it to refuse every send until a view attaches. The open cursor is scoped to its epoch, because sequences restart when an epoch is replaced. Deleted: restart-before-admission, recordFailedRestart, the fence rebase, Claude's startup gate, the attach's forget on failure and its own crash boundary. Clients without agent-session.accepted-send.v1 get their reply held until the handover; the desktop and paired desktop lists advertise it. * fix(native-chat): settle queued messages only for the child that ended A child that proved its start and then exited before its message was handed over left the message queued: the exit settlement returned early when nothing else was in flight. Delivery then started another child for it, and a child that died the same way started another, without end and without a row. A retried settlement for an earlier generation, run by the attach that delivery started, did the opposite: with that generation's turn unfinished it rejected the message queued for the child being attached. The settlement now takes the rejection for queued messages from its caller. The unexpected exit and the eviction pass one, and it applies even with no other work in flight; the retry for an earlier generation passes none. * fix(native-chat): an adoption that fails to import keeps the conversation open The attach now writes into the conversation's own open journal, but a failed transcript import still closed it as if it were the attach's provisional one. The conversation stayed indexed with a closed journal, so every later send answered "could not be recorded" and every attach failed again until the app restarted. The import now closes only a journal the attach opened for itself. * perf(native-chat): the recovering open reads the journal once Every conversation open now goes through the recovering open, including the read restore of every chat at startup, which used to replay its journal once. The recovering open replayed it twice: once to probe it and again inside the open. The probe is now handed to the open as its load. * fix(native-chat): an attach that fails after indexing its child leaves no child behind A failed attach now keeps the conversation open, but a failure after `onAttached` indexed the child (the rewind or compaction recovery, or the attach's own success record) left that entry claiming a child the failure path had already released. The next send found the phantom, skipped the start, and wrote at a fence the journal had moved past, so the message stayed queued for good. The entry now drops the released child and its event sink, and follows the record's fence, as a failure before indexing already did. * fix(native-chat): a withdrawn message shows no error, and a rejection outlasts the send's answer The error strip for a message the host accepted and then did not deliver matched the entry before the outbox reconciled, so a Stop's withdrawal, which the reconcile drops, showed "Orca could not send your message" with nothing to retry. It now reads the reconciled entry. A rejection the journal records before the send's own pending answer lands is final as well: that answer no longer puts the entry back to dispatching with no Retry. * fix(orchestration): a structured worker whose agent outlasts the preamble wait is left unknown, not torn down The preamble waits for its submission to be delivered while the worker's agent starts. When that wait ran out it threw operation_unknown, and the failed-start teardown then closed the session, which rejected the very preamble the host was about to deliver. It now reports a turn start nobody observed yet: the worker is start-unknown with its session kept, the host delivers the preamble when the agent starts, and the worker's report settles the dispatch as for any unobserved start. The receipt no longer suggests reading a screen a structured worker lacks. * fix(native-chat): a message rejected while its chat was closed reads as not sent A remount reads an entry it left dispatching as unconfirmed. When the journal had rejected it meanwhile, as a failed start or a quit now does, the reconcile left it unconfirmed: it blocked every later message behind a Retry and no reason, and the delivery probe, seeing the journal already answered, never ran. The reconcile now settles it as rejected like a dispatching one. * test(orchestration): name why the readiness settlement fakes are cast * fix(native-chat): keep each pane's own fence on frames so a failed restart is not resent * docs(native-chat): drop the fence from the admission the send effects run behind * docs(native-chat): give the fence move on release the reason that still holds * docs(native-chat): stop citing a write fence check in launch and mailbox comments Three places still gave the removed fence check as a reason: the launch replay said admission puts the ledger ahead of the fence, the launch surface said a send must name the lease it was admitted against, and the direct-mailbox path said the lease fence decides whether delivery is safe. Admission now checks only the writer lease. * refactor(native-chat): the provider child is its own record A conversation now outlives any number of provider children, so the child is one record on the conversation's entry instead of five loose fields beside its journal. It is written in one place: indexed only once an attach has fully succeeded, and ended through one function that an exit, a failed re-attach, a Stop and an eviction all share, matched on the child's generation and fence. - A failed attach writes no child, so there is nothing to unwind: the field unwind and the fence patch after it are gone. - Conversation writes read the record's fence, the way mutation admission already does; a child's own writes use its fence. The four stored-fence patches, and the settlement retry's overwrite of the conversation's fence, are gone. - The owed wind-down is its own tombstone, carrying the child it is owed for, and is no longer dropped when an attach replaced the whole entry. - Stop on a child still proving its start stops only the child: its lease goes back and the chat is told it is idle, but the journal, the holders and the readers stay. Close is that stop plus the conversation's close. - The settlement retry uses the conversation's own journal, opened through the host's one open. * fix(native-chat): the delivery loop alone settles a message its start or child failed A queued message was settled by whichever path happened to end the child first: the loop, the unexpected exit, eviction's work settlement, the open's leftover rule, and the startup branch that rejected every pending row. That gave two failure rows with different tones for one start, a loop that could hand over to a different child than the one it waited on, and a Claude start that died while starting reading unlike every other failed start. - The loop remembers the child it waited on. At handover, if that child is gone or replaced, it reads how it ended: a Stop continues; anything else writes one failure row and rejects every queued message with the same words, then stops. A child still starting whose start the adapter says did not land fails the same way. The exit, eviction and the settlement retry only settle the handed-over and legacy rows of the child that ended. - One failure row, always an error, keyed by the start. A start a view began that dies with nothing queued writes the same row through the same builder, so a second report revises it. - The open no longer rejects leftovers; the loop's first step does, and the open wakes it. - `awaitStarted` answers why a start did not land, so the row says it even when the loop sees the failure before the exit is processed. - Quit closes every conversation the way closing a chat does: what is still queued is rejected as closed, with or without a child, and a start the loop already has in flight is waited for so the child it produces is stopped rather than left behind. * refactor(native-chat): a stopped child ends on the one reading of its stop The eviction step reads a stop's result through `stopAgentSessionProviderRoot` and hands that verdict to the child's ending, so the host never forms a second view of whether the root is gone. Every ending carries it: a stop's comes from that reading, an exit's root is gone by definition, and a failed re-attach passes what its release saw. The end-of-child record can therefore also carry a stop whose root was not seen to go, which nothing ends on yet. * feat(native-chat): the host says it accepts a send before any agent has it The host now lists agent-session.accepted-send.v1 among its own runtime capabilities, the same string capable clients already send. A client can then tell a host that answers a send at acceptance, and admits a Stop with no writer before a turn starts, from an older one that still restarts the agent inside the send. Additive: an older client ignores a capability it does not know. * refactor(native-chat): an attach never opens a journal of its own The attach adopts the conversation's open journal, which outlives it, so it no longer opens one for a direct caller either. That leaves nothing for a failed adopted import to close, and the flag that told the two cases apart is gone. Tests that attach without a host open the conversation the way a host does. * fix(native-chat): a moved fence resends nothing on a host that accepts first The outbox treated any fence change as a new owner: it dropped the answer of a send in flight, queued that send to go out again under the same id, and unblocked a refused head. On an older host that is how a send the restart refused, unrecorded, gets another try. On a host that records every send before it starts an agent, a fence moves because that start ran, so the same rule resent into every failed start. With a fence stamped on every frame, that became a loop. The outbox now reacts to a fence change only when the host has not advertised that it accepts a send before any agent has it. On such a host, only a Retry or a new send goes out, and a failed start reaches the client as a rejected message it keeps with its Retry. Against an older host, or before one has answered, the outbox behaves as it did. Desktop and paired web share this hook. * refactor(native-chat): a child's end says whether the user or the host stopped it The end-of-child record's cause now tells a user's Stop from the host stopping the child for a cause of its own: `user-stop` and `host-stop` replace `stop`. The delivery loop goes on after a user's Stop, as before, and fails the start it was waiting on after a host stop, with the one error row and every queued message rejected, in the stop's reason when it gave one. The reason stays description only. Stop passes `user-stop`; nothing passes `host-stop` yet. * fix(native-chat): a chat whose only work is a queued message is not offered for resume A message accepted while the agent was starting counts as working in the chat, and quit rejects it as never sent. The teardown snapshot read the same working rule, so a relaunch offered to resume a chat whose agent never had the message. The snapshot now reads only what was handed over. * test(native-chat): type the queued-message fixtures in the resume-offer tests * fix(native-chat): a start that dies while a message waits on it is that message's failed start Opening a chat's tab starts an agent for the view, and a send accepted meanwhile waits on it. When that start died, its exit wrote the start's error row and left the message queued, so the delivery loop started a second agent into the same failure and wrote a second row. A child's end now records where the conversation's journal stood, and the loop settles a message accepted before a failed start ended with that start: one row, under its key, and no second start. A message sent after the failure still gets a fresh start. * docs(native-chat): say what an attach's open conversation and unconfirmed ids are now * test(native-chat): pin what a failed start settles, and what a resume offer names A view's child that dies while a sent message waits settles that message only when it died starting and no child has taken its place: a proven child's crash, or a second start since, gets the message delivered. The resume offer names the handed-over message, never a newer one still queued. * test(native-chat): the failed-start pins fail on what the message became, not on a timeout * test(orchestration): the preamble's host stub is typed, not cast The preamble send now takes only what it reads of the host, the send, the settlement wait and the record's fence, so its test builds that host with real types instead of `as never`. * feat(native-chat): a typed failure fact beside every failure sentence Adds the shared vocabulary the host writes a failure with: a closed failure kind, a provider diagnostic that says who it is for (a person, or a log), and a refusal cause beside the refusal code. Status rows gain an optional failure fact and rejected submissions an optional rejection fact; the dispatch row carries it, the reducer reads it field by field, and the projection forwards it. Older rows and older readers are untouched: every field is optional and the schemas stay open. * fix(native-chat): durable failure rows and rejection reasons are written for a person Every host writer that records a failure now writes a sentence for a person beside a typed fact, instead of embedding a refusal's message, an exception or a composed exit string. A provider's own words travel as a separate diagnostic from the places Orca composes them - the Claude and Codex exit stderr (a log), Codex's JSON-RPC message, Claude's compact_error and Codex's turn error (for a person) - and are never inferred from a string afterwards. Not signed in and oversized history are typed at the adapter that detects them. Covers start and restart failures, the delivery loop, dispatch rejections (content, queue-full, write failures, provider refusals), cancel and answer confirmation rows, compaction, the rewind fallback, and not_delivered, which released clients printed as it was. Two leaks close on the way: a settlement retry no longer writes Orca's probe evidence into the exit row, and an attach or journal-sink failure is recorded as Orca's fault rather than as the provider stopping. The legacy rejection markers and the reasons on sends in doubt stay byte-identical. * feat(native-chat): refusals name their cause, and a failed start is worded in one place A refusal now carries an optional cause beside its code: one closed enum of the situations a chat write can meet, set at every emitter a structured-chat write reaches. Returned refusals build it with refuse(code, cause, message). Store and host paths that raised a bare Error(code) now throw AgentSessionRefusalError, whose message is still the code and which has no code property; the RPC error mapper handles it before any other passthrough, keeps today's wire code and message byte-identical, and adds { refusal: { code, cause } } to the error's data. The hold throws it, and restart-resume files the cause beside the unchanged reason. The operation ledger stores the cause beside the code, so a replay names the same situation as the first answer. The store fallback copy picks its words and cause by situation, so a stale replay or a moved lease no longer reads as a latched owner. Every failed start is worded by structuredAgentSessionStartFailure(cause, context), which returns the row sentence and the typed fact together; the delivery loop, the exit settlement and the dispatch that met a starting child all call it. Provider diagnostics are capped at the lease record's 512 characters wherever a fact is built. * refactor(native-chat): one reader of why a submission was rejected classifyDispatchRejection(submission) returns { category, verdict, kind? }. It reads the typed rejection fact when the row carries one this build can place, and the legacy markers otherwise - all six, including not_delivered, which released clients printed as it was. The verdict is null only for a withdrawal, a host restart and a closed chat; write failures, a full queue and not-delivered stay failures. It replaces dispatchRejectionReasonIsInternal and every string comparison against the markers: the outbox reconcile, the rejection notice, and the send disposition, where a replayed Stop-withdrawn send no longer surfaces as a failed send. The journal reducer's echo-aliasing guard reads the narrow isWriteFailureSubmission, which matches the typed kind or the legacy prefix in any dispatch state, so its behaviour on legacy unknown rows is unchanged. * test(native-chat): pin provider diagnostics where they are composed The Claude exit status and stderr, the Codex stderr tail and Codex's JSON-RPC message are each checked at the place Orca composes its own error around them, so the typed detail is proven to come from the provider's value and never from Orca's wording. * fix(native-chat): an attachment Orca refuses says which limit it broke The content check's refusals (20 images, 5 MB per image, 20 MB in total, supported types) are written for a person, but the rejection writer replaced them all with one generic sentence. Each refusal now carries its own sentence, in MB rather than bytes, and the writer records it beside kind attachmentInvalid. Only an attachment that could not be read keeps the generic sentence. * fix(native-chat): the chat tab table refuses with a typed cause Showing a chat tab refused with bare Error('agent_session_conflict') and Error('agent_session_identity_required'), the only chat-reachable refusals still thrown without a cause (opening a chat from history can reach the second when the chat is removed mid-open). Both now throw the typed refusal; wire code and message are unchanged. * fix(native-chat): a compaction Codex refuses up front keeps Codex's words When Codex refused thread/compact/start, the adapter passed on only Orca's wrapped error text and dropped Codex's own message, so the chat's row read just "Compaction failed." The refusal now carries Codex's message as the failure detail, as a compaction that fails later already did. * fix(native-chat): an unreadable chat record no longer promises an update fixes it The recordUnreadable copy said a newer Orca saved the chat, but the store marks a record unreadable for damage and key mismatches too, where updating does nothing. The sentence now says Orca can't read it and gives both next steps. * fix(native-chat): a restart or a close leaves released clients a sentence, not a marker host_restarted_before_delivery and provider_closed_before_delivery are not in the markers released desktop and mobile builds hide, so they printed raw on every rejected message a restart or a chat close left. Neither marker has shipped. New rows carry a sentence plus kind hostRestarted or chatClosed, as not_delivered already did; the classifier still reads both markers, and the verdict for both stays no-failure. * fix(native-chat): log why an attachment could not be read The rejected message now says only that the attachment couldn't be read, so the error that said why (a missing file, a permission, or an unexpected throw) went nowhere. It is logged instead. The content rejection moves beside the content check that owns its errors. * refactor(native-chat): drop an unused thrown-refusal cause reader It had no callers, and its comment claimed it looked through wrappers, which it did not. * fix(native-chat): a compaction the provider never confirmed is recorded as unconfirmed, not failed * fix(native-chat): an empty or non-user message is not recorded as a bad attachment * fix(native-chat): an undelivered preamble's error ends in one period * refactor(native-chat): a compaction ends as compacted, failed, or unconfirmed, never an unlabelled error * refactor(native-chat): a failure's sentence is written only from its fact A writer could choose its sentence and its kind separately, so five writers put hand-written words beside a fact that said something else. One shared constructor, agentSessionFailureWords(fact, { surface, agentName }), now makes both, and the journal types refuse anything else: a status row, a rejected message or a conversation command that carries a fact must carry the sentence that constructor branded. Persisted rows keep their shape. - The sentence table and the restart table move to src/shared, as the English default a client copy table can reuse. - A rejection's legacy markers come from the same constructor. A write failure is now the bare `provider_write_failed` marker, which released clients already hide; its error goes to the log. - An image Orca refuses carries which check it failed (and the limit) in the fact instead of a sentence; an empty message gets its own kind, and a non-user message is Orca's fault. - The exit row, the interrupted compaction, the /clear failure and the rewind placeholder no longer carry their own words beside a fact: the exit row says what its fact says, and the placeholder carries no fact. * fix(native-chat): a start that failed without an observed exit no longer blames the provider Every untyped start error was recorded as "The provider stopped before it finished starting.", so an Orca fault, a failed spawn or a close that ended a start was blamed on the provider. Only an exit the adapter observed says so now: the Claude adapter marks the error it saw the child exit with, and anything else is a new `startFailed` kind, "<Agent> couldn't start.", keeping the provider's diagnostic when the error carried one. A child gone with no end observed, and a /clear whose new conversation was refused, read the same way. * fix(native-chat): a view never restarts a chat whose last start failed A Claude chat whose CLI exits during startup left one red row per start, and every time a view bound to it (the chat opening right after its create died, or the user switching back to it) the hold started the CLI again, so the same launch-failure row repeated. Only a send retries a failed start now, the same rule provider-exit recovery already applied; the rule lives in one predicate the hold, exit recovery and the delivery loop share. * fix(native-chat): a chat a terminal agent still holds says to quit that agent The merged base words a restart a terminal agent's claim refused with the refusal's own message, which names the process. That message is Orca's text and never reaches a durable row here, so the row read only "<Agent> couldn't restart." and lost the one step that frees the chat. The restart sentence now derives it from the refusal's cause: a claimConflicted refusal adds "This chat is still open in a terminal agent. Quit that agent to continue the chat here." The live refusal still names the process. The restart-resume ledger test now sets up a claim the base still refuses: a terminal owner that is proven running. * refactor(native-chat): keep the changed files inside their lint limits The legacy-marker lookup is a table, not a non-exhaustive switch; the preamble tests read the error without a cast; and the Codex history refusal goes through a named constructor so its file stays under the line limit. * test(native-chat): start the child the loop waits on with an attach, not a second view A view no longer starts a child whose last start failed, so the R2 case that waits on a child started since the failure now gets that child from a client attach, the one non-send starter left. * fix(native-chat): settle a gone generation's turn wherever a conversation opens A send that opens a chat this process had not read yet (after a crash, from a phone or the CLI) went through the delivery open, which never settled what the dead generation left running; only the read restore and a successful acquire did. When the send's start then failed, the turn stayed running for every reader. The settlement now runs in the one journal open, at the crash boundary, for every opener except an acquisition, which settles from the evidence it read before its reserve; the read restore's separate step is gone. * test(native-chat): prove the next child's start settles the turn an earlier child left The R1 case lost its only settlement assertion when the latch it checked was deleted. It now seeds the running turn the earlier child left and asserts it ends at the exit's receipt, with the exit's row, before the message is handed to the new child. * test(native-chat): count a failed start's rows by row, not by text Comparing the set of texts passed when two different rows carried the same words, which is the duplicate the test exists to catch. * test(native-chat): give the failed-start and stale-turn waits a loaded runner's budget * refactor(native-chat): refusals carry details keyed by their code A refusal named its situation with one flat cause list shared by every code, so nothing stopped a site pairing a code with a situation that code never means, and the loose fields released clients read (fence, revision, resolution, verdict, rewind reason) were written by hand at each emitter. A refusal is now one variant per code with optional details: a reason that code lists plus that code's own facts. refuse(code, details, message) rejects a reason the code does not list at compile time, and it is the one place the loose top-level fields are copied from details, so released clients read exactly what they read before. A site that cannot name its situation uses refuseUnclassified, which carries facts but no reason, the same as an older host; there is no catch-all reason. Thrown refusals put { refusal: { code, details } } in the RPC error's data (wire code and message unchanged). The operation ledger, the restart-resume record and a restart failure's embedded refusal keep details beside the code and read them back against it; a row an unreleased build wrote with a cause parses and reads as naming none. * test(native-chat): pin the open's and the send's start and row counts, however the view binds Opening a fresh chat whose starts fail makes one start and one row, with two views bound before or after the create's child died; one send makes one more of each. * test(native-chat): a restart-resume failure keeps its refusal details The recovery capsule reads a failure's details back against the refusal code in its reason: facts the code does not list and a reason another code owns are dropped, and a record an unreleased build wrote with a cause still parses, naming nothing. * test(native-chat): import the failure words once in the provider child test The merge left two imports of the same modules. * fix(native-chat): settle a gone generation's turn at every open but an acquisition's The journal open skipped the settlement whenever the lease read reserved or live, to leave an acquisition's own open to the acquisition. But a lease a crashed process left in recovery also reads live, until the next acquire resolves it. A send that opened such a chat, from a phone or the CLI after a crash on a host that could not prove the old owner gone, skipped the settlement; when its start then failed, the dead turn stayed running for every reader. The acquisition now says it is the opener, and every other open settles, whatever the lease still claims. * test(native-chat): hold the create's start open until the views bind The "view binds while the create is still starting" case gave the create a 300 ms head start and asserted the views bound before it died. On a loaded runner the holds took longer, the create's exit landed first, and the case failed its own precondition. The create's initialize now waits on a gate the test releases once the views are bound. * fix(native-chat): a start the provider refused or Orca broke no longer says the provider stopped A failed start whose cleanup proved the child gone was typed as `providerStartFailed` whatever failed it: Codex refusing to resume a thread, a timeout, or Orca's own store fault. The chat then read "The provider stopped before it finished starting.", which was untrue, and the provider's own words were dropped. A refused restart also inferred the same from an `exited` verdict, which only says nothing runs now. Only an exit the adapter observed names that situation now; anything else is refused with no reason, keeping its verdict, and the chat reads "<Agent> couldn't restart.". The provider's words travel host-side from where the acquisition failed into the start-failure fact's detail, never onto the refusal, and the sentence does not quote them. What failed is logged once where the start failed. * fix(native-chat): a refused /clear start keeps the situation it named The replacement start that /clear makes built its own start-failure fact, so a refusal that named its situation, such as not being signed in or a history too large to restore, was recorded as a bare "couldn't start". It now takes its fact from the same start-failure function as every other start, as a new session that failed to start. * fix(native-chat): the journal schema and comments describe a refusal's details, not its cause The persisted failure fact's schema still described `refusal.cause`, which this branch replaced with `details`. It now describes `details` as an optional open object; a row an earlier build wrote with a `cause` still parses. A comment and three test descriptions that still named the cause now name the details. * fix(native-chat): a Claude child that exits while being acquired still reads as the provider stopping Now that only an exit the adapter observed says the provider stopped, the exit the Claude adapter saw during acquisition has to be marked where it is seen, as the exit after acquisition already is. Without the mark, a Claude CLI that exited at spawn read "Claude couldn't restart." instead of "The provider stopped before it finished starting." * fix(native-chat): word a failed chat start's refusal as its start failure A chat whose agent failed to start answered the create with the raw error: the launch strip read "Chat could not be started. claude stream-json exited (code 1): claude: not signed in", and the ledger replayed the same text. The first answer and the replay now carry the sentence the chat's start-failure row reads as ("The provider stopped before it finished starting.", "Claude couldn't start.", the not-signed-in and history-too-large sentences), and the raw error goes to the log. A store refusal's code and the unproven-exit marker are unchanged. * fix(native-chat): show Claude's API retries as one sentence row While Claude retried a refused request (a 429, say), the chat gained one red row per attempt reading "rate_limit", with the raw retry frame behind Details. Each retry run now writes one warning row that later attempts revise in place: "Claude is rate-limited and retrying." for a rate limit (error `rate_limit` or status 429), and "Claude hit a temporary problem and is retrying." otherwise. The row carries a `providerRetrying` fact with the provider's error type and status, and the frame as a log detail capped at 512 characters. * fix(native-chat): tell the user to run /clear again when its new conversation can't start When /clear's replacement conversation failed to start, the result told the user to "send your message again", which would go into the old conversation. The failure words now take the command the start was for, so a failed /clear reads "Codex is not signed in for the selected account. Sign in, then run /clear again.", "Codex couldn't start. Run /clear again." or "The provider stopped before it finished starting. Run /clear again." A message send keeps its wording. * test(native-chat): expect a failed Claude create to be refused in a sentence The runtime suites asserted the CLI's stderr reached the create refusal; it now goes to the log and the refusal reads as the chat's start failure. * fix(native-chat): name the agent that stopped starting and say how to retry a failed start A start the provider ended now reads "Claude stopped before it finished starting." (or "The agent ..." when the chat's agent is unknown) instead of naming "the provider". A start or restart that failed with a chat left to retry now ends in "Send your message to try again.", or "Run /clear again." for /clear; released clients print only this sentence. A message rejected at dispatch because its child died while starting names the same agent as the start's row. * refactor(native-chat): drop the composer's second error formatter After the merge with main, every chat write in the composer path reports its failure as a typed outcome worded by the refusal-notice table, so the send's catch sees only a local throw. The {code, message} formatter this branch added for it has no payload left to format, and its claim to be the one way a chat words a failure is no longer true. The composer send is main's again. * test(native-chat): pin the reason on a message rejected while its chat was closed The reopen test checked only that the message reads as not sent; it now also checks the Retry row carries the host's reason. * fix(native-chat): answer a create refused before spawn in the words its replay reads A create that failed before any process started threw Orca's own error text as its first answer, while its replay from the operation ledger read the generic start sentence. The two refusals a person can act on, a launch whose Anthropic sign-in variables override the managed Claude account and a Claude account switch in progress, are now typed where they are thrown and worded by the shared failure constructor, so the first answer and the replay say the same thing. Every other pre-spawn failure reads the generic start sentence, with its own text in the log. The first answer keeps its wire code; a message that is itself a code is unchanged. * fix(native-chat): say how to retry after an agent stopped before it finished starting "<Agent> stopped before it finished starting." gave no next step outside /clear, unlike every other failed start. It now ends "Send your message to try again.", the same step a start or restart that could not run gives; after /clear it still says "Run /clear again." * fix(native-chat): say how to reach a Claude chat when a WSL Claude account blocks it A Claude chat that restarts while a Claude account is added in WSL and no Windows Claude account is selected was refused before spawn with Orca's own text as its first answer, and the generic start sentence on replay. The refusal is now typed where the account gate throws, and both answers read the same sentence: choose or add a Windows Claude account in Claude Accounts settings, then send the message again. Account settings that cannot be read name no situation and keep the generic sentence. * fix(native-chat): say the reason a host names for a refused chat write A refused Stop, answer, setting, goal, command or queued message now reads the refusal's reason as well as its code. A code stands for several situations, so the code alone could only say what did not happen; with the reason, the notice says why and, where the person has a step to take, what it is: "The agent is still responding. The command didn't run. Wait for the agent to finish responding, or stop it." The phone uses the same words. The notice table keys on code, then reason, then the kind of write. Every reason of every code has an entry, so a reason the host adds does not compile until it has words; a reason whose honest words are its code's keeps the code's row. A refusal with no reason, or one this build does not know, reads exactly as before, which is what an older host gets. A start that failed reuses the failure row's own sentence rather than a second one. A queued message keeps the reason, the rewind reason and the owner's verdict with its saved failure, and a rejected one keeps the host's typed fact without its provider detail. Nothing that moves with the owner or comes from the provider is saved; entries saved before this load as they were. The saved failure moves to its own module beside the words chosen from it. * fix(native-chat): retry a refused send under a new id only once its agent is proven gone A send refused because Orca could not tell who owns the chat keeps its operation id, since the first attempt may still land. When the refusal also says the agent process has exited, nothing can run that attempt, so the message moves to its Retry row under a new id instead of holding the queue. The owner's verdict is read as a floor: a saved `exited` is final, and any other saved verdict never changes the id on its own. Only a verdict re-derived from the current lease can raise it to `exited`, and nothing lowers a saved `exited`. Today no host sends a verdict on a send refusal, so nothing a person sees changes; the rule is in place for the saved verdict a reload reads back. * fix(native-chat): say a /clear that never finished did not finish, instead of that it cleared the chat A send into a chat whose /clear started but never committed was refused as if the conversation had been cleared: "This conversation has been cleared. Your message was not sent. Open the current conversation to continue." The clear never finished, so its new conversation may not exist and there is nothing to open. That refusal now has its own reason and reads "The last /clear didn't finish. Your message was not sent. Start a new chat to continue.", which is the only way on today. Its message for released clients says the same: "The last /clear didn't finish. Start a new chat to continue." Only a committed /clear still says the conversation was cleared. * fix(native-chat): a send the provider never received after a restart has no verdict Restart reconciliation rejects a crash-stranded send the provider's history proves it never received. Nobody failed that send, but the verdict table treated it as a failure. Each rejection kind now has its verdict in one exhaustive table, so a new kind does not compile until its verdict is chosen; no verdict for a withdrawal, a host restart, a chat close, or this lost send. A rejection whose kind this build cannot place, such as one a newer host added, now reads as undelivered with no verdict instead of falling back to the reason beside it: all it proves is that the message did not happen. The host keeps such a fact's kind when it reads the row back, rather than dropping it and letting the reason decide. Only kinds that can be why a message was not sent may reject one, by type: compaction, cancel/answer confirmation and provider-retry kinds stay on status rows. The one dispatch-row builder takes its input from the type that makes a rejected row carry its fact. * fix(native-chat): a send refused after its agent exited keeps its place in the queue When Orca cannot tell who owns a chat but the refusal says the agent process has exited, the next attempt may use a new id, since nothing can run the old one. It no longer marks the message as rejected: nothing recorded it, so it still holds the head of the queue, and later messages wait behind it instead of being sent ahead of it. * fix(native-chat): stop reading a provider's words from an error that contains itself A cleanup that aggregates errors restarted the depth count for each one, so an aggregate error that contains itself recursed until the host ran out of stack. One depth bound now covers both the cause chain and the aggregated errors. * fix(native-chat): say a chat whose history can't be read can't continue, and to start a new one A read of a chat's history is refused with `agent_session_journal_unreadable` only when the chat's journal file is corrupt or not a database, which no retry can change. The notice table had no words for a read at all, so a pane had nothing to show but the raw code. Reading a chat's history is now its own request kind, `read-history`, and that refusal on it reads "This chat's history couldn't be read, so it can't continue here. Start a new chat to continue.", whether the host names the reason or raises the bare code. A write refused under the same code keeps its words, because its cause is any failed open, which can clear. Any other read refusal says only "This chat's history couldn't be loaded." `agentSessionReadHistoryRefusalParts(code, details)` gives a pane those words from a read error. The notice sentences move to their own module so the table stays within its size limit. * fix(native-chat): decide what every way a child ends means for queued messages in one table What a child's end means for the messages queued behind it was an if-chain: a user's Stop was checked in one place, a host stop in another, and every other cause, including one added later, fell through to "the provider exited". It is now one table over every end cause, so a new cause does not compile until someone says whether it fails what is queued and how. A user's Stop still fails nothing, a host stop is still Orca's fault, and an exit, a failed attach or an eviction still carry the failure the end recorded. Nothing a person sees changes. * test(native-chat): a close that stops the child and then fails rejects what is queued by how the child ended When a chat closes, stops its agent, and then fails a later step, the chat stays open with its messages still queued. The delivery loop then rejects them by the way the child ended: an eviction during startup reads as a failed start, otherwise as the provider having stopped, and either counts as a failure. The cases are rows over the end cause, so another way a chat closes is one more row. * test(native-chat): type the refusal a persisted-schema test admits * fix(native-chat): say whether a chat's history is damaged or just couldn't open A write refused because the chat's journal would not open named one reason, `journalUnreadable`, for every failed open, and a read of the history took that same reason as final. So the words depended on what was asked, not on what happened: a busy or permission-denied open could tell a person to start a new chat, and a damaged one could read as something that clears. The host now decides at the refusal which it was. `journalCorrupt` is set only when SQLite itself reports the journal damaged or not a database (SQLITE_CORRUPT or SQLITE_NOTADB, extended codes included, read from the driver's result code and never from message text, through any `cause` chain). Every other failed open is `journalUnavailable`. A corrupt history reads "This chat's history couldn't be read, so it can't continue here. Start a new chat to continue.", with "Your message was not sent." before the step on a send. One that couldn't open reads "Orca couldn't open this chat's history right now. Try again.", likewise on a send. A host that names no reason gets "Orca couldn't read this chat's saved history.", which promises neither, because damage can't be proven from the code alone. The refusal's message, which released clients print for a send, is now that person sentence instead of the open error's own text; the error is logged instead. `journalUnreadable` is replaced outright: no released build wrote it, and a stored one reads as a refusal with no reason. * docs(native-chat): say why a history that couldn't open names its retry step * fix(native-chat): a failed start's row keeps the words its rejected messages carry When the delivery loop settles a failed start before the child's exit is published, it writes the start's error row and rejects every queued message with the adapter's startup answer. The exit settlement then rewrote the same row from the exit event, so the row could say one thing while the rejected messages, which are terminal, said another. The exit now leaves a start's row it finds already written. * fix(native-chat): a Claude start Orca itself failed no longer says Claude stopped Every error that ended a Claude session was marked as an exit the adapter observed, including a start Orca failed while the CLI was still running: a saved option whose restore lost its answer, an init frame naming another session, or a journal write fault. Those read "Claude stopped before it finished starting." although Claude never stopped on its own. The mark now stays where the child's exit is seen (the connection's exit callback), so those starts read "Claude couldn't start." with any diagnostic beside it, and a real exit before the start lands still says Claude stopped. * fix(native-chat): name the agent that stopped, and blame Orca for its own closes A chat that lost its agent mid-response said "The provider stopped…", and a started Claude session that Orca itself closed after a journal fault said the same, as if Claude had exited on its own. The exit row and the rejected-message reason now name the chat's agent ("Claude stopped while this response was in progress…", "Codex stopped before this message was sent."), or "The agent" when the name is unknown; the stale-state settlement now passes the agent name too. After a Claude start has landed, the ended event reports providerExited only when the child's own exit was observed; any other close is Orca's fault and reads as one. * test(native-chat): pin the sidebar verdict to the rejection classifier for every kind and legacy marker * fix(native-chat): blame Orca, not Codex, when Orca closes the Codex child A Codex chat that Orca itself closed (a journal sink that could not take a frame, or a forced close) said "Codex stopped while this response was in progress", as if Codex had exited on its own. Orca's own close path now reports hostFault. providerExited is left to the app-server connection's exit callback, which the connection withholds while Orca is closing the child, so it only ever reports the child's own exit. * fix(native-chat): a chat whose history is damaged reads "Unable to load this chat." * fix(native-chat): route the conversation-outlives-agent writers through the typed refusals Three writers that arrived with the merge wrote refusals the old way: - An operation that starts the agent itself, such as a goal change, turned any error the start threw into a refusal whose message was Orca's own error text, which released clients print. It now logs the error and says only that the agent couldn't restart. - An option picked while the chat is at rest, for a key the provider would not accept, is refused with the rejected-option reason, like the same pick on a running agent. - A restart continuation whose agent was refused a start filed the rejected message's sentence as the failure's reason. It files the refusal's code with its details again, which is what the restart-failure guidance keys on. * fix(native-chat): a start Orca stopped because it never finished reads as that The idle sweep now stops an agent whose start never finished and rejects the messages waiting on it. The chat read "Orca ran into a problem, so this didn't go through. Try again." for that, because every host stop was worded as Orca's own fault. It now reads "Codex never finished starting, so Orca stopped it." in the chat's row and on each rejected message, carried as its own failure kind so newer clients can tell it apart. The message counts as failed, like any start that did not land. * test(native-chat): pin the merged close and host-stop rows to their typed facts The merge left two expectations on the old words: the close tests looked for the marker a close used to write, and the host-stop test for the host-fault sentence. A close now writes "The chat closed before this message was sent." with its fact, and a host stop the hostStopped sentence the constructor gives, whatever reason the stop carried. Also folds the conversation command's two imports from send preparation into one. * fix(native-chat): a read of a chat this host cannot open says why Reads now reach a chat through one accessor, which refused a missing record and a provider this host does not run as a bare code with nothing beside it. Revealing the same chat already names those reasons, so a client could tell "this chat no longer exists" and "update Orca" apart there but not on the history or subscribe read that follows. The accessor now throws the same typed refusals. The wire code and message are unchanged; the reason rides only in the error's data, which released clients ignore. * test(native-chat): a Claude retrying past the idle window keeps its conversation open Every api_retry frame publishes the journal, and that publish is the activity the idle sweep reads, so a retry run revised into one row still renews the clock on each attempt. --------- Co-authored-by: Claude <noreply@anthropic.com> |