Skip to content

Keep codeaf current with quiet background updates that preserve running work - #1790

Merged
santoshkumarradha merged 12 commits into
devfrom
seamless-update-20261007
Oct 7, 2026
Merged

santoshkumarradha merged 12 commits into
devfrom
seamless-update-20261007

Conversation

@santoshkumarradha

@santoshkumarradha santoshkumarradha commented Oct 7, 2026 •

Copy link
Copy Markdown
Member

What changed

Keeps codeaf current without interrupting a conversation. A newer release gets a small, nonmodal offer in the existing footer; after ten visible seconds it downloads and installs in the background. Chats, drafts, questions and tasks remain the main interface.

Fixes #1788. Targets dev.

  • Automatic updates default on, with one row in Settings → Workspace. Skip one release with alt+n or /update skip; turn automatic installation off in settings or with /update never.
  • The countdown pauses wherever its controls are hidden. A setting changed in another window is checked again before download begins.
  • Default updates follow the installed build: devaf → dev, stable codeaf → stable, stageaf → staging. Explicit channel or tag requests override that choice. Each updates its own executable.
  • /update uses the same background installer. Completion briefly appears in the footer and leaves a concise note; failures preserve the current executable and use bounded automatic retries.
  • A new window can join a compatible older engine that still has work. The engine steps aside only when its windows and work are finished. A busy incompatible engine is refused instead of killed or duplicated.
  • The shared installer locks the real executable path, verifies its checksum and replaces it atomically. Stale windows cannot overwrite a known newer installation; explicitly naming a release still permits rollback.
  • The built-in manual explains the controls, defaults and activation limits so the model can answer questions about updating.

“New window” means a newly launched CLI process, not a new in-app chat tab. The new executable and the running engine are different: installation does not migrate live turns. Existing windows continue on their current build; new windows use the installed app and may temporarily share an older compatible engine. Older engines without the new retirement request transition at the next safe launch.

Screens

These are actual TUI renders in isolated profiles. Release labels in the seven update states are deterministic fixtures; the settings captures operate the real settings page. They show appearance, not proof of runtime continuity.

Launch offer · 80 columns

Launch offer · 80 columns

Installed while work continues · 120 columns

Installed while work continues · 120 columns

Settings → Workspace · automatic updates on

Settings → Workspace · automatic updates on

Settings → Workspace · automatic updates off

Settings → Workspace · automatic updates off

Failed installation · current version continues

Failed installation · current version continues

All 18 captures, raw frames and provenance.

How it was checked

Live-test source: 63cb1b347e94f9471f9079e9f4c40f4dccbc8b31, rebased onto dev at baa2d7f0fda7466fd787841274170bd56a4148aa (including the Teams changes). All full and live acceptance ran on Spark; all live model calls and parallel model reviews used deepseek/deepseek-v4.1-flash.

Check Result
make pr-ready Passed; every selected test passed on its first run. Build, vet, laws, packed manual, tooling acceptance and full affected-package suites.
Manual /update during real model work 31/31 required assertions passed, including cleanup.
Automatic countdown update during real model work 31/31 required assertions passed, including cleanup.
Real installer failure/concurrency checks Bad checksum, interrupted download, offline check and competing installers passed.
Actual released devaf engine → candidate 40/40 required assertions passed, including safe idle handover, transcript continuity, a real follow-up reply and cleanup.
Repeated independent review Correctness findings fixed and re-reviewed; no remaining blocker identified within the reviewed Linux scope.

High-level examples exercised:

  • Start a shell tool, update the executable while it runs, and receive its result exactly once on the same UI and engine processes.
  • Keep an unsent draft through the update, open a separate chat on the same busy engine, then send the next message successfully.
  • Close the windows after work finishes and resume the exact conversation with its earlier answer on the new engine.
  • Reject a damaged download or stop a partial download while leaving the original executable intact; two installers sharing a target produce one successful installation.
  • Skip a release in one window while another writes retry history: the skip survives. Turning automatic installation off is re-read before download.
  • Open a model picker or full-screen settings/question surface during the offer: hidden controls pause the grace period.
  • Install a named dev build, then advance to a newer build published the same day; refuse a stale reverse update. If release order cannot be confirmed, keep the existing build and explain the uncertainty.

