Skip to content

feat(worker): launcher worker-mode groundwork (config, status, credential renewal, process ownership) - #12705

Draft
mudler-agent wants to merge 16 commits into
masterfrom
feat/launcher-worker-resume
Draft

mudler-agent wants to merge 16 commits into
masterfrom
feat/launcher-worker-resume

Conversation

@mudler-agent

@mudler-agent mudler-agent commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator

What

Adds the worker-side groundwork for a generic distributed worker mode in the
launcher. This branch carries plan tasks LW01 to LW07. The end-to-end acceptance run
(LW08) and the platform acceptance (LW09) are not done (see "Plan status").

  • Launcher configuration migrates to versioned, owner-only profiles, with
    mode-specific validated launches and child-only secret injection (LW01 to LW03).
  • local-ai worker can publish an opt-in, owner-only status snapshot that a
    launcher polls. Readiness requires current approval, credentials, NATS,
    lifecycle subscriptions, armed local checks and an accepted heartbeat (LW04).
    Approval is observed through a read-only, registration-token-protected
    GET /api/node/:id/status route; credential renewal uses a renew_node_id
    request field so renewal does not restore offline nodes.
  • Dynamic NATS credential providers keep reconnecting after a JWT expires
    during a renewal outage (IgnoreAuthErrorAbort, only when a provider is
    configured). Static credentials keep NATS's default behavior (LW04).
  • The backend process ledger is now durable ownership (LW05):
    • a record is released only when the whole process group is gone; a group
      that outlives its leader is killed and waited for (5 s), and stays recorded
      if it cannot be emptied;
    • a backend whose record cannot be written is not run;
    • start times come from /proc, or from ps where /proc does not exist;
    • the launch generation is stored with each record;
    • at startup, an unresolved record (unverifiable identity, surviving group,
      unreadable ledger) makes the worker refuse to start; nothing unverified is
      signalled and the record is kept.
  • Serialized launcher lifecycle (LW06):
    • one controller owns the runtime child and refuses start or stop while another
      operation runs; results of older generations are dropped;
    • a per-user lock file (owner-only, close-on-exec) keeps a second launcher from
      starting an overlapping role;
    • before a start the launcher kills and verifies every backend group a recorded
      earlier run left, and refuses to start over a record it cannot resolve;
    • stop is SIGTERM, 30 s to drain, force-kill of the runtime group, 5 s to reap,
      then every recorded backend group; "Stopped" is shown only after all groups
      are verified empty, otherwise "shutdown incomplete" keeps starts blocked and
      stop available;
    • an unexpected runtime exit runs the same cleanup;
    • a worker starts only if the runtime prints the status capability; its state
      comes from the status snapshot, never from log text;
    • the process identity rules moved to pkg/workerledger, shared by the worker
      and the launcher.
  • Settings window and tray (LW07): mode selector, worker connection and advanced
    settings, masked secrets that are kept unless replaced or cleared, derived
    reverse-connectivity ports, Stop and apply / Cancel when a change affects a
    running role, a per-session plaintext acknowledgement, and the tray actions
    Start worker, Stop worker and Open deployment UI.
  • Fix: a reader that opened status.json just before it was replaced saw an inode
    with no links and got "status unavailable". This was the rare failure of the
    atomicity spec. Only an extra hard link is refused now.

Why

A launcher that starts and stops a worker must be able to prove that no backend
process of an earlier run is still alive before it starts another role. The old
ledger forgot a backend when its leader exited, ignored write errors, could not
identify processes on macOS and dropped records it could not resolve.

How it was tested

Linux, cgo on, -tags auth:

  • go test on cmd/launcher/internal, pkg/workerledger, pkg/workerstatus,
    core/services/worker, core/services/messaging, core/cli/workerregistry
    pass. go test -race passes for cmd/launcher/internal and pkg/workerstatus.
  • LW06 specs use real processes and real process groups: graceful exit, a child
    that ignores SIGTERM, backend groups that outlive the runtime, a leader that
    exits before its group, a recycled pid, an unresolvable or corrupt ledger, a
    kill that never lands, concurrent starts, stale generations, and two launchers
    racing for the lock. Mutation checks: skipping the backend group kill and
    skipping the runtime force kill each fail the specs; dropping the stop prompt
    in apply fails the apply specs.
  • LW07 specs run the Fyne widgets with the test driver (mode switching keeps
    drafts, masked secrets, labels per mode, derived ports) and test the settings
    apply logic and the tray entries directly.
  • go vet clean. golangci-lint (Go 1.26 toolchain) reports no finding in the
    new packages or in core/services/worker.
  • make test-launcher-appimage passes, and make build-launcher-appimage
    produces an AppImage whose extracted tree has the expected layout, no missing
    libraries, and the new UI.

