-
Notifications
You must be signed in to change notification settings - Fork 4
Domains Lifecycle
The lifecycle domain owns the verbs that act on the install itself rather than on a session or a dispatch:
diagnose the install, upgrade it and migrate its versioned state, detect how it was installed, and tell
the operator when the running version is out of date. The public surface is re-exported from
src/domains/lifecycle/index.ts, which exposes the doctor core and formatters, the migration runner,
the state-metadata readers, the upgrade-notice text generator, and the version info.
runDoctor in src/domains/lifecycle/doctor.ts is the synchronous core. It returns an array of
DoctorFinding rows, one per named check, where each row carries an ok flag, a stable name, a
human-readable detail, and an optional level ("ok" | "info" | "warn" | "error"). The command-line
entry point is runDoctorCommand in src/cli/doctor.ts, registered under the name "doctor" in the
COMMAND_HANDLERS map of src/cli/index.ts.
The synchronous core checks, in order:
- The Clio version, Node version, and platform, from
getVersionInfo(). - The engine runtime, by asserting
piAgentCore,piAi, andpiTuiare all present. - The four XDG roots (config, data, state, cache) via
directoryFinding, which distinguishes a missing path (ENOENT), a path that is a file/symlink/FIFO/socket/device node, and a directory that is not readable/writable/traversable. The remedy differs by failure:--fixcreates a missing root but cannot move a file aside or widen a mode, so each row names the command that fits the failure. -
settings.yaml, validated directly through the loader's ownvalidateSettingsFile, so a parse error here matches the exact key paths and remedy the loader will refuse to start on. -
credentials.yaml, in one stable row covering missing, wrong mode, correct mode (0o600), and read error.--fixchmods this file to0o600. - State metadata, read through
readStateInfoResultand compared against the running version. A record whose version differs from the running one is reported stale with a pointer todoctor --fix.
Before the per-root checks, runDoctor short-circuits on an untouched home. isUninitializedHome
returns true only when none of the four roots exist and no install.json exists. On that home, with no
--fix, runDoctor returns a single "installation" row (level "warn") saying to run clio-coder
or clio-coder configure, and stops. This is the documented behavior that doctor as a first command
after npm install does not read as damage and exits 0.
runDoctor with --fix calls initializeClioHome() (from src/core/init.ts) to create the directory
tree and write settings.yaml and credentials.yaml if absent. A repair that throws is recorded in a
"repair" row and the run continues, so the command that explains the damage never prints nothing.
The network-bound and fleet sweeps are separate async functions that the CLI invokes on top of the core.
They are not part of runDoctor itself:
-
runDoctorRuntimeCheckswalkssettings.targetsforopenai-compatandanthropic-compatURLs and fingerprints any that respond as a known native server (LM Studio, Ollama), emitting a WARN row that advises converting the target to the native runtime. -
runDoctorModelCheckschecks every model pointersettings.yamlaims at a target with no static catalog against what the target advertises; it uses the live list when the target answers and falls back to thewireModelslistconfigurerecorded. It reports server settings that defeat prefix-cache reuse as their own WARN rows. -
runDoctorInteropChecksreports one row per detected external coding agent and an aggregate row for foreign skill roots; it reports and never proposes, and never writes. -
runDoctorFleetChecksprobes every configured fleet node over SSH and persists the verdicts to the durable preflight store that dispatch placement consults. A failing node is a WARN, never fatal. Plain doctor observes;--fixis the run that may record, because recording is what admits a node.
collectDoctorFindings in src/cli/doctor.ts composes the core with all the sweeps plus the CLI-local
findings (stateStorageFinding, toolchainFindings, hpcToolchainFindings, slurmMcpFindings,
panesFindings, namingHistoryFindings, validationContractFinding, taskWorktreeFindings, and the
--deep checks). formatDoctorReport renders each finding as a four-character badge (OK, INFO,
WARN, or !! ) padded, then the name padded to 22, then the detail folded onto one line by
foldDetail.
runUpgradeCommand in src/cli/upgrade.ts is the upgrade verb, registered as "upgrade" in
src/cli/index.ts. It detects the install method, consults the registry (for non-source installs),
replaces the package (for npm installs), and applies pending state migrations.
The sequence for an npm global install:
-
inspectInstallation()classifies the install (see below). -
lookUpAvailableVersionfetches the channel version from the registry. A source checkout never queries the registry; a--post-installrun reports the lookup as "not checked". -
runNpmInstallspawnsnpm install -g --prefix <prefix> @iowarp/clio-coder@<channel>vianpmInstallArgs, which pins the prefix so the upgrade lands in the install's own prefix, not npm's current default. The child's stdout is drained, becausenpm install -gwrites well past a 64 KB pipe buffer and a child whose stdout nobody reads blocks. -
runPostInstallUpgradere-launches the installed entry withupgrade --post-install --channel=<c>, so the new binary runs the post-install checks, not the old process. - In the
--post-installrun,runPendingapplies migrations andrunDoctorFixAfterInstallrunsdoctor --fixwith the installed entry.
For a source checkout, the upgrade never touches the registry or runs npm install. It prints
SOURCE_UPGRADE_LEAD advice (fetch tags, check out a release, pnpm run install:local), applies
migrations with runPending, then calls initializeClioHome() directly to refresh install.json, and
finishes with finish() which may relaunch via runRestart.
The --dry-run path reports the detected facts (method, current version, available version, state dir),
previews each pending migration by id, and returns 0 without changing anything. It distinguishes three
registry states: "not checked" (source checkout or post-install checks), "unknown (the registry could
not be reached)" (a lookup that was made and came back empty), and a concrete version. An "already
current" verdict is only claimed when a lookup was made and compared a version, or when no lookup was
owed.
runPending in src/domains/lifecycle/migrations/index.ts applies every registered migration the state
tree has not recorded yet. A migration is an object with a stable id of the form
YYYY-MM-DD-<slug> and an async up(stateDir). The registry is a static, ordered list compiled into
the bundle, so the runtime never scans the filesystem for migration files.
Applied migration ids are persisted to <stateDir>/migrations.json. runPending reads the manifest,
skips every id already present, and for each remaining migration invokes up(), adds the id, and
rewrites the manifest after each up() rather than once at the end. That rewrite-after-each is the
at-most-once guarantee: a throw from a later migration cannot discard the record of the earlier ones
that already succeeded, so they are not re-run against a tree they have already changed. Even when
nothing is pending, runPending rewrites the manifest, so a home whose file was missing or unparseable
gains a well-formed one. listMigrations returns the registry and readMigrationManifest returns the
{ applied: string[] } for a state directory.
The two registered migrations, in order, are:
-
2026-09-01-settings-v2(src/domains/lifecycle/migrations/2026-09-01-settings-v2.ts). This is the complete settings v1 → v2 rewrite. It moves 62 v1 keys to their v2 locations (orchestrator.target→chat.target,workers.default→fleet.default, etc.), drops retired keys, setsversion: 2, and validates the result against the strict schema. It holds the settings single-writer lock (withSettingsLock) around its read-rewrite-write and lands the write throughsafeResourceWritewith a.v1.bakbackup. It throwsSettingsV2CollisionErrorwhen a v1 source and its v2 destination both exist, leaving the original untouched. It is idempotent: a re-run over aversion: 2file returns without rewriting. -
2026-09-01-retire-panes-knobs(src/domains/lifecycle/migrations/2026-09-01-retire-panes-knobs.ts). It strips the retiredpanes.agentsandpanes.keepFailedkeys, which the v2 schema refuses by name. It is a no-op on a file that never named those keys and never rewrites a file it cannot parse.
The registry order is deliberately not the id order. settings-v2 owns the complete v1 rewrite,
including the already-retired pane keys, and must run before any later migration reaches the strict v2
reader. retire-panes-knobs remains registered for homes that recorded the v2 migration independently and
for manifest continuity. The manifest replays by id membership rather than by position, so a home that
already applied one is unaffected by where the other sits.
The module header documents three requirements for future migrations: a migration that writes
settings.yaml must hold the settings single-writer lock around its read-rewrite-write and land the
write through the atomic rename writer; migrations are authored against the shapes the code has on the
day they are needed, never against stale pre-release shapes; and a repair migration runs before any
migration that reads through readSettings, because that reader throws on the very document the repair
exists to fix.
inspectInstallation in src/domains/lifecycle/install-method.ts classifies an install without running
a package manager or consulting the network. It returns an Installation with kind, root, entry,
and prefix. The kinds are "source" | "npm" | "pnpm" | "bun" | "local" | "unknown".
The detection order is:
- Resolve the entry path (default
process.argv[1]) to a real path. A missing launcher still leaves the package root available for recovery advice. - If the root's
package.jsonname is not@iowarp/clio-coder, returnunknown. -
sourceCheckoutRootchecks whether the entry ends indist/cli/index.js, is not inside anode_modulesdirectory, and the root has either a.gitdirectory orsrc/cli/index.ts. If so, returnsource. - A path ending in
lib/node_modules/@iowarp/clio-coderisnpm, with the prefix being everything before that suffix. - A path containing
pnpm/global/<number>ispnpm. - A path containing
install/global/node_modulesisbun. - A path containing
node_modulesislocal. - Otherwise
unknown.
This is the prevention the domain exists to provide: a source-checkout install is never classified as
npm, so clio-coder upgrade never offers the npm-global reinstall path to a source checkout. The
published package may not exist, and npm install -g would escape the install's roots and touch the
global npm prefix.
npmInstallArgs returns ["install", "-g", "--prefix", prefix, "@iowarp/clio-coder@<channel>"] and
throws for any non-npm kind, because automatic upgrade requires an identified npm global installation.
installationCommand produces the update or uninstall command as a string for the install method:
npm with its prefix for npm, pnpm add/remove -g for pnpm, bun add/remove -g for bun, and for a
source checkout it prints the git fetch and checkout advice.
createUpdateCheck in src/domains/lifecycle/update-check.ts returns an object with probe and
claim. Construction does no I/O; the interactive owner starts probes after its first committed frame.
The cache path is <cacheDir>/update-<fingerprint>.json, where the fingerprint is the first 16 hex
characters of the SHA-256 of the install root. The intervals are UPDATE_CHECK_INTERVAL_MS (24 h) and
UPDATE_NOTICE_INTERVAL_MS (7 days).
probe reads the installed files afresh from disk. It compares the on-disk version to the running
version captured before hydration and flags a "replaced" notice when they differ or the entry's mtime is
newer than process start. Development trees, local/npx copies, and unknown layouts never generate
registry traffic. A "available" notice is only produced when the cached registry version is newer than
the running version and the install kind is npm, pnpm, or bun. Registry lookups are cached for 24 h and
share one request across simultaneous sessions through withStateFileLock.
claim stamps notifiedKey and notifiedAt in the cache under the same file lock, and refuses to
claim when the session is not idle, when an available-notice was already claimed within the check
interval, or when the same notice key was claimed within the notice interval. The interactive consumer
is startUpdateMonitor in src/interactive/update-monitor.ts, which ticks every second, probes on a
60 s cadence, and displays the notice text for 30 s while idle.
The version-semantics helpers are in src/domains/lifecycle/release-version.ts:
-
parseReleaseVersionaccepts strict SemVer and rejects leading zeros,vprefixes, and metadata that could become a shell argument. -
compareReleaseVersionsorders core versions numerically and prereleases by the SemVer rules, returningnullwhen either side fails to parse. -
fetchReleaseVersionfetches a channel from the npm registry with a 2.5 s timeout and returns the version string ornull.
describeUpgradeNotice in src/domains/lifecycle/upgrade-notice.ts renders the one line the first
interactive launch after an upgrade says. It reads KEYBOARD_FACING_CHANGES, a frozen map from version
to a keyboard-facing change summary. When the target version has an entry, the line names the change
and points to the CHANGELOG; otherwise it gives the generic "What changed is in CHANGELOG.md" line.
takeUpgradeNotice in src/domains/lifecycle/state.ts returns the version transition the operator has
not been told about yet, or null. initializeClioHome refreshes install.json silently on every
boot, so by the time anything can speak the record already says the current version; upgradedFrom
remembers where it came from. Claiming the notice stamps noticedVersion, so it is shown once per
version and never again. A record without upgradedFrom, or one already noticed, yields null and
writes nothing. The consumer is entry/orchestrator.ts, which calls takeUpgradeNotice() only when
interactive and pushes describeUpgradeNotice(upgrade) into initialNotices.
src/domains/lifecycle/state.ts reads and writes <stateDir>/install.json. readStateInfoResult
distinguishes a genuinely absent file (ENOENT, returns info: null, problem: null) from a present but
unreadable one (returns problem with the read error), because the two have different remedies.
readStateInfo returns only the info. ensureClioState calls initializeClioHome() and then reads,
throwing if the record was not written. takeUpgradeNotice is described above.
StateInfo carries version, installedAt (absent when the record was rebuilt over a state root whose
install time was gone), upgradedAt, repairedAt (when the record itself was rebuilt, never the same
claim as an install), upgradedFrom, noticedVersion, platform, and nodeVersion.
flowchart TD
A["clio-coder upgrade"] --> B["inspectInstallation()"]
B -->|kind=source| C["Print source-update advice"]
B -->|kind=npm| D["lookUpAvailableVersion()"]
B -->|kind=pnpm/bun/local/unknown| H["Print package-manager instructions"]
D --> E["runNpmInstall()"]
E --> F["runPostInstallUpgrade()"]
F -->|"new process"| G["runPending(stateDir)"]
G --> I["runDoctorFixAfterInstall()"]
C --> J["runPending(stateDir)"]
J --> K["initializeClioHome()"]
I --> L["finish()"]
J --> L
K --> L
-
Adding a migration. Author
YYYY-MM-DD-<slug>.tswith a default export matching theMigrationcontract, then register it inREGISTRYinsrc/domains/lifecycle/migrations/index.ts. The order matters: a settings-repair migration must precede any migration that reads throughreadSettings. The migration header in2026-09-01-settings-v2.tsdocuments the lock and atomic-write requirements. -
Adding a doctor row. Add the finding to the relevant
runDoctor*function. Synchronous rows go inrunDoctorinsrc/domains/lifecycle/doctor.ts; network-bound rows go in the async sweeps (runDoctorRuntimeChecks,runDoctorModelChecks,runDoctorInteropChecks,runDoctorFleetChecks), which are composed incollectDoctorFindingsinsrc/cli/doctor.ts. CLI-local findings are composed there too. -
Adding an install kind. Extend the
kindunion inInstallationand add a branch ininspectInstallation,npmInstallArgs(if it needs automatic upgrade), andinstallationCommand. -
Adding a keyboard-facing change note. Add an entry to
KEYBOARD_FACING_CHANGESinsrc/domains/lifecycle/upgrade-notice.ts. The footer classifies a plain string by its text, and"changed"is what makes it a sticky, dismissable notice rather than one that fades in twelve seconds.
-
tests/contracts/upgrade-command.test.tsdemonstrates the install-method detection and the upgrade flow. It builds an npm-layout fixture with a custom prefix, assertsinspectInstallationreturnskind: "npm"and the correct prefix, assertsnpmInstallArgsreturns the exact prefix-pinned args, assertsinstallationCommandquotes the prefix, and assertsnpmInstallArgsthrows for a pnpm kind. It verifies the upgrade runsnpm install -g --prefix <prefix>thenupgrade --post-install, and that--restartrelaunches with argv the real startup parser accepts. It also checks pnpm global vs project-local layout classification and that an older dist-tag never downgrades the installed package. -
tests/contracts/update-check.test.tsdemonstrates the update check. It asserts construction does no I/O, thatprobereturns an available notice, that a busy session does not consume a reminder, that simultaneous sessions share one registry request and one reminder, that a replaced install is detected without consulting the registry, and that development/local/unknown installs never check the registry. It also verifies the monitor waits for idle, hides during work, expires, and can be dismissed. -
tests/extended/settings-migration.test.tsdemonstrates the migration ordering and the settings v2 migration. It asserts the registry order is[settings-v2, retire-panes-knobs], that a v1 document migrates atomically with a.v1.bakbackup and is idempotent, that a v1/v2 collision is refused without replacing the file, and that a v2 file is untouched on re-run. -
tests/extended/upgrade-lifecycle.test.tsdemonstrates the upgrade lifecycle. It asserts the upgrade reports detected facts before doing anything, exits 0 when current with no pending migrations, does not claim to be current when the registry was asked and could not answer, previews every pending migration by id in dry run, applies pending migrations and reports the count, and reports a failed migration with the recovery command. -
tests/extended/package-manager-upgrade.test.tsdemonstrates the post-install path. It asserts a--post-install --dry-runpreviews local migrations without a registry lookup or package replacement.
-
Manifest rewrite after each
up(). Do not move thewriteManifestcall inrunPendingto after the loop. The per-migration rewrite is what preserves the at-most-once guarantee when a later migration throws. -
Registry order is not id order. The
REGISTRYarray order encodes the settings-repair-before-read constraint. Do not sort the registry by id; the manifest replays by id membership and a home that already applied one is unaffected by where the other sits, but the order matters for a home that has applied neither. -
Settings migrations must hold the lock and write atomically. A migration that writes
settings.yamlmust holdwithSettingsLockaround its read-rewrite-write and land the write throughsafeResourceWrite, so it never racesupdateSettingsand readers never see a partial file. -
Install-method classification must stay network-free.
inspectInstallationinspects layout only. Adding a registry lookup here would break the offline upgrade path and the update-check test that asserts no registry traffic for non-npm kinds. -
The upgrade notice is claimed exactly once per version.
takeUpgradeNoticestampsnoticedVersionand only returns a transition whenupgradedFromis present and differs from the current version. If you change the stamp or the return condition, the notice will either never show or repeat on every boot. -
Doctor must stay read-only without
--fix. The per-root checks inrunDoctorcreate nothing;initializeClioHomeis called only whenoptions.fixis true. Adding a side effect to the synchronous core breaks the "diagnose without creating files" contract and the exit-0 behavior on an untouched home. -
Drain the npm child's stdout.
runChildinsrc/cli/upgrade.tsdrains stdout and stderr becausenpm install -gwrites well past a 64 KB pipe buffer and a child whose stdout nobody reads blocks forever. Removing the drain reintroduces the silent hang. -
The upgrade notice word "changed" is load-bearing. The footer classifies a plain string by its
text, and
"changed"is what makes the upgrade notice sticky and dismissable rather than a transient message. Renaming the phrase indescribeUpgradeNoticeor inKEYBOARD_FACING_CHANGESvalues can change how the footer renders the notice.
Source and generation metadata
title: "Domains lifecycle"
summary: "Diagnose an install with `clio-coder doctor`, migrate versioned state through `runPending`, and detect the install method so an upgrade never offers npm-global reinstall to a source checkout."
sources:
- "src/domains/lifecycle/index.ts"
- "src/domains/lifecycle/doctor.ts"
- "src/domains/lifecycle/migrations/index.ts"
- "src/domains/lifecycle/migrations/2026-09-01-settings-v2.ts"
- "src/domains/lifecycle/migrations/2026-09-01-retire-panes-knobs.ts"
- "src/domains/lifecycle/install-method.ts"
- "src/domains/lifecycle/upgrade-notice.ts"
- "src/domains/lifecycle/update-check.ts"
- "src/domains/lifecycle/release-version.ts"
- "src/domains/lifecycle/state.ts"
- "src/cli/doctor.ts"
- "src/cli/upgrade.ts"
symbols:
- "runDoctor"
- "runDoctorFleetChecks"
- "runDoctorInteropChecks"
- "runDoctorRuntimeChecks"
- "runDoctorModelChecks"
- "formatDoctorReport"
- "isUninitializedHome"
- "inspectInstallation"
- "npmInstallArgs"
- "installationCommand"
- "runPending"
- "listMigrations"
- "readMigrationManifest"
- "createUpdateCheck"
- "parseReleaseVersion"
- "compareReleaseVersions"
- "fetchReleaseVersion"
- "describeUpgradeNotice"
- "takeUpgradeNotice"
- "readStateInfo"
- "ensureClioState"
tests:
- "tests/contracts/upgrade-command.test.ts"
- "tests/contracts/update-check.test.ts"
- "tests/extended/settings-migration.test.ts"
- "tests/extended/upgrade-lifecycle.test.ts"
- "tests/extended/package-manager-upgrade.test.ts"
invariants:
- "The migration manifest is rewritten after each `up()`, so a migration that already succeeded is never re-run."
- "A source-checkout install is never reported as `npm` kind, and `npmInstallArgs` throws for any non-npm kind."
- "An upgrade notice is claimed and shown once per version, via the `noticedVersion` stamp in `install.json`."
- "The settings v2 migration runs before any migration that reaches the strict v2 settings reader."
- "On a home Clio has never written to, `runDoctor` reports one row and returns without running the per-root checks."
validate:
- "pnpm run test:file -- tests/contracts/upgrade-command.test.ts"
- "pnpm run test:file -- tests/contracts/update-check.test.ts"
- "pnpm run test:file -- tests/extended/settings-migration.test.ts"
- "pnpm run test:file -- tests/extended/upgrade-lifecycle.test.ts"Clio Coder · Repository · Website · Documentation
Wiki v0.1 · Developing implementation reference · Source snapshot: 657dce13d. Authored architecture documents define the product contracts.
- Clio Coder GUI Client
- apps / clio-coder-gui
- Apps clio coder gui server
- Apps clio coder gui tests
- apps
- Architecture
- Command-line surfaces
- Core
- Domains agents
- Config Domain
- Context Domain
- Dispatch domain
- Domains evidence
- Domains extensions
- Domains gateway
- domains
- Domains interop
- Domains lifecycle
- Domains memory
- Middleware Domain
- Domains mux
- Domains observability
- Domains plugins
- Prompt Compiler
- Domains providers
- Domains quota
- Domains resources
- Domains safety
- Domains scheduling
- Domains session
- Vendored Tool Registry and Resolution
- Engine
- Engine acp
- Engine apis
- engine
- Entry point
- Interactive
- interactive
- Interactive overlays
- Interactive renderers
- clio-coder wiki
- Scripts
- Contract tests
- Tests extended
- tests
- Tools
- Tools data
- tools
- Tools verify
- Worker runtime