The live updater runs use two synthetic release stamps on the live-test source named above and a loopback server that serves the actual binary and checksum. They exercise real TUI/installer/engine paths, not published release packaging. The separate released-engine run uses a byte-identical isolated copy of dev-20261007-0dbeccf575ff.

Evidence: Spark job 20261007-162309-001791-seamless-integrated-final-validation ran the full sequence on the live-test source named above. The independent fix and integration review ran in parallel as 20261007-162310-001792-seamless-delete-fix-review. The final integration regression also drives the real skip key with the new conversation-delete sheet open and proves that its countdown waits.

Final code review follow-up: 0ba10fca643e5e8e3b77cb9f8fa5e1bc62238670 fixes two message defects: /update skip no longer calls a finished or failed install “running”, and managed/unwritable installs show the correct guidance with automatic updates on or off. Focused Spark regressions passed; a fresh independent DeepSeek review verified both fixes and default channel selection (20261007-170243-001797-merge-fix-verification-review). This follow-up changes messages and their predicate, tests, and manual; it changes no installer or engine path. E2E was not rerun at the owner’s request. Final make pr-ready passed on that exact follow-up commit, with every selected test passing on its first run and all GitHub checks green. No tests were suppressed. Spark job 20261007-170157-001796-seamless-merge-gate.

No experimental build has been installed into the owner's Blackmac or Spark devaf profile. Windows locking was cross-compiled; Windows runtime continuity and macOS runtime continuity are not established by the Linux acceptance run.

Published release verification

After merge, Spark job 20261007-171749-001799-published-curl-review tested the actual public release dev-20261007-4fd9cfaa0fb6 (merge commit 4fd9cfaa0fb63b1c78db5aa6ebe9479e512d1640) on Linux/arm64. The public curl -fsSL https://agentfield.ai/get/devaf | bash installer was exercised in isolated homes with --no-modify-path --no-start passed via bash -s --.

  • Fresh installation selected dev, installed as devaf, and matched the public SHA256 checksum. devaf update --check correctly reported the newest dev build.
  • Settings → Workspace showed automatic updates on by default; off/on changes persisted.
  • The curl-installed TUI started its real engine and completed a real deepseek/deepseek-v4.1-flash chat. No update offer appeared while already current.
  • A second isolated installation of the previous published dev release updated through the public endpoint to this release; the resulting checksum matched again.
  • The public /get/codeaf script was inspected and defaults to stable/codeaf. No stable installation was performed.
  • User binaries, configuration, and shell files were unchanged; isolated test processes were cleaned up.

No newer public release existed, so this smoke run does not prove an automatic countdown into a subsequent public release; that behavior remains supported by the earlier isolated live tests. The test needed a shorter private state path to fit the Unix socket length limit. Evidence: published-curl/assertions.json under the Spark evidence root, all required assertions passed.

Checklist

  • Change entry: docs/changes/unreleased/1790-background-updates.md.
  • Built-in manual updated with the changed controls, defaults and limits.
  • No known-red ledger entry added or test failure suppressed.
  • Only explicit task-owned paths staged.
  • Final review fixes and released-engine acceptance complete.

@santoshkumarradha santoshkumarradha added this to the Chat & connections milestone Oct 7, 2026
@santoshkumarradha santoshkumarradha added feature Work that adds a capability; developers break it into tasks area:chat The v3 surface a person sits in front of (internal/tui3) area:session The engine — turns, tasks, the toolbelt, checkpoints area:build make targets, size budget, cross-build, release labels Oct 7, 2026
@santoshkumarradha santoshkumarradha self-assigned this Oct 7, 2026
…unning