Known limits

  • LW08 was not run: it needs a frontend with PostgreSQL, authenticated NATS with
    TLS, a backend and a model. The build host has no PostgreSQL and no Docker
    access. A mocked state test is not an acceptance pass. Nothing here proves
    registration, approval, credential issuance, backend install, model load or
    inference through a real frontend with a launcher-managed worker.
  • LW09 is partial: Linux native tests and the AppImage build ran. The AppImage
    start-up smoke did not run (no xvfb-run or desktop-file-validate on the
    host). macOS did not run at all: the ps -o lstart= identity path is covered
    by a fallback spec that simulates a system without /proc, and the macOS
    launcher, tray and descendant tests have no native run.
  • Windows has no process groups; the ledger is inert there. pkg/workerstatus,
    the worker package and the launcher use unix-only calls and do not build for
    Windows.
  • The launcher UI is covered by widget tests, not by a manual run on a desktop
    session. There is no screenshot from a real window.
  • An unresolved ledger record blocks the worker until the launcher can verify
    and clean it; the launcher now does this at the next start or stop, and keeps
    blocking when it cannot. The worker binary itself still refuses to start over
    such a record.

Plan status

Done: LW01 baseline, LW02 secure configuration migration, LW03 validated
launches and secrets, LW04 evidence-backed worker status, LW05 durable process
ownership, LW06 serialized lifecycle and safe role switches, LW07 settings and
tray.

Not done: LW08 authenticated distributed integration run (could not run here),
LW09 packaging and platform acceptance (Linux native and AppImage build done;
AppImage smoke and macOS not run).

Known issues on this branch

  • golangci-lint reports 10 issues in pkg/workerstatus (9 errcheck, 1
    forbidigo) from the status snapshot code. They are not fixed.
  • cmd/launcher is excluded from the repo lint target, so the launcher code is
    checked by go vet and tests only.
  • Windows: see "Known limits".

🤖 Generated with Claude Code

@github-advanced-security github-advanced-security AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gosec found more than 20 potential problems in the proposed changes. Check the Files changed tab for more details.

mudler added 12 commits October 16, 2026 07:03
Run native Linux launcher, worker registry, worker and nodes suites in Ubuntu with strict Ginkgo evidence checks. Preserve failures through logging, enforce scratch space preflight and test apt-compatible temporary directory permissions.

Assisted-by: nib:gpt-6-astra
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
A truncated report could pass with only one of its scheduled specs.
Require integer counts and reconcile total, scheduled and reported It
specs per suite. Keep hook reports outside the It count.

Add regressions for malformed counts and incomplete reports under
normal and optimized Python.

Assisted-by: nib:unknown
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Preserve standalone settings and startup tri-state while preparing separate worker connection and NATS storage. Keep legacy start_on_boot inert, reject unsafe configuration files, and atomically persist owner-only settings before applying them. Remove configuration and environment value dumps.

Add migration and filesystem-security specs, config-only isolated verification, and user-facing storage documentation.

Assisted-by: nib:gpt-6-astra
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Keep worker startup fail-closed pending runtime and ownership integration. Share standalone command construction, isolate worker authentication in the child environment, and bound/redact child log sinks. Add CLI-tag compatibility coverage and a strict focused/full launch verification gate.

Assisted-by: nib:gpt-6-astra
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Resolve NATS CA, client certificate and key paths against the launcher working directory before validation so the child opens the same paths from its data directory. Preserve saved settings and document resolution semantics.

Cover missing and conflicting child files, all three TLS paths and immutable launch snapshots. Worker startup remains fail-closed.

Assisted-by: nib:gpt-6-astra
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Add opt-in atomic owner-only schema-versioned snapshots with session identity, sequence freshness, approval, credential, authenticated NATS, subscription, armed readiness and heartbeat evidence. Reject non-2xx heartbeats and expose an exact bounded runtime capability probe without enabling launcher worker startup.

Exercise real JWT NATS lifecycle subscriptions and reconnect with HTTP registration fixtures. Extend status verification with genuine CLI/UI prerequisites, strict full-suite evidence and race runs. Document readiness limits and the remaining lifecycle and integration gates.

Assisted-by: nib:gpt-6-astra
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Reject expired approval, stale unseen publications, inconsistent timestamps and snapshots not bound to the owned child PID. Keep accepted freshness on the reader's monotonic clock.

