Literate AI

Repository documentation

Prefix-installed CLI self-update

Prefix-installed CLI self-update implementation plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: A prefix-installed litai launcher stages a newer GitHub Release wheel in the background and, on the next enrolled invocation, installs it into the same prefix and re-execs the original argv.

Architecture: Keep GitHub I/O and venv mutation in src/literate_ai/adapters/host_self_update.py. dispatch.main calls maybe_host_self_update(argv) before argparse. The installer writes literate-ai/host-install-manifest@2 and both launchers export LITAI_HOST_INSTALL=1 plus LITAI_PREFIX. Checkouts, CI, @1 manifests, and LITAI_NO_SELF_UPDATE=1 stay inert.

Tech Stack: Python 3.11+, urllib.request, packaging.version, existing private venv python -m pip, POSIX/Windows launchers, unittest.

Spec: ADR 0037

Global Constraints


File map


Task 1: Manifest schema @2, launcher env, enrollment

Files:

Interfaces:

# tests/critical/test_host_self_update.py
from __future__ import annotations

import json
import os
import sys
import tempfile
import unittest
from pathlib import Path

from literate_ai.adapters.host_self_update import resolve_host_self_update_enrollment
from literate_ai.adapters.user_paths import (
    HOST_INSTALL_MANIFEST_SCHEMA,
    HostInstallLayout,
)


def _write_manifest(layout: HostInstallLayout, *, schema: str, self_update: bool = True) -> None:
    Path(layout.environment).mkdir(parents=True, exist_ok=True)
    Path(layout.launcher).parent.mkdir(parents=True, exist_ok=True)
    Path(layout.launcher).write_text("launcher\n", encoding="utf-8")
    document = {
        "schema": schema,
        "prefix": str(layout.prefix),
        "environment": str(layout.environment),
        "launcher": str(layout.launcher),
        "self_update": self_update,
    }
    if schema.endswith("@1"):
        document.pop("self_update")
    Path(layout.manifest).write_text(
        json.dumps(document, sort_keys=True, separators=(",", ":")) + "\n",
        encoding="utf-8",
    )


class HostSelfUpdateEnrollmentTests(unittest.TestCase):
    def test_enrolls_only_when_flag_manifest_and_venv_python_match(self) -> None:
        with tempfile.TemporaryDirectory() as temporary:
            prefix = Path(temporary, "prefix").resolve()
            layout = HostInstallLayout.for_prefix(prefix)
            _write_manifest(layout, schema=HOST_INSTALL_MANIFEST_SCHEMA)
            python = Path(layout.environment) / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
            python.parent.mkdir(parents=True, exist_ok=True)
            python.write_text("python\n", encoding="utf-8")
            enrolled = resolve_host_self_update_enrollment(
                environ={
                    "LITAI_HOST_INSTALL": "1",
                    "LITAI_PREFIX": str(prefix),
                },
                executable=python,
            )
            self.assertIsNotNone(enrolled)
            self.assertEqual(enrolled.staging_root, Path(layout.application_root) / "self-update")

    def test_skips_v1_manifest_checkout_ci_and_opt_out(self) -> None:
        with tempfile.TemporaryDirectory() as temporary:
            prefix = Path(temporary, "prefix").resolve()
            layout = HostInstallLayout.for_prefix(prefix)
            _write_manifest(layout, schema="literate-ai/host-install-manifest@1")
            python = Path(layout.environment) / "bin/python"
            python.parent.mkdir(parents=True, exist_ok=True)
            python.write_text("python\n", encoding="utf-8")
            self.assertIsNone(
                resolve_host_self_update_enrollment(
                    environ={"LITAI_HOST_INSTALL": "1", "LITAI_PREFIX": str(prefix)},
                    executable=python,
                )
            )
            _write_manifest(layout, schema=HOST_INSTALL_MANIFEST_SCHEMA)
            self.assertIsNone(
                resolve_host_self_update_enrollment(
                    environ={
                        "LITAI_HOST_INSTALL": "1",
                        "LITAI_PREFIX": str(prefix),
                        "LITAI_NO_SELF_UPDATE": "1",
                    },
                    executable=python,
                )
            )
            self.assertIsNone(
                resolve_host_self_update_enrollment(
                    environ={
                        "LITAI_HOST_INSTALL": "1",
                        "LITAI_PREFIX": str(prefix),
                        "CI": "true",
                    },
                    executable=python,
                )
            )
            self.assertIsNone(
                resolve_host_self_update_enrollment(
                    environ={"LITAI_PREFIX": str(prefix)},
                    executable=python,
                )
            )
            self.assertIsNone(
                resolve_host_self_update_enrollment(
                    environ={"LITAI_HOST_INSTALL": "1", "LITAI_PREFIX": str(prefix)},
                    executable=Path(sys.executable),
                )
            )

Run: PYTHONPATH=src python3 -m unittest tests.critical.test_host_self_update -v

Expected: FAIL because host_self_update is missing.

resolve_host_self_update_enrollment returns None when any of: missing LITAI_HOST_INSTALL=1; empty/relative LITAI_PREFIX; LITAI_NO_SELF_UPDATE truthy (1/true/yes casefold); CI or GITHUB_ACTIONS truthy; LITAI_EVIDENCE_RUN or LITAI_SELF_UPDATE_REEXEC truthy; manifest missing, symlink in custody, not @2, self_update not true, prefix/environment/launcher mismatch; executable.resolve() not equal to or under Path(layout.environment). Staging root is application_root / "self-update".

POSIX launcher after computing prefix:

LITAI_PREFIX=$prefix
LITAI_HOST_INSTALL=1
export LITAI_PREFIX LITAI_HOST_INSTALL

Windows launcher: set "LITAI_HOST_INSTALL=1" next to the existing LITAI_PREFIX assignment.

install_litai.py writes schema @2 plus "self_update": true.

Expected: PASS.


Task 2: GitHub latest-release selection, cache throttle, no-downgrade

Files:

Interfaces:

Use a fixture payload shaped like GitHub's latest-release JSON: tag_name, draft, prerelease, assets: [{name, browser_download_url}]. Cases:

  1. v0.12.0 wheel selected when installed is 0.11.0.
  2. Returns None when tag is 0.11.0 or older (no downgrade / no equal).
  3. Returns None when draft or prerelease is true (defense in depth; /releases/latest already skips them).
  4. Returns None when no literate_ai-*-py3-none-any.whl asset exists.
  5. cache_allows_github_check is false when ok and age < 24h, or when a staged wheel for a still-newer version exists (callers pass that as ok cache or a separate staged_version); true when last check failed and age >= 15 minutes; false when last check failed and age < 15 minutes.

Compare versions with packaging.version.Version. Strip a leading v from tag_name. Invalid versions return None.

Do not perform I/O in these functions.


Task 3: Background worker stages a wheel without touching the live venv

Files:

Interfaces:

  1. Given a newer fixture release, stage_github_wheel writes cache.json and the wheel bytes, and does not modify layout.environment or layout.launcher.
  2. If cache_allows_github_check is false, fetch is not called.
  3. Fetch errors write ok: false cache and do not raise.
  4. spawn_self_update_worker uses subprocess.Popen with start_new_session=True on POSIX (and CREATE_NEW_PROCESS_GROUP on Windows), stdout/stderr redirected to worker.log, and does not wait(). If worker.lock is already held, it does not start a second process (inject lock).

Worker argv (so the child does not need the operator command):
[sys.executable, "-m", "literate_ai.adapters.host_self_update", "--stage", str(enrollment.prefix)]
The module if __name__ == "__main__" path only stages; it never applies.

Use urllib.request.Request like host_install._download_artifact, timeout 10. Catch OSError and urllib.error.URLError. Atomic replace for cache and wheel (*.tmp then os.replace).


Task 4: Apply staged wheel, non-blocking lock, re-exec, fail open

Files:

Interfaces:

  1. Staged newer wheel → runner called with local wheel path; exec_fn called with original argv and LITAI_SELF_UPDATE_REEXEC=1.
  2. Equal/older staged version → no pip, no exec.
  3. Lock busy → no pip, no exec.
  4. Runner non-zero → warning, no exec, current files otherwise left as pip left them (do not invent rollback beyond "do not exec").
  5. maybe_host_self_update with no enrollment → no spawn, no apply.

Inject runner and exec_fn; do not call real pip or exec in unit tests.

Order in maybe_host_self_update:

  1. Resolve enrollment (None → return).
  2. Try apply if a staged wheel is newer (non-blocking lock).
  3. If still in this process (no exec), spawn worker unless worker lock is held.

Task 5: Hook dispatch.main before argparse

Files:

Interfaces:

Assert the hook was called with ["help"] (or the normalized argv main actually receives). help must still return 0.

from literate_ai.adapters.host_self_update import maybe_host_self_update

raw = sys.argv[1:] if argv is None else list(argv)
maybe_host_self_update(raw)

Pass the same argv the caller provided so tests that inject argv= still work. Do not pass sys.argv when argv is provided.


Task 6: Uninstall @2 and remove staging

Files:

Interfaces:

Keep the existing @1 fixture working. Add an @2 fixture with a self-update/cache.json file; after uninstall the staging dir and venv are gone. A @2 document missing self_update or with extra unknown keys still fails closed.

Replace document != expected with a field-wise check: required keys match layout; schema is @1 or @2; if @2, self_update is True.


Task 7: install-check stays GitHub-silent

Files:

Interfaces:


Task 8: Operator docs and changelog

Files:

Interfaces: none


Spec coverage

ADR 0037 rule Task
Prefix-only identity (launcher flag + @2 + venv python) 1
@1 / checkout / CI / opt-out / evidence / re-exec inert 1, 5
GitHub latest non-prerelease wheel; any newer version; no downgrade 2
Background check; 24h / 15m throttle; never delay this command for GitHub 3, 5
Stage only; apply next invoke; re-exec argv 3, 4
Non-blocking lock; fail open 4
Uninstall @2 + staging 6
install-check silent 7
Docs vs litai update; standing prefix authorization 8
No new subcommand (none added)
Tokens never logged 3

Out of scope

PyPI, git rebuilds, pipx/Homebrew/apt/WinGet copies, a litai self-update verb, applying under a running command, 0.10.x backport.

Read the Markdown sourceConference homePublished 1.3.0 · Patch 1.3.1 in progress · Development 1.4