The launch check now raises an offer in the keys line, counted down live from
ten seconds, and installs the release in the background when it expires.
alt+n or /update skip skips that exact release; /update never or the settings
row turns the automatic road off; auto off still names the release on one dim
line. Nothing is modal, no plain letter is bound, and no install ever quits,
restarts or stops a turn, a task or an engine. /update typed by hand installs
on the same coordinator and says the same thing.

The lock, the on-disk sha256-tied record and the duplicate/stale-downgrade
refusal live inside codeupdate.Install, so the chat surface and `codeaf update`
are one acquirer. A duplicate is a quiet no-op; only a named tag may roll back.
Package-managed or unwritable installs are refused before any download.

Manual pages the engine lane flagged are respelled to the new takeover
contract. CODEAF_UPDATE_DEMO raises labelled render fixtures and
scripts/update-drive.sh captures them at 80 and 120 columns with no network.

docs/design/background-updates.md is the design; the change entry carries the
unreleased placeholder number 1789 for root to correct.
What was true: a newer build connecting to a workspace replaced an engine
from an older build whenever it arrived, busy or not, through
enginehost.Stop — which asks with Anyway (do not ask the host whether it may
go) and signals the pid for a host too old to answer. It ended turns people
were watching, and it was two round trips: ask what is there, then kill it,
with a window free to attach in the gap.

What is true now:

- An older engine holding anything — a turn, an unanswered question,
  handed-off work, an attached window — is JOINED, and asked with the
  additive WhoIs.StandDownWhenIdle to retire at its first quiet moment.
- An older engine holding nothing is retired through enginehost.Retire with
  no Anyway: the host decides under the same lock that admits a
  conversation, so there is no status-then-kill race.
- A host on another wire is refused in words while it is busy, retired when
  it is not; one too old to answer at all is refused rather than signalled.
  codeaf engine --stop / --stop-all stay the only force in the tree.
- HostSelf.StandDownWhenIdle acknowledges the note, so the busy line only
  promises a quiet-moment swap to a host that understood it; an installed
  older build gets the honest sentence instead.
- openChatV3Local keeps a takeover refusal as its own type (hostRefusal)
  rather than hostUnreachable, so a launch refuses instead of opening a
  second writer beside the journal the engine holds.
- A structural law keeps the takeover road free of enginehost.Stop,
  migrations and store.Open.

Focused checks: internal/enginehost (whole package, and -race on the
lifecycle tests), internal/remote whois/idle/hold suites, and the engine and
host suites in cmd/codeaf all pass. Command list and limits: the engine
update plan in the external evidence directory.
…ller per real file

What was true: the countdown re-read the setting it had captured when the window
started, so a row turned off in another window was installed over anyway; the
launch check was suppressed whenever `update.auto` was off, so the quiet line the
change entry promised was never drawn; the bare `/update` that answered an offer
carried the offered tag into the installer as if the person had named it, so a
file another window had advanced could be rolled back; an explicit tag equal to
this process's own stamp never reached the installer, whose record is the only
thing that knows what the file holds.

What is true now:

- The automatic road asks the PROFILE, live, on every countdown beat, at the
  deadline and AGAIN after the resolver answers: a row turned off, or a release
  skipped, in another window stops this one before anything is downloaded.
  `/update` typed by hand stays the person's own answer and is not re-checked.
- The offer's own clause survives a narrow width over a running turn: the turn's
  clauses are shortened first, so the one control the countdown has is never the
  first thing dropped. The clock WAITS wherever the offer cannot be read or
  answered (a question page, /settings and the other places, the first-run
  sheet, a team's card, the provider panel, the setting panel's own boxes)
  instead of spending the grace invisibly.
- The launch check runs with `update.auto` off: the release is named on one dim
  line and never installed on its own. Only CODEAF_NO_UPDATE_CHECK silences the
  request. A production runSurface test proves the request is made and the file
  is untouched with the row off.
- Intent is carried, not inferred: only a tag a person NAMED may roll back. A
  named tag reaches the installer even when it matches the running stamp, and a
  bare `/update`, a channel or the automatic road never steps the file back.
- Skip is per release and says so; a skip the profile could not save is reported
  instead of claimed. `/update never` says an install already running finishes.
- The ready note names what is true without the engine lane's vocabulary: new
  windows open the updated app, and work already running keeps its current
  engine until all its windows and work close.
- The install target is canonicalized before the lock, the refusal, the
  temporary file and the replace, so an alias of a held target answers in flight
  and a package-managed file reached through a symlink is still refused. Two
  new tests hold real locks across two processes and through a symlinked folder.
- The `update.auto` row is registered in the config reader table and the profile
  key ledger, so the derivation law stops failing on the row the previous
  commit added.
- The `failure` render fixture goes through the production handler with a
  synthetic error, and `scripts/update-drive.sh` also captures the REAL
  /settings page with the auto update row on (default) and off. Captures run
  with an isolated home, an empty environment, `--no-host`, the launch check
  off, a loopback provider endpoint, and remove only the directory they made.

Focused checks: internal/update (whole package), internal/config, internal/namelaw,
internal/manual, the update/offer tests in internal/tui3, the Manual tests, the
update and surface tests in cmd/codeaf, and `make build`. The credits_test
failure in internal/tui3 was reproduced on the integrated base and is untouched.
InstallLock.Record opened the record file a second time to read it back. On
Windows our own exclusive byte-range lock makes that second handle unable to read
the region until it is unlocked, so the record written by the lock holder was the
one thing the holder could not read; on every platform it was a second look-up of
a name another process may be replacing. The record is now read from the held
descriptor through a bounded io.NewSectionReader, which does not disturb the
write cursor RecordInstalled uses.

Focused checks: internal/update (whole package, with the new read-through test
covering a second write after a read, a replaced file, corrupt bytes and the read
bound), `go vet` and a GOOS=windows compile check for internal/update and
cmd/codeaf. No Windows runtime claim: the reference is the LockFileEx contract in
the comment, and the run is a compile check on Spark.
The launch offer's countdown asked "am I standing on home?" by comparing the
page id itself (updateoffer.go), which is the switch LAW 1 forbids outside the
registry, and newOfferLab built its surface with no profile of its own
(testprofilelaw_test.go), so an earlier test's marker decided what it saw.

The visibility question is now the registry's: place.showsOffer says whether a
place draws the offer's own keys row (pages.go), false by default and true on
home, whose foot draws the offer in place of the resting hint (hometip.go), so
a room added later answers it on the day it is registered. The conversation
answers by not being a place at all. newOfferLab mints a profile with
t.TempDir(); the two-window shared-profile cases keep their own shared profile
through the Coordinator's callbacks, as they did.

Focused checks on Spark: the two failing laws alone
(TestNothingSwitchesOnAPageIdOutsideTheRegistry,
TestEveryHelperThatBuildsASurfaceNamesAProfile) pass; ./scripts/laws.sh is
green; the update tests in internal/tui3 (TestLaunchOffer…, TestTwoWindows…,
TestTheGraceWaitsWhereTheOfferCannotBeAnswered, TestBareUpdate…, and the
every-place laws) pass; internal/update whole; go vet ./internal/tui3. No
credit tests touched; the root full gate diagnoses those separately.
…ment

Three gaps the frozen review verified, none of them a new surface.