Observe controller approval independently of static or expiring credentials through a token-protected read-only query. Keep managed credential renewal from restoring offline nodes or changing registration telemetry. Older controllers fail closed; launcher startup remains disabled.

Exercise shared production approval and composite readiness components, non-expiring refresh-loop behavior, static revocation, backend failure, permission recovery and publication cleanup. Extend verification to controller handler and authentication suites.

Assisted-by: nib:gpt-6-astra
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Keep renewal failures separate from current approval and retained
credentials. Heartbeat and approval success cannot clear this evidence.

Share production renewal wiring with expiring-JWT tests. Cover rejected
renewals, missing credentials, recovery, and authenticated reconnect after
the original JWT expires. Retain independent non-expiring approval tests.

Document renewal evidence and regenerate Swagger for approval queries and
credential renewal with make swagger using swag v1.16.6.

Assisted-by: nib:gpt-6-astra
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Dynamic credential providers can recover after a JWT expires during a
renewal outage. nats.go closes the connection permanently after two
identical authentication errors unless IgnoreAuthErrorAbort is set, so
the worker could never reconnect after a successful renewal.

Set IgnoreAuthErrorAbort only when a credential provider is configured.
Static credentials keep the default terminal authentication behavior.

Test the scope with a real authenticated broker. The renewal test keeps
the same broker running through expiry and the 30 second renewal retry,
proves the original JWT is rejected, and verifies that the worker
reconnects on the same broker process once renewal succeeds.

Document the dynamic-provider retry behavior and the unchanged static
behavior in the distributed mode and launcher configuration pages.

Assisted-by: Claude:claude-haiku-5-5 go test golangci-lint
The process ledger forgot a backend as soon as its leader stopped, even
when descendants of the group still ran, ignored write failures, could
not identify a process on macOS, and silently dropped records it could
not resolve. A restarted worker could then double-book a port or GPU
memory held by an orphan.

Release a record only after the whole process group is gone: a group
that outlives its leader is killed and waited for up to five seconds,
and stays recorded if it cannot be emptied. Refuse to run a backend
whose record cannot be written. Read start times from ps where /proc
does not exist, and store the launch generation with each record.

At startup, refuse to serve when a record is unresolved (unverifiable
identity, a group that survives the kill, an unreadable ledger). Keep
the record and signal nothing unverified.

Assisted-by: Claude:claude-sonnet-5-5 go test golangci-lint
Read the generation from the status publisher instead of the process
environment, as the repository lint rules require, and run ps with a
fixed locale and path so start times compare equal across processes.

Assisted-by: Claude:claude-sonnet-5-5 go test golangci-lint
Compare the owner uid of a stat result as int64 instead of converting
os.Geteuid() to uint32, which gosec reports as G115 integer overflow.
Both sides widen, so the comparison keeps its meaning.

Replace the os.Lstat probe in NewPublisher, which gosec reports as G703
path traversal because the path comes from the environment. The probe
now goes through the validated, owner-only session directory descriptor
and fstatat with AT_SYMLINK_NOFOLLOW, so a fresh launch still refuses
any existing status.json, including a dangling symlink. No suppression
comments are added.

