Updating bosun¶
Status: approved 2026-11-05. Part A, the groundwork, is built: the two paths, the stores' formats, the
update's record and hold, backups, version folders, the write gate and the migration registry.
bosun update plans, rehearses, holds, freezes, releases and brings the agents along
(lib/update_facts.py gathers the plan's facts, lib/update.py carries it out, behind the wrapper
libexec/bosun-update; lib/update_agents.py decides what each window gets, lib/update_windows.py
carries it out, lib/update_notes.py keeps the windows' notes) and ends with its summary
(lib/update_summary.py). get.sh installs versions side by side and hands an existing install over to
it, and its first run converts a checkout an older get.sh made (convert in lib/update.py). What
was decided while building it is under "Settled while building it" and "Decided while building it"
below. Not done yet: the runs in the macOS VM (Testing), and what they settle. The plan:
docs/plans/update.md.
Why¶
Before bosun update, updating meant running get.sh again. It ran git pull --ff-only in
~/.local/share/bosun, in place, then started bosun init. All the while, the repo sessions, the
orchestrator, the check-in loop and the launchd jobs kept running from that folder. Nothing was backed up
and no data was migrated. Nothing checked the result, and nothing said what changed. A command that
started mid-pull could import half old and half new code.
Since sync phase 0, data formats change between versions: a journal's envelope, a store's rules version. Sync and sharing decided what an update owes them:
- a format change ships with its migration;
- the update backs the data up before it changes anything;
- going back means installing the older version and restoring that backup. Going back to versions older than today is not supported.
What I want¶
In my words, 2026-11-04:
- I run it mid-afternoon. Repo sessions are working, often with subagents and background tasks, and the orchestrator checks in with them and watches Slack and other sources.
- Nothing is lost midway. After the update, the agents check for anything missed from just before.
- The ledger, the knowledge base and all other stored state come through it. They are backed up before anything is replaced and checked after.
- When it is done, every agent continues what it was doing before, if anything. A restart ends an agent's background tasks, so before one, they are listed or the agent takes stock itself.
- It ends with a short summary: the migrations it ran, version bumps, config files changed, numbers such as how many prompts changed, and where the backups are.
- It is tried for real in the macOS VM, with unit and integration tests. How restarts work exactly gets settled by testing there.
Goals, non-goals and anti-goals¶
The goal is bosun update (with --dry-run) doing the above. get.sh on an existing install hands
over to it.
Not in the first version:
- A command to go back to an older version. A failed update puts everything back itself. Going back later is the manual path decided in sync.md, which the summary points to.
- Releases, tags or channels. It updates to the head of the branch the install tracks (
main, orBOSUN_BRANCH), asget.shdoes today. Each data format carries its own version, so migrations do not depend on release numbers. - Updating Claude Code or Codex themselves.
bosun refreshalready restarts idle sessions on a new agent version. - More than one Mac. That comes with sync phase 1.
Any of these makes the update a failure, even if it otherwise works:
- an event, a write to the ledger or the knowledge base, a hook's record or a running task lost because of the update;
- a busy agent interrupted, or an agent left stopped;
- an install left half old and half new, or a command that starts mid-update seeing a mix;
- data changed before a backup exists, or a backup that cannot be restored;
- a person deleted on request coming back from a backup;
- anything of mine in the config folder (
bosun.env,sessions.yml,policy.md,tasks/) changed without a copy from before and a line in the summary.
What exists to build on¶
bosun refreshrestarts a session only when it is idle, and only once it has been idle forBOSUN_RESTART_MIN_IDLE_SECONDS; it leaves busy ones alone. It resumes the same conversation. It sendsBOSUN_DRAIN_PROMPT(finish whatever was cut off, or else reply with a one-line status), but only to the orchestrator, to ashellwindow, and to a window that holds unfinished handed-over work.- Signals (signals.md): the Stop hook records the background tasks a turn left
running. A window whose only work left is background work shows as
shell. - The check-in loop interrupts the orchestrator only when that is safe. A check-in it holds back stays due.
- Catching up after a pause. The Slack watcher lists activity since its last scan, and the events queue is files in the state folder (events.md). So a run that is skipped catches up on the next one. This is to be checked in the VM.
- The store locks.
locked_ledgerand the knowledge base'slockedwait on anflockwith no timeout, so a writer waits rather than fails while the lock is held. - The file tables.
STATE_FILESandCONFIG_FILESinlib/uninstall_plan.pylist every file bosun keeps, and class each as working, ledger, knowledge or settings. The smoke suite fails on a state file the tables do not list, so they can serve as the backup's list too. - What
initandinstall.shalready do.initkeeps a*.pre-init-*copy of each config file it writes.install.shlinksbosuninto~/.local/bin, then renders the launchd jobs (filling in the install path,__BOSUN_ROOT__) and reloads them. -
Format versions before this work:
- the journal envelope's
v(change_log.ENVELOPE_VERSION, 1); ledger.json'sversion(1);RULES_VERSIONandKG_RULES_VERSION, each pinned by a digest test.
The knowledge base's files had no format version (Part A gave it one, in
knowledge/format.json). - the journal envelope's
- Rolling out by hand (
CLAUDE.md, "Rolling a change out to running sessions"). The orchestrator readspolicy.mdonce per conversation; to pick up a change it is sent "re-read". Tasks already handed to repo sessions keep their text (delegate --update).
Shape¶
Versions side by side, switched in one step¶
Each version gets its own folder, ~/.local/share/bosun-versions/<commit>/ (named for the first 12
characters of its commit): a git worktree of one repository there, bosun.git. ~/.local/share/bosun
becomes a link to the current one. Every path already written into launchd jobs, hooks and the
~/.local/bin/bosun link stays the same and goes through that link.
The switch replaces the link in one rename, so a command sees either the old version or the new one,
never a mix. A version's folder is never changed once in use, so whatever runs from it finishes on
the code it started with. The previous version's folder stays until the next update; older ones are
deleted, unless one holds something of yours (a change in its files, a commit made in it), which the
summary says. One a running process still uses (it has the folder open, or names it in its
command line, as a long-running bosun console does) is left to a later update, and with no lsof or
ps to tell, every one stays.
Two paths, kept apart. A process loads its code from the version folder it started in. Bash and
Python alike resolve that folder once, at the start, so a command that runs across the switch stays
on one version. Anything that outlives the process gets the install link written into it instead:
hook commands, launch commands, the launchd plists and the ~/.local/bin/bosun link. Before Part A,
three places resolved all the way to the real folder and wrote that path out:
bin/bosun, withreadlink -f;lib/init.py, which runsinstall.shfromPath(__file__).resolve();install.sh, which fills in__BOSUN_ROOT__and makes thebosunlink from its own location.
Each still finds its own folder that way, to run from it, and what it writes out is the link's path
now (lib/install_paths.py, and BOSUN_INSTALL / BOSUN_CMD in lib/common.sh).
A session keeps its launch command and hook commands until it restarts. So each hook entry point has to accept the command lines the previous version wrote. A test runs the previous version's launch settings against the new hooks. Where an update cannot keep this, the plan marks those windows for a restart.
Old code that writes after the switch¶
The freeze makes writers wait, but it does not keep old code out. Some writers load the old code before the switch and write after it:
- a
bosun ledgercommand an agent started just before the switch; - the detached child of the UserPromptSubmit hook, and of Codex's
codex-turn-endhook; - the approval hooks;
- a running
bosun console, which saves review decisions.
So every write checks the store before it changes it, once it holds the lock
(lib/update_gate.py). It checks two things, in this order: an update marker saying a freeze is in
progress, before it reads anything of the store, since a store the freeze changed part way may not
even read as one; then the store's format version, before the store's shape, which a newer format
may change. If a freeze is on, or the store's format is not the writer's, nothing is written:
- a command or a hook's child runs itself again through the install link, with the same arguments
and the same stdin, so an agent's call just works on the new version. It does so once: the run
again carries
BOSUN_RERUN=1, and a refusal there is said in one line, never run again; - a freeze whose update still runs is waited out for a few seconds, then the same. If that update died meanwhile, the freeze is put back first (below), so the run again can write;
- a store older than the writer's code (a newer checkout run on data not yet migrated) is refused in
one line: run
bosun updateto migrate it; - the console refuses with a message to restart it, and the plan lists a running console;
- bosun's own background writers handle a refusal too.
bosun review-watchruns itself again like a command, since what it records it records once.bosun learnfails the run, or the apply, in one line and leaves it to the next one. It checks before it proposes or applies anything, so a refusal normally changes nothing; a freeze that starts in between can still refuse a later write, and the next run picks it up; - the
checkinwindow's loop is bosun's own, a long-running script that keeps the code it started with, so the update's release restarts it, every time.
With that check in place, a migration may rewrite the files derived from a journal. The journals themselves still keep what was recorded: old envelopes are read as new ones. A version without the check gets no protection from it, which is why the first update converts at a quiet moment (below).
The steps¶
-
Plan (changes nothing). Fetch the tracked branch and check out the new version's folder. The new version's own code compares the two folders and lists:
- the commits;
- each store's format before and after, and the migrations in between;
- the rules versions;
- config keys added, renamed or removed;
- the prompts that changed:
policy.md,chores.md, the task files,learn.mdandslack-watch.md, counted and named; - which windows need a restart and which only need to re-read something (see below);
- which launchd jobs need reloading;
- what the new version needs that is missing (its
lib/preflight.sh); - a
kg forgetcut short, which stops the update until it is finished (see Backups).
As built, these stop it at the plan too: a store whose format cannot be read, a link among what a backup copies that leads nowhere, a launchd job the new version cannot render, versions that are not side by side, and windows the last update still brings along (below). It also lists a running console, the handed tasks that keep their old text, and what to change by hand in my own files.
It shows the plan and asks first, as
initanduninstalldo.--yesskips the question;--dry-run, or no terminal to ask in, stops here. -
Rehearse. Copy what a backup holds (below) into
update/rehearsalin the state folder (APFS clones,cp -c). Run the new version's migrations and checks on the copy. If they fail, stop: nothing has changed, and it says why in one line. - Hold bosun's own background work. The check-in loop, the watchdog, learning, the Slack watcher, the events dispatcher and the morning job each check for an update hold before they start a run. While it is set, they skip the run, log that, and catch up on their next one; the morning job instead waits for the hold to lift, as its next run would be the next morning. The agents keep working.
-
Freeze, briefly. Take both stores' write locks, the ledger's and then the knowledge base's. Anything that writes to them waits (the other working files a backup holds, such as the events queue, are not locked, and a failed freeze does not put them back: an event queued meanwhile is never undone). While the locks are held:
- set the update marker, recording each step as it goes;
- back up;
- migrate;
- switch the link;
- run each store's own check (
ledger validate,kg validate) on the real data; - clear the marker.
If any of these fails, restore the backup, switch the link back, release the locks, and report what failed. 5. Release. Reload the launchd jobs whose plist changed, restart the check-in loop while the hold is still on (it stops at once, even mid-tick, and the hold keeps a tick from starting meanwhile), lift the hold, delete the old backups and version folders, and run the new version's
bosun doctor. A finding at this point is reported, not rolled back, since writes may have landed since the freeze. 6. Bring the agents along (below). 7. Summarize (below).
The freeze has to be shorter than the timeouts of the hooks that wait on these locks. Claude Code kills a hook that runs longer:
- the approval hooks write to the ledger: PreToolUse and PermissionRequest have 30 s, PostToolUse and PostToolUseFailure 10 s;
- the SessionStart hook after a compaction reads the ledger under a shared lock, which the freeze's exclusive lock blocks, and has 15 s.
The UserPromptSubmit hook detaches its child, which simply waits out the freeze. The signal hooks take no lock. So the freeze must stay under 10 s. It gets measured in the VM on a year of data. If it cannot stay under 10 s, the post-call approval hooks must save their record to disk for the next write to take in. Measure before building that.
Backups¶
- What. Everything the file tables class as ledger, knowledge or settings, plus the working files
that carry work in flight: the events queue, signals, the Slack watcher's state,
login.json,codex-sessions.jsonandagent-changes.json. (replica.jsonis already classed as ledger.) Logs, dry runs and earlier backups are left out. - Where.
~/.local/state/bosun/backups/<time>-<from>-<to>/, made with APFS clones so they cost little. Each has a manifest listing both versions, every file with its digest, and the format versions. The last three are kept. The backups folder has a row inSTATE_FILES, of a class of its own (BACKUPS):bosun uninstalldeletes it with either store, as each backup holds both. - Restoring. The update itself restores from a backup when its checks fail. In the first
version, restoring later is by hand; the summary and
docs/setup.mdsay how. - A forget reaches them.
kg forgetpromises that nothing of the person is left, but a backup holds the whole knowledge base as it was,forgotten.jsonl, the journal andsecret.keyincluded. Restoring one from before the forget would bring the person back with no trace of the forget, which the adopt check in the knowledge guide cannot catch, since that check reads the very records the restore replaces. So every deletion of a person deletes every update backup there is, whole, and says so in its line ("deleted 2 update backups taken before this; going back past it is no longer possible"), without naming the person. Every backup there is was taken before the deletion, as the freeze holds the knowledge lock while it backs up.- Every deletion means each one that ends in
forget_in_journal(lib/knowledge.py):kg forget,kg expirewhen it deletes a person fact, and a person removed outside the journal whom a forgotten record matches. Deleting the backups is a step of that last step, so a deletion cut short is finished by the next write, backups included. The backups' own module does the deleting; the knowledge code only calls it. - The rehearsal copy is deleted when the rehearsal ends, so it never outlives the update.
- A
kg forgetcut short holds the person in the files until the next knowledge write. The plan checks for one, and the update stops there and says so (anykgwrite finishes it), so no backup or rehearsal copy is taken of it. - When the ledger gets a forget (open for phase 1 in sync.md), it does the same.
- Every deletion means each one that ends in
Migrations¶
- Each store gets a format version where it has none (the knowledge base). A test per store pins a digest of its format to its version, the way the rules digests are pinned, so a format change cannot land without a version bump and a migration.
- One registry,
lib/migrations.py, lists each migration in order: its store, its from and to versions, the function that does it, and the line it adds to the summary. The same registry takes the rules changes that alter what earlier changes mean (sync.md). - Each migration:
- runs on the rehearsal copy first;
- can run twice with the same result;
- is tested from a fixture in the old format;
- leaves the store passing its own check.
- Journals are append-only. A migration reads old envelopes as new ones (upcasting) rather than
rewriting what was recorded, and rebuilds the files derived from the journal. So whatever a store
rebuilds from its journal is in the code's format (a
ledger.jsonmade from the rebuild records it). - Config. A new key needs nothing written, since the defaults in
lib/common.shapply. A key that is renamed or removed is edited inbosun.env, keeping abosun.env.pre-update-<time>copy (YYYYmmdd-HHMMSS, in UTC). The update never edits my own files:sessions.yml,setup.sh,policy.md,learn.md,tasks/andworkstreams.json. If their format has to change, the summary says what to change andbosun doctorpoints to it.
Bringing the agents along¶
Each window gets one of three treatments, decided from the plan and the window's signals:
- Nothing it was launched with changed. It is left alone. This is the case for most updates. The next bosun command or hook it runs uses the new version.
- Only the orchestrator's prompts changed (
policy.md, the task files). Once it is idle, it gets the update prompt, which asks it to re-read those files. Tasks already handed to repo sessions keep their text, as today; the summary lists them, so I can senddelegate --updateif a rule matters: each item handed a task type whose file changed (delegate review,fixhands both; a type the site replaces in its owntasks/is the site's text, which an update never changes), and every item handed out whenorchestrator/tasks/_common.mdchanged, as every task gets it (a site's_common.mdonly adds to it).chores.mdis the orchestrator's plugin. Whether a running session picks up a changed command file is still to be checked in the VM; until then, a change to it counts as a launch input. -
Something it was launched with changed: its settings, its hooks, the launch command or the plugin. It is restarted on the new version through refresh's path, and what happens depends on what it is doing:
- idle, nothing running: restart it, resume the conversation, and send the update prompt;
shell, only background work running: a restart would end that work, so first ask it to take stock. The message says that bosun will restart it for an update, asks it to list its background work and what it would do when each part finishes, and to stop and wait. Keep the reply and the signals' list, restart it, and pass the list back in the update prompt so it starts that work again;- busy: wait until it is idle. The update keeps waiting and shows which windows it is waiting
for. Ctrl-C leaves them to the check-in loop, which restarts each one once it is idle. For that,
the update's record keeps the windows still waited for and what was done to each, and
bosun launchrecords what each window was launched with (below); - waiting at a permission prompt or a question: never restarted until it is answered;
- someone typing in it (within
BOSUN_SEND_TYPING_MINUTES, or unsent text in its input box): left until they are done, assendwaits.
A window whose next launch takes up another conversation than the one it runs starts that one as it would have, never with "continue what you were doing" in a blank conversation: the orchestrator still on yesterday's starts today's with the morning chores, as on a new day, and a repo window with a fresh conversation pending starts it with the fresh conversation's prompt.
What a window was launched with. bosun launch records it, per tmux session and window, in
launches/<session>/<window>.json in the state folder (lib/launch_record.py): bosun's commit, the
agent and the model and effort it runs with, the window's directory and any --model / --effort it
was given, and a fingerprint in two parts:
- the agent's arguments, built for a placeholder conversation name, with the policy's text left out: a new day's or a fresh conversation's name is no launch input, and the policy is re-read, not restarted for;
- the content of the files under each argument that is a path inside the install (the plugin, a settings file), read through the install link, as the agent reads them.
bosun launch --fingerprint <window> gives the fingerprint a launch would record now, and bosun
refresh --update-restart restarts a window through refresh's path. Both start from the record:
- every window keeps the agent it was launched on. A restart onto another agent would land in a blank
conversation mid-work, so if
harness:insessions.ymlorBOSUN_HARNESSchanged since, the update restarts it on the recorded agent (launch --harness), with the model and effort it ran with, since the configured ones are meant for the other agent, and the change does not count as one in its fingerprint. The new agent takes over at the next restart that is not for an update (refresh,start); - a window outside the window list (opened by
send --new) keeps the directory and the--model/--effortits launch recorded, so it carries on as it was started. The two fingerprints are compared after the switch, in this step, when the link leads to the new version's files. Before the switch it still leads to the old ones, so for the plan's list of the windows that need a restart the new version'slaunch --fingerprintreads the files under the install from its own folder.
The update prompt says bosun was updated from one version to the other, and whether the session was restarted or kept running. It asks the session to check for anything missed from just before:
- for the orchestrator, a check-in, covering the events queued during the hold and Slack since its last scan;
- for a repo session, its item in the ledger and any replies.
Then it continues what it was doing, including any background work it listed. The exact wording gets settled with the real Claude and Codex in the VM, and with free-text test cases.
The update is done when every window that needed something has answered with its status. bosun
update ends there, with the summary.
The summary¶
The update prints the summary when it ends and saves it as summary.md next to the backup; --json
prints it as JSON instead (as summary in its one object, beside the plan and the record). For
example (fictional windows):
bosun updated: 95545b0 -> c41b186 (23 commits) in 2m 41s
Data
ledger format 1 -> 2 (412 items migrated); rules 1 -> 2
knowledge format 1, unchanged
Config
bosun.env BOSUN_OLD_KEY is now BOSUN_NEW_KEY (before: bosun.env.pre-update-20261104-153207)
3 new keys, at their defaults (bosun doctor lists them)
Prompts changed: 4 (learn.md, policy.md, tasks/_common.md, tasks/review.md)
Jobs reloaded: watchdog
Agents
orchestrator asked to re-read policy.md, tasks/_common.md, tasks/review.md; checked in (2 events from the update)
api restarted; resumed: waiting on CI for PR 12
web kept running (nothing it was started with changed)
Backup: ~/.local/state/bosun/backups/20261104-153207-95545b0-c41b186 (3.1 MB)
Going back: docs/setup.md#going-back
Settled while building it:
- The same however it ends. Each step keeps in the update's record what the summary needs as it
learns it: the plan said yes to when it starts, each migration's line and what changed in
bosun.envat the switch, the jobs reloaded in the release. So an update carried on by the nextbosun update, or finished by the check-in loop, says what one that ran in one go would; a record an older bosun wrote, which kept none of it, says less, never something false. - When. It is said when the update ends, also when Ctrl-C stops the wait (the windows still
waited for say so), and saved as
summary.mdonce the update is done, by whichever run finishes it: the update itself, the nextbosun update, or the check-in loop's last turn. - Each window's line is what was done to it, as the record keeps it (the orchestrator's re-read
naming the files it was asked to re-read), then its own answer; a window left alone says it kept
running, and the
checkinwindow is not listed. The prompts are named in the plan's order. - What else it can say, each only when there is something: what to change by hand in my own
files, under Config; the tasks handed out that keep their old text, with the
bosun delegate --updatefor each; and, under "Found after the switch", what the update itself found (a job not reloaded, the check-in loop not restarted) and eachbosun doctorFAIL, with its detail, that a doctor run while planning (before the hold, its FAIL lines kept in the record) did not show. Those it did show are counted ("2 already failing before the update"), and so are doctor's warnings ("3 warnings: run bosun doctor"): they are no news of the update. A backup that akg forgethas deleted since says so instead of its path ("deleted by a kg forget since; going back past it is no longer possible"); a forget deletes a backup whole even when a summary is being saved into it. A failed update names the backup it took. The backup's size is that of its files. bosun doctorlists what the last update left to me, from its record: the changes to make in my own files, and the keys it added thatbosun.envdoes not set, with the value each has now.
get.sh on an existing install¶
The bosun already installed may not have update yet. get.sh is always fetched fresh from the
branch, so on an existing install it gets the new version into its own folder and runs that version's
bosun update. A first install uses the new layout from the start.
The first update converts, at a quiet moment. The version it converts from may have none of the following (a checkout from before Part A has none; one of Part A has the hold, the write check and a check-in loop that honours the hold, but records no launches):
- the hold;
- the write check;
- a
refreshthat knows bosun's version; - a check-in loop that honours the hold.
So the conversion does not try to meet the mid-afternoon bar; nobody else runs bosun yet. It waits
until every window is idle with nothing running and nobody has typed for a while. Then it stops
bosun's own jobs and swaps the checkout for the link in one step: macOS's renamex_np with
RENAME_SWAP, so the path never goes missing. Finally it restarts the checkin window and brings the
agents along as below. The VM is to test this conversion on its own, and the mid-afternoon update
between two versions that both have update (not run yet).
Settled while building it:
get.shlooks at the install path first, and refuses what it does not know. On an installinithas set up, it fetches the branch intobosun.git(cloned bare beside a checkout first), adds the new version's folder and runs that version'sbosun update --fromthe version in use. An installinitnever finished getsinitagain, as it is, since onlybosun updateswitches versions. The cases: init.md.- Which update converts is decided by the version updated from, once an update cut short is carried
on: a checkout from before versions, at the install path or kept as
legacy-*(where a conversion put back leads the link).convertrefuses one with anything of yours in it, asbosun uninstallchecks a checkout, since the update after it deletes the checkout. - The quiet moment: every window's agent idle, or none running (the
checkinwindow aside, whose loop it stops), and nobody typing in a client of the session withinBOSUN_ACTIVE_CLIENT_MINUTES. It waits once you said yes, saying what it waits for each time that changes, and plans again when it is quiet. Ctrl-C there changes nothing. The doctor run and the rehearsal take a while after it, and the old check-in loop could prompt a window meanwhile, so once bosun's own work is stopped it checks again, and waits again if it has to (with the loop stopped, that settles soon), before anything is frozen. That wait lasts two minutes at the most (QUIET_AFTER_STOP_SECONDS), as a busy window would otherwise keep bosun's own work off for as long as its turn runs: past it, it says so, starts that work again, waits for a quiet moment with everything running, and plans again, as after the first wait. - bosun's own work is stopped after the rehearsal, where the hold begins: the watchdog's and
learning's launchd jobs unloaded, and the check-in loop's process ended. The morning job is left, as it
runs once a morning. Before anything stops, a mark of what runs (the jobs loaded, and whether the loop)
goes into a file of its own,
update/own-work.jsonin the state folder, not intoupdate.json: an older version's write gate, putting a freeze back, rewrites the record with only the fields it knows. However the conversion ends short of the switch, a put back included, the work the mark names starts again as it was, once, and the mark goes: each job loaded from its plist as it is on disk (start_jobs: nothing rendered, so the old code runs under the plists it had, and the morning job, or one you unloaded, is left alone), and the loop restarted by the new version'srefresh --checkin-loop, whose pane runs the install path'sbosun, which leads to the old code again by then (the checkout, orlegacy-*after the swap); a loop that runs by then (one never stopped, or started since bybosun startor the morning job) is left to its tick, as a restart would stop it mid-tick. After a crash, the next update does it, once it has put the freeze back, whoever wrote the record since, and also with no record at all (moved aside, or deleted). That next update comes from running the install line again, as the bosun installed then is the old checkout, which has nobosun update; what the update says on a failure or a Ctrl-C names the install line wherever the installed bosun is a checkout from before versions. Past the switch, the release reloads the jobs and restarts the loop, and removes the mark. - The swap is one call: the link is made at the legacy folder's name and exchanged with the checkout
(Linux's
renameat2withRENAME_EXCHANGEdoes the same), so the checkout is never under another name, nor the install path missing. A link a swap cut short left there is replaced. - From the switch on, the record names the legacy folder as the version updated from, in the record
write of
switched, so a put back leads the link there; before the swap, a checkout is never made a link. The update after the conversion is an ordinary one, from a version folder. Its prune deletes the legacy checkout unless something of yours is in it, or the installer did not make it, and an older version unless a change or a commit of its own is in it (lib/checkout_work.py, shared with uninstall), which its summary says. The record names the legacy folder once, and the swap puts the checkout there. - Temporary links that a switch cut short leaves beside the install (
.bosun.link-*, links only) are swept with the prune, once older than the update that sweeps them.
Cut short¶
- Each step records where it is, in the update marker in the state folder (a row in
STATE_FILES). An update can be cut short by a power cut, Ctrl-C or a crash; what happens next depends on where it stopped:- before the freeze: nothing changed but the hold, which is lifted;
- inside the freeze: the backup is the truth. A dead process releases its locks, but the writers
still find the marker and write nothing. So the next bosun command that would write finds the
update's process gone, and restores the backup and the link itself, under the locks. Nothing
has been written since the backup, so nothing is lost.
bosun updatedoes the same if it runs first; - after the checks passed: the update rolls forward. The next
bosun updatelifts the hold and brings the agents along.
- The hold lapses at once when its update has died, as it counts only while the update holds its
lock, and after an hour in any case, so an update that died never stops the check-in loop. The
watchdog and
bosun doctorreport an update that stopped inside its hold. - One update at a time: a second one is refused while the first runs.
- The record only grows. Old code reads a newer version's record: a hook or a command started
before the switch, the console. So its steps are only ever added, never renamed or removed, and a
new field is optional; a field old code does not know is ignored. A step it does not know is a
newer bosun's: the write is refused and runs again through the install link, whose code knows it,
and the hold counts as on. So a new step is recorded only inside the freeze or after the switch;
progress before the switch goes in a field, or the installed old code would refuse every write. A record that is there but cannot be read refuses every write in one
line, since it may record a freeze:
bosun updatecomes first (it resumes an update in progress and puts back one cut short), and the file is moved aside only ifbosun updatecannot read it either.
Testing¶
- Unit tests:
- the plan, which is pure: two version folders in, the migrations, prompts changed and windows to restart out;
- the registry, with a fixture per migration;
- the format digests;
- the summary;
- the decision for each window, for every pairing of its state (idle, shell, busy, waiting) with what changed.
- Smoke tests, hermetic, with fake Claude windows. An update between two local commits with:
- an idle, a
shelland a busy window; - a hook and a ledger write arriving during the freeze: they wait, and nothing is lost;
- a migration that fails: everything is back as before;
- a
kill -9at each step: the next run finishes or undoes it; - a
kg forgetafter an update deletes its backup and says so; one cut short is finished, backups included, by the next write; and an update stops while one is cut short; - stand-ins for launchd.
- an idle, a
-
The VM (
tests/sandbox.sh mac work; not run onbosun updateyet). A working day on the previous version, thenbosun updateto this checkout mid-afternoon, while sessions are busy, a background wait is running and stand-in Slack events arrive during the hold. It checks that:- no event, write or record was lost;
- each window resumed, with its background work started again;
- the summary is right;
- the backup restores, and the previous version runs on it;
- after a person is forgotten, no backup or rehearsal copy holds their id.
It also measures the freeze on a year of data.
tests/sandbox.sh mac live claude|codextries the take-stock and update prompts with the real agents (about a dollar a run), with free-text cases for them intests/llm/.
Decided, my calls, and what is left to the builder¶
Decided by the user:
- backup before anything changes, a check after, and everything put back on failure;
- a migration with each format change;
- going back means installing the older version and restoring the backup, and is not supported to versions older than today;
- nothing lost during the update, and the agents check for anything they missed;
- each agent continues what it was doing, its background work included;
- a
kg forgetdeletes the update backups taken before it, so it leaves nothing, at the cost of going back past it; - the summary's contents;
- tried for real in the VM.
My calls, which the user can overturn:
- versions side by side, with
~/.local/share/bosunas the link that switches; - each process keeps the version it started on, and only the link is written into anything that outlives the process;
- every write checks the store's format and the update marker, and on a newer store it runs itself again on the new version;
- after a crash inside the freeze, the backup is the truth, and the first writer restores it;
- the first update, which converts the layout, waits for a quiet moment;
- a hold on bosun's own background work, plus a short freeze under the stores' locks;
- agents left alone unless something they were launched with changed;
- the update waits for busy windows, and Ctrl-C hands them to the check-in loop;
- a window's launch inputs are its arguments (the conversation's name and the policy's text left out) and the files under the install they name, read through the link; the commit, the agent, the model and effort, the directory and the overrides are recorded with them, and they are compared after the switch;
- an update restarts a window on the agent it was launched on; a changed agent takes over at the next restart not for an update;
- three backups kept, in the state folder;
- a forget deletes each backup whole, not only its knowledge part, since a backup is restored as one moment;
- every deletion of a person does it (
kg expireand a removal outside the journal too), at the deletion's last step, so a deletion cut short finishes it; - the update stops while a
kg forgetis cut short; - restoring done by the update itself only, with no
--restorecommand in the first version; - updating to the head of the tracked branch;
- the summary also saved next to the backup.
Decided while building it:
- The hold is the record's step: from
holdingup toreleased, and only while the update holdsupdate.lockand within the hour. bosun's own background work asks before each run and skips it; a run already going finishes, its writes waiting on the freeze like any other's. - The update's record,
update.json: the update's id, both versions' folders and commits, its step, when it started, when its hold ends, the backup and what of it a put-back restores, the windows still to bring along, the changes to my own files, how long writes were paused (freeze_ms, from the stores' locks taken to their release), the error, and the findings after the switch (bosun doctor's, and a job that could not be reloaded); and for the summary, the plan, the migrations that ran, what changed inbosun.env, the jobs reloaded and whatbosun doctorfailed at while planning. Each step is recorded before it starts, and the backup once it is taken, so no step pastbacked-upis recorded without it. - The launchd jobs: a job is reloaded only where its rendered plist differs or it is not loaded, and each rendered plist is checked before the job is touched. One that cannot be rendered, or renders invalid, blocks the plan, and in the release it is a finding while the installed job stays loaded.
- The plan before the switch asks the new version: its
launch --fingerprintreads the files under the install from its own folder. One of bosun's windows with no launch record counts as changed. - A window bosun did not start: one
bosun sessionslists outside the window list (extra) with no launch recorded, as a shell or a Claude opened by hand in bosun's tmux session, is left as it is, whatever it does, and the plan says so ("not started by bosun: left as it is", the summary "left as it is (bosun did not start it)"). Restarting one would replace your terminal, or your own Claude, with a bosun launch in a conversation it never had.refresh --update-restartrefuses one, aslaunch --fingerprintdoes. A windowsend --newopened has a launch recorded, and is compared as bosun's; in a conversion, one an older version opened has none, so it is left running too, which is safe: its hooks take the older command lines. - The rehearsal's copy is taken under both stores' shared locks, so it is whole; writes wait only for the clones.
- A store's check after the migrations fails the update on an error (writes refused, or a rebuild that would lose data); a warning is no reason to put the data back.
- Bringing the agents along is the record's step
agents, afterreleased:lib/update_agents.pydecides, over the windows' statuses and an Actions interface, its only file the update's record (each thing done recorded as it is done);lib/update_windows.pycarries it out (bosun's commands, the wait and Ctrl-C), andlib/update_notes.pykeeps the notes. Every window the plan gives something to do is pending (not those left alone, and not thecheckinwindow, whose loop the release restarted), with the kind the plan lists for it (never guessed from a later listing), until it answers withbosun update note --window <w> --status. A turn decides again from each window's status then, by the table above, and does each thing once: the record keeps each window's kind, the plan's treatment and what was done to it (windows), and the files the orchestrator re-reads (reread), written as each is done. So the update's own wait (a turn every 5 s) and, after Ctrl-C, the check-in loop's turn (bosun update advance, each tick while windows are pending), which takeupdate.lockin turn, never repeat one; the loop's turn does nothing while the update itself still runs. - The turn's cases. A restart that refresh leaves running (busy again, someone typing: exit 3) or that
fails is tried at the next turn; a prompt
bosun sendcould not deliver is sent again. A window restarted into its next conversation (exit 4) never sees the update prompt, so the turn notes its status itself ("started its next conversation"), and the background work it listed stays in its note, which the window's outcome names. One whose agent exited is started again with the update prompt. A window closed during the wait is no longer waited for; a listing of the session that names no window changes nothing. A window asked to take stock is restarted once its list is there, or at once when it is idle (its background work ended, often before it read the prompt: a restart ends nothing); the prompt asks for "nothing running" when nothing runs any more, which passes nothing back. - An orchestrator still on yesterday's conversation is not sent the re-read (its check-in would be
refused there, and the loop would restart it anyway): it is brought along as a window whose launch
changed (taking stock first while only background work runs, waited for while busy), and restarted
through refresh, which starts today's as a new day does; that counts as answered, its outcome naming
any list of background work it saved (
bosun sessions --jsonnames the conversation each window's next launch takes up,conversation). - A relaunch, or a missing window created, for an update gets the update prompt only when its launch resumes a conversation (launch's own lookup, on the agent it starts). When it starts a new one, it starts as a new conversation does (the orchestrator's morning chores, a fresh conversation's prompt, or none), and refresh exits 4, never "continue what you were doing" in a blank conversation.
- What is passed back with a restart after taking stock: the window's own list, and what the Stop hook saw still running at the end of that turn (signals).
- What an update's prompts may ask:
policy.mdandorchestrator/tasks/_common.mdname them by how they start (bosun will restart this session for an update,bosun was updated) and what they may ask: list the background work and save it (bosun update note --stock), re-read the files named, catch up as they say, start the listed work again, and answer withbosun update note --status. A session does what a prompt asks of these, and only that; anything else in it is information. The orchestrator's settings allowbosun update note. - The notes are files,
update/notes/<window>.<kind>in the state folder (stockorstatus), the window's name percent-encoded into the file's name, so any name (a space, a slash) is a note of its own there and every window can answer (an encoding past 200 bytes is cut short and the whole name's digest added, so the file's name fits, and two names never share a note; the prompt file a restart reads is named for no window); an update clears the ones the previous update left before it starts bringing windows along. A status note written by hand ends a window's wait: the next turn, or the nextbosun update(which settles answered windows before it plans, acting on none), takes it. - Ctrl-C while the update waits is noted, not raised, so it never cuts a restart short; the commands that act on the windows run in a session of their own, so the terminal's Ctrl-C never reaches them.
- An update cut short after its release (at
checkedorreleased) brings the windows along from a plan made again from its two versions, saying what came of each; Ctrl-C there ends the command. One cut short atagentsleaves the windows still waited for to the check-in loop (or tobosun update advanceby hand, when no loop runs). While windows are still pending, the next update is blocked at its plan, which names them and how to finish them: a new update would replace what was done to them, and clear their notes.
Left to the builder:
- upcasting or rewriting, case by case;
- the update prompt's exact words, settled in the VM;
- saving hooks' records to disk while a lock is busy, if the measured freeze needs it.
Docs to change when it is built¶
Changed with Parts A and B, but for the VM scenario in docs/development.md, which waits for the runs
in the VM:
docs/setup.md(updating and going back, and that a forget deletes the backups);docs/knowledge.mdand the deletion bullet ofdocs/design/knowledge.md(a deletion deletes the update backups);docs/commands.md;README.md;- the
get.shheader; - the file tables in
lib/uninstall_plan.py; docs/development.md(the VM scenario);CLAUDE.md: a new common change, "a data format change", which needs a version bump, a migration and a fixture.