P2-1, the lost failure tag: every error out of codeupdate.Install carries an
empty InstallResult, so tookUpdateInstall read "" from the result and
updateStopped recorded no AutoFailure — no count, no backoff, no three-strike
stop, and the line drew `codeaf  could not be installed`. The requested tag is
now carried on updateInstallMsg from the resolve (which also stops the resolve
from blanking the offer's own tag) and read from the message, the result and
last the offer on BOTH the error and the success roads.

P2-2, the settings row told the truth backwards: `update.auto` off stops
installing, not checking. The hint and the KeyUpdateAuto comment now say off
installs nothing until asked while the launch check still runs and names a
release on one dim line, and name CODEAF_NO_UPDATE_CHECK=1 as the only switch
that makes no request. (The manual and the design already said this; only the
config surface contradicted them.)

P2-3, the pinned tag carried no publish moment: the automatic and offer roads
resolve an exact tag, Select answers it without the API, and the record was
written with a zero PublishedAt — so recordSupersedes fell to the same-day date
comparison and a stale same-day dev/staging offer could write over a newer file
another window had installed, AllowDowngrade false notwithstanding. The launch
check's LatestPublished now rides on the offer (updateOffer.published) into
Choice.Published and out as Release.PublishedAt, and recordSupersedes refuses a
same-day same-channel pair when either moment is unknown instead of guessing
permissive. Exact-tag rollback is untouched: a named tag still sets
AllowDowngrade, and a pinned resolve still asks no API, so `--version <tag>` and
`/update <tag>` install what they name with the assets alone.

Focused checks on Spark: internal/update whole (9.9s) plus the new pinned-moment
test and the extended recordSupersedes table; internal/tui3 update/offer set
including the two new tests (P2-1 through the REAL installer against a host that
answers nothing, asserting the exact tag, the count, the persisted LastError, the
backoff and the block at three; P2-3 through the REAL Select and Install on two
windows of one target, asserting the pinned resolve carried 10:00 and dev-b's
file survived); cmd/codeaf update/version suites; internal/config; ./scripts/laws.sh
green; go vet and gofmt clean. Credits tests untouched.

The change entry now states which version is which (the offer names the release,
the status row and the note name the build you are on) and the limits of
activation: an install replaces the file only, the running session keeps its
build until it is restarted, and the automatic road moves forward only.
…date row moves to Workspace

The coordinator's memory is a read-modify-write of one file, and two windows
could interleave it: a skip pressed in one while another finished an install
read the state before the skip landed and saved it back without the dismissal,
so the release the person declined was offered again and installed itself. Every
change to update-state.json now goes through one mutateAutoState that holds an
in-process mutex AND the profile's OS advisory lock on a stable
`<update-state.json>.lock` that is never unlinked, with a bounded wait so the
surface cannot hang. DismissRelease already reports a failed write instead of
claiming a saved skip; AutoFailure and AutoSuccess keep their signatures and no
longer claim a write they did not make, and AutoSuccess still never clears a
dismissal. A cross-process regression drives the real functions from two
processes and proves the skip and the failure both survive the interleaving.

The `auto update` row (`update.auto`) moves from the Display tab to Workspace,
beside `background checks`, and reads its category with them: keeping this
installation current is what codeaf does on this machine on its own, not how the
surface draws itself. The key, label and default are unchanged, and the first
sentence of its hint now says what it does and what off means. The settings
capture script walks to Workspace and asserts the new sentence; the manual,
design and change entry say the same.

The change entry also loses two literal escape sequences, its claim that the
status ROW reports the running build (it is the `build` row of `/status`, the
build holding the session), and the internal "engine lane" wording. The design
page gains a maintainer note: shared storage breaking changes must bump the
engine wire compatibility version, and joining an older engine is safe only
within that contract.
…heets

Four review findings on the background-update change (PR 1790).

P2-1: a hand-named tag (or `update --version <tag>`) resolved without the
API, so the install record it wrote carried no publish moment and a
genuinely-newer SAME-DAY dev or staging build was then refused as if the
disk build were newer. The shared installer now makes ONE best-effort,
bounded `Client.TagPublished` lookup for a dev or staging tag whose moment
is unknown - for the release being installed and for a dateless record
already on disk - so a dateless record no longer blocks a candidate whose
own moment proves it is newer. The lookup is skipped for stable and rc
(release order is the tag itself) and for a deliberate named rollback, its
failure is never fatal, and nothing is invented: when the facts cannot
order two same-day builds the refusal says "their release order could not
be confirmed" instead of claiming either is newer, and still names
`/update <tag>` as the escape. A dev or staging cache entry written
without its moment is refreshed once (and the real timestamp persisted);
when the API cannot answer, the cached offer still stands. `Select`'s
pinned-tag promise is untouched.

P2-2: the grace pause list omitted the model picker, the thinking ladder,
the session picker (whose keys take the slot the countdown is read from),
the folder chooser, and the phone tier's status sheet and tool detail.
The countdown now waits under every one of them, and a test asserts the
whole window is given back when it resumes and that the offer's own
control stays on the row wherever the clock keeps running.

P2-5: the change entry said the dismissal/failure state was keyed by
release AND executable; it is per profile, and only the install lock is
keyed by executable. The design note's pause list is corrected to match.

P2-3/P2-4: `runtimePlatform` was dead and is removed. The door's
detach-then-restart plumbing is RETAINED (the shared launch hands one empty
plan to four doors and C7/C14 pin that road) with the false "the engine
lane reads the same plan" comment replaced by what is actually true; see
the summary for why it is kept rather than deleted.
@santoshkumarradha
santoshkumarradha force-pushed the seamless-update-20261007 branch from 70de2ec to 263478b Compare October 7, 2026 16:16
…mation

Rebase integration fix (PR 1750 Teams brought in the conversation-delete
sheet). Its key handler is read at the top of the loop, before
updateOfferKey, so alt+n never reaches the offer while the confirmation
stands and the countdown could run down and install behind a sheet the
person cannot answer from.

updateOfferHidden now pauses on a.cdelete.on. The existing grace table
gains the sheet, asserting the grace does not fire, the full window is
given back and nothing installs; a new focused test drives alt+n with the
sheet up to pin the premise that the loop eats the chord. The design
note's pause list names the confirmation. No other code changed.

Focused checks on Spark: go test ./internal/tui3/ -run
'Update|Offer|Grace|Delete' and ./internal/namelaw ./internal/e2e - ok;
gofmt/vet/build clean.
@santoshkumarradha
santoshkumarradha marked this pull request as ready for review October 7, 2026 16:31
Two surface-truth bugs in the background-update messages:

- `/update skip` tested `offer.active()`, which is true through the few
  seconds a FINISHED (`updateReady`) or FAILED (`updateFailed`) install
  keeps its line on screen, while `updateInFlight` was already false. It
  said "an install is already running" over a line that read "installed"
  or "could not be installed". The predicate is now the install, not the
  offer; the ready/failed dwell answers "there is no update offer to skip".

- A managed or unwritable executable suppressed the refusal whenever
  `update.auto` was off, so the launch only advised `/update` -- the road
  the refusal says is answered with itself. The refusal is now always said,
  on both settings, and the notice printed beside it drops the
  `/update installs it for the next launch` clause rather than offering a
  road the product knows it will refuse.

Regression coverage genuinely models running vs finished/failed: the
running test starts a real in-flight install and never completes it, and
new tests pin the finished and failed dwells and the refused launch with
auto on and off. Manual wording matches. Channel intent (dev->dev,
staging->staging, stable/rc->stable) and the executable target are
untouched.
@santoshkumarradha
santoshkumarradha merged commit 4fd9cfa into dev Oct 7, 2026
8 checks passed
@santoshkumarradha
santoshkumarradha deleted the seamless-update-20261007 branch October 7, 2026 17:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:build make targets, size budget, cross-build, release area:chat The v3 surface a person sits in front of (internal/tui3) area:session The engine — turns, tasks, the toolbelt, checkpoints feature Work that adds a capability; developers break it into tasks

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Background updates that preserve running work with a quiet TUI experience

1 participant