Assisted-by: Claude:claude-sonnet-5-5 go gosec golangci-lint
@mudler-agent
mudler-agent force-pushed the feat/launcher-worker-resume branch from f56cfbb to 4fe1947 Compare October 16, 2026 07:15
Comment thread cmd/launcher/internal/launcher.go Fixed
Comment thread core/services/worker/process_ledger.go Fixed
Comment thread core/services/worker/process_ledger.go Fixed
dir := os.NewFile(uintptr(fd), "launcher settings directory")
var st unix.Stat_t
if err := unix.Fstat(fd, &st); err != nil || int64(st.Uid) != int64(os.Geteuid()) || st.Mode&0022 != 0 {
dir.Close()
f := os.NewFile(uintptr(fd), "launcher.json")
var st unix.Stat_t
if err = unix.Fstat(fd, &st); err != nil || st.Mode&unix.S_IFMT != unix.S_IFREG || int64(st.Uid) != int64(os.Geteuid()) || st.Nlink != 1 {
f.Close()
// Never follow a replaced parent/snapshot to invalidate it.
if dir, e := directory(p.path); e == nil {
_ = removeSnapshot(dir)
dir.Close()
}
b := make([]byte, 32)
if _, e = rand.Read(b); e != nil {
os.Remove(dir)
Comment thread pkg/workerstatus/status.go Fixed
f := os.NewFile(uintptr(fd), "worker status")
var st unix.Stat_t
if unix.Fstat(fd, &st) != nil || int64(st.Uid) != int64(os.Geteuid()) || st.Mode&unix.S_IFMT != unix.S_IFREG || st.Mode&07777 != 0600 || st.Nlink != 1 || st.Size > MaxSize {
f.Close()
return ErrUnavailable
}
if old != nil {
old.Close()
A reader that opened status.json just before the writer replaced it holds
an inode with no links left. The link-count check required exactly one
link, so that read failed as "status unavailable". This was the rare
failure of the atomicity spec, and it would show a brief false connection
problem on a launcher.

An unlinked file that was complete and owner-only is still valid. Refuse
only a snapshot with extra hard links.

Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
… (LW06)

The launcher now runs its runtime through one lifecycle controller instead
of tracking a bare child process.

- One operation at a time: start, stop and quit are refused while another
  is in progress. Results tagged with an older generation are dropped.
- A per-user lock file (owner-only, close-on-exec, dropped by the kernel
  when the holder dies) keeps a second launcher from starting an
  overlapping role.
- Before a start, the launcher reads the worker's process ledger. It kills
  and verifies every group that provably belongs to an earlier run and
  drops records whose group is gone or whose pid was recycled. A record it
  cannot resolve blocks the start and nothing unverified is signalled.
- Stop sends SIGTERM, waits 30 s, kills the runtime group, waits 5 s more,
  then kills every recorded backend group. It reports stopped only after
  the runtime group and all recorded groups are verified empty. Otherwise
  it reports "shutdown incomplete", keeps the lock, blocks every start and
  leaves stop available to retry.
- An unexpected runtime exit runs the same cleanup.
- A worker starts only if its runtime prints the status capability. Its
  state comes from the private status snapshot, never from log text, and
  liveness alone shows "Connection problem", not "Ready".

The process identity rules move to pkg/workerledger so the worker and the
launcher judge a recorded group the same way. The worker behaviour and its
specs are unchanged.

Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
The repo lint rejects testing.T assertions. Port the two small test files
added by the lifecycle change and the link-count fix to Ginkgo and Gomega.

Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
…ns (LW07)

The settings window now starts with a mode selector: Run locally or Join a
distributed server. Each mode keeps its draft when you switch.

- The join form edits the frontend URL, registration token, advertised
  address and node name. Advanced settings hold the NATS URL and TLS files,
  the maximum gRPC port, optional JWT and seed overrides, runtime
  environment overrides and a per-session plaintext acknowledgement. The
  window shows the reverse HTTP port and gRPC range the frontend must reach.
- Secret fields are masked and never filled from the file. A blank field
  keeps the saved value; replacing or clearing a secret is explicit.
- Save persists both profiles. A change that affects a running role offers
  Stop and apply or Cancel. Stop and apply commits only after the runtime
  and its backends are confirmed gone and never starts the replacement.
  Start, save and update are refused while it runs.
- Environment and log level edits are drafts until Save, so a change cannot
  reach the saved profile without the stop prompt.
- Worker mode names the controls Start worker and Stop worker. The tray
  adds Open deployment UI, which opens the saved frontend URL without
  credentials. Stop stays available until shutdown is confirmed.
- Start selected mode when launcher opens keeps the legacy default for
  standalone. A worker starts at launch only when the owner chose it.

Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
func psStartTime(pid int) string {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "ps", "-o", "lstart=", "-p", strconv.Itoa(pid))
return err
}
tmp := path + ".tmp"
f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
// ledger; an unreadable or corrupt one is an error, because ownership of the
// earlier processes is then unknown.
func ReadFile(path string) ([]Entry, error) {
data, err := os.ReadFile(path)
}
}

cmd := exec.Command(spec.Binary, spec.Args...)
f := os.NewFile(uintptr(fd), "worker status directory")
var st unix.Stat_t
if unix.Fstat(fd, &st) != nil || int64(st.Uid) != int64(os.Geteuid()) || st.Mode&07777 != 0700 {
f.Close()
return nil, fmt.Errorf("cannot restrict the launcher lock: %w", err)
}
if err := unix.Flock(fd, unix.LOCK_EX|unix.LOCK_NB); err != nil {
f.Close()
return nil, errors.New("the launcher lock is not an owned regular file")
}
if err := f.Chmod(0o600); err != nil {
f.Close()
f := os.NewFile(uintptr(fd), "launcher lock")
var st unix.Stat_t
if unix.Fstat(fd, &st) != nil || st.Mode&unix.S_IFMT != unix.S_IFREG || int64(st.Uid) != int64(os.Geteuid()) || st.Nlink != 1 {
f.Close()
if err != nil {
return err
}
d.Close()

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants