Skip to content

Continue a chat from another computer every time, and let doctor repair sync (#2070) - #2078

Merged
AbirAbbas merged 5 commits into
devfrom
fix/2070-continue-every-time
Oct 30, 2026
Merged

AbirAbbas merged 5 commits into
devfrom
fix/2070-continue-every-time

Conversation

@AbirAbbas

@AbirAbbas AbirAbbas commented Oct 30, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #2070. Closes the rest of #2072 (A, D, F, G, I, L and M landed in #2075).

What changes

  • Continue works every time. The flag that marks a take as running is now cleared on every way a take can end: it opens, it is brought forward, it fails, or it is cancelled. Before this, three continues in a row in one window could leave the third doing nothing. A press while a take is still running now says so on the card (a chat is already continuing here; wait for it to open). A failure is shown on the card with 1 retry / 2 leave it there, or in the chat, never only on Home's say line after the card has closed.
  • A busy store is named. When the engine says its store writer is still busy, continue says which program holds it (… the engine store is busy (held by <command>, pid <pid>)) and offers retry. The error is classified once at the engine boundary into cellstore.StoreBusyError (errors.Is(err, cellstore.ErrStoreBusy)), and the home's own saving process is never named as the holder.
  • --host is consistent. Under codeaf chat --host B, the window no longer wires this computer's sync identity and takeover into a session that runs on B. continue here is not offered there, and a chat B holds never shows as held elsewhere. The remote wire has no take verb, so this is the option the issue allowed for.
  • One chat, one row. Home keys rows by chat id, not transcript path. Branch copies collapse into the main row, which shows a count and says whether discarding a copy loses anything.
  • codeaf doctor finds it; codeaf doctor --fix repairs what is safe. Doctor reports a busy store and its holder, an unfinished continue this computer started, duplicate rows for one chat, and this computer's lapsed holds. --fix releases this computer's unfinished continues and retires branch copies whose turns are all in the main chat, with one fixed: line each. It never touches another computer's live hold and never removes a turn that exists only in what it would delete. Without --fix it changes nothing. A lock file whose process has ended is already free, so doctor neither reports nor deletes one.
  • The manual quotes every new sentence, under headings in the asker's words ("continue here does nothing", "the same chat twice on Home", "repair sync"), with probes in chat_test.go.

Validation contract → tests

  1. Continue works every time: TestContinueThreeRemoteChatsInOneWindowShowsContinuingAndFullHistory, TestHomeTakingClearedOnEveryEndingAndBusyPressExplainsOnCard, TestEveryFailedTakeShowsItsSentence
  2. Busy store named: TestBusyStoreNamedWithHolderAndOffersRetry, TestStoreBusyReportsHolderWithinSeconds, TestStoreBusyIgnoresHomesOwnDaemon
  3. --host: TestHostedSurfaceNeverOffersContinueOrShowsHeldElsewhere
  4. One row: TestOneChatOneRowKeyedByIDAndCollapsedBranchCopies, internal/chatlist collapse tests, TestC5OnlineContinueOpensTheArrivedCopy (now also asserts the local and sync rows merge)
  5. Doctor: TestDoctorFindsAndRepairsSyncAndEngineStoreSafely (real relay, two paired homes, a foreign process holding the store, report-only mutates nothing, --fix keeps every unique turn)
  6. Manual: the chat manual probes and TestEverySentenceOfTheDeviceScreensIsOnItsPage

Proof

Hand test with the real binary (bin/codeaf): two homes paired through the card over a local self-hosted relay, with a scripted model endpoint. B wrote three chats and closed. On A, each of the three continued in turn from Home in one window: each showed continuing here…, opened with its message and reply, and Home then listed each chat once. codeaf doctor on A then reported nothing, and codeaf doctor --fix changed nothing and left every transcript byte-identical.

Focused runs on the branch: internal/tui3 (continue, take, held, home, machines, wormhole, repository-read sets), cmd/codeaf (Doctor|Host|Surface), internal/cellstore, internal/chatlist, internal/manual, and make test-laws. The touched-package suites run in CI.

Five internal/syncsetup real-engine tests (TestDriveSideSupersededStopsTools, TestDriveSidePublishesOverTheDefaultDaemon, TestTwoHomesL12DeathBranches, TestTwoHomesTakeARunningChatAtOnce, TestPublicTakeRestoresEveryWorkspaceSetRoot) fail when the engine binary is staged locally. They fail identically on unmodified dev (f128ec3), and CI skips them because it does not stage the engine. That is tracked separately.

🤖 Generated with Claude Code

AbirAbbas and others added 5 commits October 30, 2026 17:55
When the engine answers that its store writer is still busy, the error is
classified once at the engine boundary into StoreBusyError, which unwraps to
ErrStoreBusy and names the holding program and pid where the OS can tell.
The home's own saving process is never named as the holder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A kept branch copy no longer stands as its own row: the main row counts its
copies and says whether discarding one loses anything.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ir sync

Every ending of a take clears the running mark, so a second and third continue
in one window work; a press during a take says so on the card; a failure is
shown on the card with retry or in the chat. A busy engine store is named with
its holder. Home keys rows by chat id. Under --host the window no longer wires
this computer's sync into a session running on the other one, so continue
here is not offered there. codeaf doctor reports a busy store, unfinished
continues, duplicate rows and lapsed holds; --fix releases this computer's
unfinished continues and retires branch copies whose turns are all in the
main chat. The manual quotes each new sentence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The narrow spelling of a row with folded copies was the state plus the whole
short copies sentence, too wide for the sessions column, so the painter dropped
it and the row lost host-a offline. It now keeps the state and counts the copies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@AbirAbbas
AbirAbbas merged commit f3e4f6f into dev Oct 30, 2026
8 checks passed
@AbirAbbas
AbirAbbas deleted the fix/2070-continue-every-time branch October 30, 2026 22:13
AbirAbbas added a commit that referenced this pull request Oct 31, 2026
…OS test reds, provenance on private apply (#2086)

* release: expand an empty test argument list safely under macOS bash 3.2

The release-surface step ran go test "${test_args[@]}" under set -u. With no
known-red ledger the array is empty, and /bin/bash 3.2 on macOS treats that
as an unbound variable, so the prologue test failed on a Mac. The conditional
expansion behaves the same on bash 5 and is safe on 3.2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(syncsetup): give hand-built Sync fixtures their own home

Two fixtures built Sync{} with an empty Home. A refusal retires the send
hold, which joined the empty Home and wrote v3/sync/holds/<hash>.lock into the
package's source directory on every run. They now use t.TempDir().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: engine fixtures record a real conversation and wait for async branches

These tests need a furrow engine. GitHub's full tests stage none, so they skip
there, and they failed on every machine that has one (a Mac after make build,
or CODEAF_FURROW on Linux):

- the scripted drive chat wrote files but never a reply, and a chat with no
  conversation is deliberately not published, so its turns never became
  durable; each scripted turn now writes a reply to the transcript
- the large-home startup test drained a warm capture that has no turn, so
  nothing was sealed; it now completes one recorded call first
- a refused drive branches asynchronously, so the counts and two-home tests
  wait for the branch row instead of reading it once
- the superseded-drive test keeps A offline until its next publish, as the
  scenario states, and waits for the kept-work notice too

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Never write a new identity into a codeaf home that was deleted

A codeaf engine that was still saving after it was asked to stop could find
its home deleted and treat that as a first run: identity.EnsureSolo made the
folder again and wrote a new identity.json, device.json and identity-solo,
and the vault capture then wrote the sync ledgers beside them. The Mac tester
saw exactly that 8-22 s after removing a test home.

Each codeaf process now records the home it starts in (home.Witness). The
identity writers, the vault open, the stats recorder and the send-hold writer
refuse with "codeaf home was removed or replaced" once that folder is gone
or is a different folder, and they never re-create a recorded root. A first
run, where no home existed at start, creates it exactly as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* A stopping engine ends at once when its home is deleted

An engine asked to stop closes every conversation before it exits, and for a
synced chat that can take tens of seconds while the relay is unreachable. If
the home was removed meanwhile, everything it still wrote went into a folder
that no longer existed. While shutting down, the host now watches its home;
if it is removed or replaced, the engine exits within about a tenth of a
second instead of saving into it. With the home intact the stop does what it
did before, in the same order and with the same limits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Bound how long a busy engine store spends naming its holder

#2078 named the process holding a busy engine store. On macOS it asked lsof
about every lock file it found, 500 ms each with no cap, so a failing seal
could add seconds. One shared budget now covers the whole lookup, and it
stops at the first busy holder; when the budget runs out the message is the
plain busy sentence. Requests that are not busy never inspect anything.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* A chat made with chat --once always reaches your other computers

runChatV3Once returned before the interactive road registered its process
cleanup, so a paired --once never closed its sync drive. Publishing uploads
a chat's frames before it registers the chat with the relay, and a --once
that exited in between left the uploads with no record pointing at them: the
other computer never listed the chat, and doctor said nothing (Hello saw it
in 1 of 48 runs, about 1 s after pairing).

--once now always registers drive cleanup; with no drive it returns at once,
and a paired chat gets its final send and release inside the same limit the
interactive road uses, with one plain stderr sentence if that fails. A paired
drive records a small local publication marker, so codeaf doctor can report a
chat this computer started that the relay does not list, and doctor --fix
registers it through the ordinary road, create-only, never over another
computer's record and never for a chat put away meanwhile. A never-paired
computer writes no marker and makes no relay request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* manual: --once waits for your other computers, doctor finds a chat that did not arrive, a deleted home is refused

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: spell the Option chords the way the product does on macOS

macOS names the Option key opt, so Home's continue offer shows opt+c and the
Memory switch shows opt+u. Two tests hard-coded alt+. The Memory test now
builds its expected line through the app's own chord spelling, and the
takeback test waits for the offer's question and checks a continue chord is
drawn beside it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* changes: entry for #2086

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(syncsetup): make the quiet solo home in a fresh folder

The quiet case removed a paired machine's folder and created a solo identity
at the same path in the same process. codeaf now refuses to create an identity
in a home it saw and that was then deleted, so the case uses a fresh folder,
which is what a never-paired home is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(session): task-copy fixtures that hold on macOS

42 internal/session subtests failed on macOS and passed on Linux:

- On macOS a new file takes its folder's group, so the Apply fixtures' files
  often carried a group other than the process's and the portable-metadata
  check refused them during setup, before the test's own behaviour ran. The
  success fixtures now give their files the process's uid and gid, resolve
  temp-path aliases first, and clear extended attributes. The product refusal
  stays: private Apply stages replacements with the process's group, so a
  foreign group cannot be carried. A new setgid-directory test pins that
  refusal on Linux.
- Bare mktemp on macOS ignores TMPDIR, so five scratch probes now use
  mktemp "$TMPDIR/codeaf-runtime.XXXXXX".
- Two path checks compared a canonical path with a raw temp path; both sides
  are canonicalized now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Revert "manual: --once waits for your other computers, doctor finds a chat that did not arrive, a deleted home is refused"

This reverts commit f28b0f4.

* Revert "A chat made with chat --once always reaches your other computers"

This reverts commit f9bb2e7.

* manual: a deleted home is refused, and a stopping engine exits when its home is removed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* changes: #2086 no longer carries the --once change

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Private apply tolerates macOS's own com.apple.provenance attribute

macOS 15 stamps com.apple.provenance on every file a process tree started
from some apps creates, and the owner cannot remove it. Private apply refused
any extended attribute, so on such a Mac it refused every changed file. That
one attribute is now tolerated on macOS: the replacements apply writes come
from the same process tree and are stamped the same way. Every other
attribute, quarantine included, is still refused, and Linux is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(session): Apply fixtures leave macOS provenance in place

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
AbirAbbas added a commit that referenced this pull request Oct 31, 2026
… slow relay says so

The public-network test found four ways continuing a chat between two
computers went wrong:

- D6: a bare start continued the newest chat another computer let go of,
  and while that move ran every explicit pick on Home answered "a chat is
  already continuing here" and the older chat opened. An automatic move now
  gives way: a confirmed different chat waits with the holder the person
  saw, the automatic move settles and its unused hold is released with a
  fresh bounded context, then the picked chat is taken and opened.
  Confirming the arriving chat joins its move. With no input a bare start
  behaves as before.
- D3: a lost-race refusal skipped #2078's retry card and only said
  "another device continued this chat first" in the footer, and a holder
  whose own writer reopened after a move (raising its ownership number) was
  blamed as another device. Every refusal now keeps the card and the draft;
  a fresh reading tells the same holder reopening (the card asks again with
  what it now shows) from a real different device.
- D5: Home gave up on a slow relay after 5 s, and the first message after a
  successful move was left in the box under Home's timeout sentence. Home's
  listing now waits up to 25 s, says "waiting for <host>; still checking"
  at 5 s and keeps its rows; a real outage still shows at once. A send
  right after this window's own move waits up to 30 s for its writer to
  start, then sends once; a send that cannot leave says the message was
  kept and names the relay.
- D7: each continue writes one chat.log line with the time spent in each
  phase, so a slow empty-folder move can be read instead of guessed.

Consent still binds holder and ownership number, and nothing in
internal/clusterstate, the wire or stored formats changed. The manual
pages say what the code now does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AbirAbbas added a commit that referenced this pull request Oct 31, 2026
… slow relay says so

The public-network test found four ways continuing a chat between two
computers went wrong:

- D6: a bare start continued the newest chat another computer let go of,
  and while that move ran every explicit pick on Home answered "a chat is
  already continuing here" and the older chat opened. An automatic move now
  gives way: a confirmed different chat waits with the holder the person
  saw, the automatic move settles and its unused hold is released with a
  fresh bounded context, then the picked chat is taken and opened.
  Confirming the arriving chat joins its move. With no input a bare start
  behaves as before.
- D3: a lost-race refusal skipped #2078's retry card and only said
  "another device continued this chat first" in the footer, and a holder
  whose own writer reopened after a move (raising its ownership number) was
  blamed as another device. Every refusal now keeps the card and the draft;
  a fresh reading tells the same holder reopening (the card asks again with
  what it now shows) from a real different device.
- D5: Home gave up on a slow relay after 5 s, and the first message after a
  successful move was left in the box under Home's timeout sentence. Home's
  listing now waits up to 25 s, says "waiting for <host>; still checking"
  at 5 s and keeps its rows; a real outage still shows at once. A send
  right after this window's own move waits up to 30 s for its writer to
  start, then sends once; a send that cannot leave says the message was
  kept and names the relay.
- D7: each continue writes one chat.log line with the time spent in each
  phase, so a slow empty-folder move can be read instead of guessed.

Consent still binds holder and ownership number, and nothing in
internal/clusterstate, the wire or stored formats changed. The manual
pages say what the code now does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AbirAbbas added a commit that referenced this pull request Oct 31, 2026
… slow relay says so

The public-network test found four ways continuing a chat between two
computers went wrong:

- D6: a bare start continued the newest chat another computer let go of,
  and while that move ran every explicit pick on Home answered "a chat is
  already continuing here" and the older chat opened. An automatic move now
  gives way: a confirmed different chat waits with the holder the person
  saw, the automatic move settles and its unused hold is released with a
  fresh bounded context, then the picked chat is taken and opened.
  Confirming the arriving chat joins its move. With no input a bare start
  behaves as before.
- D3: a lost-race refusal skipped #2078's retry card and only said
  "another device continued this chat first" in the footer, and a holder
  whose own writer reopened after a move (raising its ownership number) was
  blamed as another device. Every refusal now keeps the card and the draft;
  a fresh reading tells the same holder reopening (the card asks again with
  what it now shows) from a real different device.
- D5: Home gave up on a slow relay after 5 s, and the first message after a
  successful move was left in the box under Home's timeout sentence. Home's
  listing now waits up to 25 s, says "waiting for <host>; still checking"
  at 5 s and keeps its rows; a real outage still shows at once. A send
  right after this window's own move waits up to 30 s for its writer to
  start, then sends once; a send that cannot leave says the message was
  kept and names the relay.
- D7: each continue writes one chat.log line with the time spent in each
  phase, so a slow empty-folder move can be read instead of guessed.

Consent still binds holder and ownership number, and nothing in
internal/clusterstate, the wire or stored formats changed. The manual
pages say what the code now does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AbirAbbas added a commit that referenced this pull request Oct 31, 2026
…n a slow relay; trust-proxy for every relay limit (#2089)

* relay: trust X-Forwarded-For only with --trust-proxy, for every limit

A self-hosted codeaf relay took the first X-Forwarded-For address as the
caller's network for its registration and dial limits whenever a request
carried one, so a relay exposed directly to the internet let any caller
mint a fresh allowance by naming its own address. --trust-proxy already
existed but reached only the pairing limits.

relay.Server now counts the socket peer unless it is handed a peer policy,
and relayserve hands the pipe the same socket-or-forwarded policy the
pairing mailbox and link pairing use. A relay behind a proxy must now pass
--trust-proxy, or every caller shares the proxy's allowance; the flag help,
docs/relay.md and the relay manual page say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* sync: a chat this computer never held primes from its frame plan

SyncEngine.Holds read the store-wide published.<ledger> file, and new chats
share one store, so after any earlier exchange with a relay every new chat
looked warm and Fetcher.prime skipped its frame plan. A large chat then
came over one serial object batch after another instead of its frames,
eight at a time; on the hosted relay each batch costs seconds.

Holds now asks whether this computer holds history for this chat (its
adoption record or a recorded turn), and a return landing in a fresh
staging folder asks about the chat's existing folder, so a warm return
still fetches only its delta. The completeness loop is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* continue: the picked chat opens, a refused continue keeps its card, a slow relay says so

The public-network test found four ways continuing a chat between two
computers went wrong:

- D6: a bare start continued the newest chat another computer let go of,
  and while that move ran every explicit pick on Home answered "a chat is
  already continuing here" and the older chat opened. An automatic move now
  gives way: a confirmed different chat waits with the holder the person
  saw, the automatic move settles and its unused hold is released with a
  fresh bounded context, then the picked chat is taken and opened.
  Confirming the arriving chat joins its move. With no input a bare start
  behaves as before.
- D3: a lost-race refusal skipped #2078's retry card and only said
  "another device continued this chat first" in the footer, and a holder
  whose own writer reopened after a move (raising its ownership number) was
  blamed as another device. Every refusal now keeps the card and the draft;
  a fresh reading tells the same holder reopening (the card asks again with
  what it now shows) from a real different device.
- D5: Home gave up on a slow relay after 5 s, and the first message after a
  successful move was left in the box under Home's timeout sentence. Home's
  listing now waits up to 25 s, says "waiting for <host>; still checking"
  at 5 s and keeps its rows; a real outage still shows at once. A send
  right after this window's own move waits up to 30 s for its writer to
  start, then sends once; a send that cannot leave says the message was
  kept and names the relay.
- D7: each continue writes one chat.log line with the time spent in each
  phase, so a slow empty-folder move can be read instead of guessed.

Consent still binds holder and ownership number, and nothing in
internal/clusterstate, the wire or stored formats changed. The manual
pages say what the code now does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* changes: entry for #2089, what continuing between computers now does

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* continue: review fixes — leave a shared view open, bound an abandoned move

From the review of the continue fixes:

- An abandoned automatic arrival no longer closes its conversation; it
  leaves through the path a departing window takes, so another window on
  the same session keeps working and the engine releases the chat only
  when the last window goes. AlreadyHere skips cleanup.
- An abandoned automatic move is cancelled the way Escape used to cancel
  it if it has not answered within two minutes, so a waiting pick always
  runs. A second Escape navigates again instead of being swallowed.
- Opening another chat's card no longer abandons the automatic arrival;
  only confirming it, or the resume chord, does.
- The already-here recovery retries silently once, then keeps the card.
- A send's own reading keeps Home's outstanding listing progress and host.
- A writer wait ends at once on displacement, with the existing words,
  instead of waiting 30 s and blaming the relay.
- The manual says what Escape does to an automatic continue; the release
  retry reuses cellsync.ReleaseRetryEvery.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* tui3: read the stale listing before the take, as the test intends

askChats now carries its five-second progress tick in a batch, so
a.askChats()() returned the batch rather than the answer, and the test ran
the "old" read only after the take, against the new holder. It now takes
the listing out of the batch and reads it first, which is the in-flight
answer the test was written about; what it asserts is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* sync: a continue waits for another window's vault save instead of failing

D4 in the two-network re-measure: 2 of 5 picks on the dev relay failed
with "could not continue this chat here because the chat could not be put
in this folder", and chat.log said "handoff: resource temporarily
unavailable" with the hooks phase at 5 ms. A continue's last hook pulls
the vault under state-sync.lock, taken without waiting, while every
running chat's drive pushes the vault under the same lock; over a slow
relay that push holds it for seconds, so the pull failed at once and the
chat was handed back. The pick fix made this more frequent: picks that
used to be refused as busy now reach the take while the bare start's
engines are still saving.

The continue's pull now waits up to 30 s for the lock, then fails as
before. Background exchanges still skip when the lock is busy, because
the holder is already carrying the same state.
TestAContinueWaitsForAnotherWindowsVaultExchange holds the lock from a
second open file through the move's restore; without the wait it fails
with the exact error L3 saw.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Continuing a chat on another machine fails silently and can list it twice

1 participant