Migrating to v0.49.0

Five surfaces are cut in this release, each a hard cut with no shim: the retired spelling fails naming its replacement. The sections below are independent, so apply the ones you use in any order.

Unified updateSection anchor link

Use the top-level update command for both targets:

Previous commandReplacement
sparkwing version update --clisparkwing update --cli
sparkwing version update --sdksparkwing update --sdk

Bare sparkwing update selects the CLI. --cli and --sdk conflict; --force and --override-hold are CLI-only. --version selects a canonical release tag for either target. Both targets default to the latest published GitHub release. SDK updates now pass that resolved tag to native go get, then run go mod tidy, retaining Go's toolchain and module verification rules.

update --check performs HTTP metadata reads and local identity inspection. It never invokes Go, installs a binary, changes the project pin, or writes tool caches. Explicit tags must identify published releases. Clean CLI commit provenance must match its stamped release tag before version comparisons are reported as known; local builds and SDK replacements produce unknown. Checks do not verify release asset signatures or guarantee installation.

JSON checks emit one update_check record with tool, target, strategy, status, installed, and available. Unknown identity fields are omitted; reason and blocked_reason explain uncertainty or operator holds. Exit 0 means current or ahead, 1 means update_available, and 2 means unknown, diverged, or lookup failure. Plain checks print the status word.

Successful updates emit a compact update receipt with before and after identities. CLI artifact metadata is read without executing the new binary; resolved_release retains the verified release label when embedded version metadata cannot be established. Progress goes to stderr. JSON is the pipe default, pretty output is the terminal default, and --output pretty|json|plain overrides it. Plain updates print the resulting version. The rich version report and version hold remain available. The version card keeps its layout and adds cli_status / cli_reason to its JSON report. Its CLI verdict uses the same comparison facts as update --check; --offline reports not_checked without network access. The entire metadata lookup chain shares one three-second budget. The version card describes the invoking process. Update checks and receipts inspect the destination on disk, which may already contain a replacement build. SDK module inspection accepts regular files up to 1 MiB and reports unsafe or oversized inputs as unknown. SDK receipts report updated whenever native go get and go mod tidy ran, even if the SDK pin stayed the same, because other dependency files can change. Unreadable, unsafe, or oversized operator-hold files now report an error and refuse updates instead of appearing unset. A missing file still means no hold; a nonempty environment hold keeps precedence without requiring a home path.

Serve commandSection anchor link

The local dashboard and API lifecycle now lives under sparkwing serve. Update shell scripts, shortcuts, and runbooks to use these commands:

Previous commandReplacement
sparkwing dashboard startsparkwing serve start
sparkwing dashboard statussparkwing serve status
sparkwing dashboard killsparkwing serve stop
sparkwing dashboard stopsparkwing serve stop

The retired dashboard command fails with a replacement instruction and performs no service action. serve takes start, stop, restart, logs and status; there is no serve kill.

serve start leaves a running service alone and reports it, where dashboard start replaced whatever it found. A script that restarted the dashboard by calling start again calls serve restart. Lifecycle receipts name the owner, the effective endpoints, readiness, and the artifact the service runs, and serve logs reads a bounded window of the service log.

The dashboard UI, HTTP routes, JSON service identity dashboard, and dashboard.pid / dashboard.log state paths keep their names, so serve addresses the same local service and state.

Lint slots removedSection anchor link

sparkwing.AcquireLintSlot, the LintSlot type, its Configure and ConfigureIn methods, and SPARKWING_LINT_SLOTS are gone. Give each worktree its own linter cache with ToolCacheDir instead.

Before:

slot, err := sparkwing.AcquireLintSlot("golangci-lint")
if err != nil {
    return err
}
defer slot.Release()

cmd := sparkwing.Bash(ctx, "golangci-lint run --allow-serial-runners ./...")
_, err = slot.Configure(cmd, "GOLANGCI_LINT_CACHE").Run()

After:

_, err := sparkwing.Bash(ctx, "golangci-lint run --allow-serial-runners ./...").
    Env("GOLANGCI_LINT_CACHE", sparkwing.ToolCacheDir("golangci-lint")).
    Run()

A multi-module repository sets Dir per module and hands each invocation the same worktree-scoped cache.

Why the slot could not be keptSection anchor link

A slot lent every worktree one alias path -- a symlink repointed at whichever worktree held the lease -- so that a shared cache's stored absolute paths kept resolving. git resolves that alias and reports the real worktree as the repository root, so every finding the linter recorded under the alias sat outside the diff that a baseline such as golangci-lint's new-from-merge-base matches against, and the baseline filter dropped all of them. A tree with eight findings linted clean in three seconds. The alias breaks the filter whether or not the cache is shared, so keying the cache by tree would not have restored the findings.

A worktree therefore starts its linter cold, which is the cost of a gate that reports what is in the tree.

Queue exec removedSection anchor link

sparkwing queue exec is gone. It ran one command under a lease from the local admission daemon, and the daemon kept that lease alive across a lost connection until the command's process session was proven empty. Nothing in the pipeline path used it.

Run the work as a pipeline instead. A pipeline run takes admission the same way, appears in sparkwing queue, and gets the retries, logging, and cancellation a bare command never had:

# Before
sparkwing queue exec --run-id build-123 --name bootstrap --cores 1 \
  --semaphore bootstrap -- make prepare

# After: a pipeline job that runs `make prepare`, with the same charge
sparkwing run bootstrap

Declare the charge and the shared lock in the pipeline's plan: plan.Resources(sparkwing.Cores(1)) for the charge, and a sparkwing.NewConcurrencyGroup("bootstrap", ...) enrolled with plan.Concurrency(group) for the lock. The orchestrator turns the group into the same admission claim the --semaphore flag used to send, so the daemon arbitrates the run exactly as it arbitrated the command.

What left the wireSection anchor link

The messages guard_complete and guard_complete_ack, and the admission_request.guard field, are removed from the protocol. The daemon still speaks protocol major 3 and still serves every major from 1 up, so a pipeline binary pinned to any released SDK keeps its admission: no SDK ever sent these. A sparkwing CLI older than this release that runs queue exec against a newer daemon is admitted without the guard, runs the command to completion, and then exits non-zero naming the operation the daemon no longer serves: it sends guard_complete only once the command has finished. The work is done and the exit code says otherwise, so replace the call rather than relying on it.

Isolated home removedSection anchor link

sparkwing run --sw-isolated-home DIR is gone. It pointed one run's state and config at DIR, so the run hosted an admission daemon of its own instead of joining the machine's. A run that leaves the machine's daemon is arbitrated by nobody: it does not appear in sparkwing queue or the dashboard, and several such runs contend on the operating system instead of queueing. A command that still passes the flag fails naming the replacement.

The flag existed for one case, a pipeline binary whose runs-store schema is newer than the sparkwing hosting the machine's daemon. Admission now refuses that case with a message naming the daemon's version, this binary's version, how to identify the build the daemon runs, and the upgrade:

local admission: the admission daemon cannot read this runs store: daemon
v0.48.1 understands runs-store schema 33, this binary is v0.49.0 at schema 34,
and the store both share is migrated to the newer one. `sparkwing daemon
status` names the build the daemon runs, and the `sparkwing` found on PATH
resolves to ~/.local/bin/sparkwing; upgrade that binary to one that
understands schema 34, with `sparkwing update` for a published release or this
repository's `bin/install.sh` for a build newer than any release, then
`sparkwing daemon restart`, or point SPARKWING_WINGD_BIN at a binary that
understands schema 34 and restart the daemon

Which half of the upgrade applies depends on where the newer schema came from. sparkwing update installs a published release. A branch whose schema is newer than every release has no release to install, so build and install that branch instead, or point SPARKWING_WINGD_BIN at the binary you built and restart the daemon.

Before:

sparkwing run pre-commit --sw-isolated-home "$(mktemp -d)"

After:

sparkwing update && sparkwing daemon restart   # a published release
sparkwing run pre-commit

SPARKWING_HOME still gives a command a home of its own, and keeps that meaning: deliberate isolation for work that must not touch the operational runs store, such as the release preview in Getting started. It carries the same consequence the flag did, so a run started under it is outside the machine's admission ledger.

Dashboard lifecycleSection anchor link

serve stop replaces serve kill; the old verb is removed. Repeated serve start preserves every known running instance. Use serve restart for replacement; it preserves the recorded options unless a flag overrides them. serve logs provides a finite 40-line tail and explicit following.

Receipts add action/outcome, ownership, readiness, effective endpoints and artifact comparison. Plain is the state word across all lifecycle actions. Linux stop uses boot/birth identity and a process handle; macOS uses guarded birth checks with a remaining check-to-signal race. Other platforms refuse verified lifecycle actions. Legacy numeric PID files cannot authorize signals. Stop the legacy instance through its existing owner before starting the new supervisor. The admission daemon remains separate.