Files
multica/server/internal/daemon/agents_refresh.go
Jiayuan Zhangandmultica-agent 231dedb552 MUL-8034: chore: clean historical comments across the codebase (#9274)
* chore: clean historical comments (MUL-8034)

Co-authored-by: multica-agent <[email protected]>

* docs: preserve pending Composio expiry contract

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: multica-agent <[email protected]>
2026-10-15 21:54:33 +08:00

885 lines
36 KiB
Go

package daemon
import (
"context"
"fmt"
"sort"
"time"
)
// agentDiscoveryInterval is how often a running daemon re-checks which agent
// CLIs are installed. A round is a handful of exec.LookPath calls — the
// login-shell fallback is separately rate-limited by the much longer
// shellResolveTTL — so this can be short enough that installing a CLI feels
// immediate. Overridable for tests.
var agentDiscoveryInterval = 2 * time.Minute
// agentConvergeMaxBackoff caps the retry delay for a discovered provider that
// keeps failing to register (permanently below the minimum supported version, a
// CLI whose --version never succeeds, a server that keeps rejecting the
// register). Discovery itself stays on agentDiscoveryInterval; only the
// expensive half — version probes plus one register call per workspace — backs
// off, so a stuck provider cannot turn into a busy loop. Overridable for tests.
var agentConvergeMaxBackoff = 30 * time.Minute
// agentVersionRefreshInterval is how often a running daemon re-probes the
// version of every agent CLI it already has registered, so an in-place upgrade
// of codex/claude is picked up without a restart. A round is one `--version`
// fork per installed CLI, fanned out and machine-level — it does not scale with
// workspace count or runtime count. Overridable for tests.
//
// This is the one local probe whose cost grows with the host (one fork per
// installed CLI) and which executes third-party binaries, some of whose
// wrappers have visible side effects when run — so it is the one worth
// lengthening if background probing needs to get cheaper.
//
// It is not lengthened further than selfReloadCheckInterval, though, because
// the round does more than refresh a displayed version string: it also keys
// version-sensitive launch policy, and it is what confirms a CLI has dropped
// below its minimum supported version and must stop being given work. The
// interval is therefore also the window in which an unsupported CLI keeps
// claiming tasks, which is why this tracks the reload check rather than being
// pushed out on cost grounds alone.
var agentVersionRefreshInterval = 10 * time.Minute
// agentDiscoveryLoop keeps the registered runtime set converged on the agent
// CLIs actually installed on this machine, so a CLI installed while the daemon
// is running comes online without a restart (MUL-5439).
//
// Each tick does the cheap half unconditionally: re-probe availability and
// publish anything new. The expensive half (version probes + registration) runs
// only when some discovered provider is not yet registered for every tracked
// workspace — which is derived from live state, not remembered from the round
// that discovered it. That is what makes a failed first attempt retry: a
// provider whose version probe timed out, or whose register call failed, is
// still "missing" on the next tick and gets tried again. It also means a
// provider rejected for being below the minimum version recovers on its own
// after an in-place upgrade.
//
// A second, slower ticker keeps the versions of already-registered providers
// fresh (refreshAgentVersions) — the converge half only ever looks at providers
// that are *missing* a runtime, so without it an in-place CLI upgrade would stay
// invisible until the daemon restarted.
func (d *Daemon) agentDiscoveryLoop(ctx context.Context) {
ticker := time.NewTicker(agentDiscoveryInterval)
defer ticker.Stop()
versionTicker := time.NewTicker(agentVersionRefreshInterval)
defer versionTicker.Stop()
var (
backoff time.Duration
nextRetry time.Time
// lastMismatch is the DSH profile/runtime disagreement the previous tick
// saw, for the one direction that is throttled to a single forced round.
lastMismatch dshProfileMismatch
)
for {
select {
case <-ctx.Done():
return
case <-versionTicker.C:
d.refreshAgentVersions(ctx)
case now := <-ticker.C:
// Forced by disagreement between two live states, not by a change
// since the last look: a profile removed between two observations,
// or before the first one — which is what a restart produces —
// would otherwise never be noticed. Observed in practice: a profile
// removed a minute after startup stayed live for three minutes and
// the server routed a chat task straight into it.
mismatch := d.dshRuntimeProfileMismatch()
force := mismatch.forcesEveryTick() || (mismatch != dshMismatchNone && mismatch != lastMismatch)
lastMismatch = mismatch
d.convergeAgentRuntimes(ctx, &backoff, &nextRetry, now, force)
case <-d.agentDiscoveryKick:
// A local state change (the DSH runtime profile appearing) makes a
// provider registrable right now. Wait for nothing: the scheduled
// attempt this replaces can be agentConvergeMaxBackoff away.
d.convergeAgentRuntimes(ctx, &backoff, &nextRetry, time.Now(), true)
}
}
}
// convergeAgentRuntimes runs the expensive half of a discovery round: publish
// anything the cheap probe gained, then version-probe and register whatever is
// still missing a runtime.
//
// force ignores the backoff earned by previous failures, for a caller that
// knows the reason a provider could not register has just gone away — and, in
// the other direction, for one that knows a registered provider may just have
// gone bad. backoff and nextRetry are the loop's scheduling state, threaded
// through rather than owned here so the ticker and the kick share one backoff.
func (d *Daemon) convergeAgentRuntimes(
ctx context.Context,
backoff *time.Duration,
nextRetry *time.Time,
now time.Time,
force bool,
) {
gained := d.refreshAgentAvailability()
missing := d.providersMissingRuntimes()
// A forced round runs even with nothing missing: that is the shape of "a
// registered provider just became unusable", which is exactly what the
// verdict layer needs a round to notice.
if len(missing) == 0 && !force {
*backoff = 0
return
}
// A newly discovered provider always gets an immediate attempt; otherwise
// honor the backoff earned by previous failures unless the caller knows
// the blocker is gone.
if len(gained) == 0 && !force && now.Before(*nextRetry) {
return
}
before := len(missing)
d.convergeRuntimeRegistrations(ctx)
after := len(d.providersMissingRuntimes())
// backoff is earned by registration that keeps failing. A round that leaves
// nothing missing has nothing to back off from — including a forced round
// that only came here to condemn a registered provider, where before and
// after are both zero.
if after == 0 || after < before {
*backoff = 0
} else {
*backoff = nextConvergeBackoff(*backoff)
}
*nextRetry = now.Add(*backoff)
}
// dshOnlyVerdicts narrows a round's demotable verdicts to dsh.
//
// Returns nil rather than an empty map when there is nothing, so the caller's
// length check reads the same either way.
func dshOnlyVerdicts(demotable map[string]runtimeVerdict) map[string]runtimeVerdict {
verdict, ok := demotable["dsh"]
if !ok {
return nil
}
return map[string]runtimeVerdict{"dsh": verdict}
}
// dshProfileMismatch names a disagreement between the two states the daemon has
// to keep aligned: whether the Multica runtime profile is installed, and whether
// a dsh runtime is registered.
//
// The kind, rather than a bare bool, is what the caller needs, because the two
// directions have opposite termination behaviour — see forcesEveryTick.
type dshProfileMismatch string
const (
// dshMismatchNone: the two states agree.
dshMismatchNone dshProfileMismatch = ""
// dshMismatchRuntimeWithoutProfile is a live runtime whose CLI can no
// longer start. It is still "available" — discovered, and holding a
// runtime — so nothing else in the loop looks at it, and the server keeps
// routing work into a CLI that cannot start.
dshMismatchRuntimeWithoutProfile dshProfileMismatch = "runtime-without-profile"
// dshMismatchProfileWithoutRuntime is the mirror image, and is what a
// manual `dsh plugin --profile multica add` produces.
dshMismatchProfileWithoutRuntime dshProfileMismatch = "profile-without-runtime"
)
// forcesEveryTick reports whether this mismatch must force a converge round on
// every tick rather than only on the tick that first observed it.
//
// The asymmetry is about which direction a round is guaranteed to make progress
// on. Condemning a live runtime takes TWO probe rounds — the first only starts
// condemnedConfirmWindow — and nothing else schedules the second: with dsh still
// registered nothing is missing a runtime, so an unforced tick returns from
// convergeAgentRuntimes without probing at all. Forcing only the first sighting
// therefore leaves the runtime live and taking work forever, which is the exact
// failure the mismatch check exists to prevent.
//
// The other direction has no such guarantee and must NOT force every tick: a
// profile that is installed but answers nothing keeps that mismatch for as long
// as it is installed, and forcing on it would spend a full probe of every
// provider every agentDiscoveryInterval, permanently bypassing
// agentConvergeMaxBackoff.
func (m dshProfileMismatch) forcesEveryTick() bool {
return m == dshMismatchRuntimeWithoutProfile
}
// dshRuntimeProfileMismatch names the current disagreement, or dshMismatchNone
// when the two states agree.
//
// Deliberately not a change detector: this is compared on every tick against
// live state, so a profile removed before the daemon's first look — or while it
// was not looking — is caught on the very next tick rather than never.
func (d *Daemon) dshRuntimeProfileMismatch() dshProfileMismatch {
registered := d.dshRuntimeRegistered()
if registered == dshMulticaProfilePresent() {
return dshMismatchNone
}
if registered {
return dshMismatchRuntimeWithoutProfile
}
// Only worth a round if there is a dsh CLI to register at all: without one
// nothing this round does can resolve the mismatch.
if _, discovered := d.agents()["dsh"]; discovered {
return dshMismatchProfileWithoutRuntime
}
return dshMismatchNone
}
// dshRuntimeRegistered reports whether any tracked workspace still holds a
// built-in dsh runtime. Custom runtime profiles are excluded: they carry a
// ProfileID and are owned by the drift path, not by the built-in set.
func (d *Daemon) dshRuntimeRegistered() bool {
d.mu.Lock()
defer d.mu.Unlock()
for _, ws := range d.workspaces {
for _, id := range ws.runtimeIDs {
rt, ok := d.runtimeIndex[id]
if !ok || rt.ProfileID != "" {
continue
}
if rt.Provider == "dsh" {
return true
}
}
}
return false
}
// kickAgentDiscovery asks the discovery loop for an immediate convergence
// round. Safe on a zero-value Daemon and from any goroutine: the send is
// non-blocking, so a burst of producers collapses into one round and none of
// them waits on the loop.
func (d *Daemon) kickAgentDiscovery() {
if d.agentDiscoveryKick == nil {
return
}
select {
case d.agentDiscoveryKick <- struct{}{}:
default:
}
}
// nextConvergeBackoff doubles the retry delay, starting at one discovery
// interval and capped at agentConvergeMaxBackoff.
func nextConvergeBackoff(current time.Duration) time.Duration {
if current <= 0 {
return agentDiscoveryInterval
}
next := current * 2
if next > agentConvergeMaxBackoff {
return agentConvergeMaxBackoff
}
return next
}
// agents returns the current built-in agent availability set.
//
// The returned map is shared and MUST NOT be mutated: refreshAgentAvailability
// publishes a whole new map rather than editing this one, which is what makes
// unlocked reads from task-execution paths safe.
func (d *Daemon) agents() map[string]AgentEntry {
if m := d.agentsAvailable.Load(); m != nil {
return *m
}
// Zero-value Daemon (tests construct one directly): fall back to the
// startup config so behavior matches the pre-refresh code path.
return d.cfg.Agents
}
// setSkippedAgents replaces the diagnostic "discovered but not registered" set
// reported on /health. Called at the end of every registration round with the
// providers that round dropped.
//
// Purely diagnostic, and deliberately so: it is last-writer-wins across every
// goroutine that probes, so nothing may steer behavior off it. A caller that
// needs to act on a verdict takes it from detectBuiltinRuntimes' return value,
// which describes its own round.
func (d *Daemon) setSkippedAgents(skipped map[string]string) {
d.skippedAgentsMu.Lock()
defer d.skippedAgentsMu.Unlock()
d.skippedAgents = skipped
}
// skippedAgentsSnapshot copies the current skip reasons for the health handler.
func (d *Daemon) skippedAgentsSnapshot() map[string]string {
d.skippedAgentsMu.RLock()
defer d.skippedAgentsMu.RUnlock()
if len(d.skippedAgents) == 0 {
return nil
}
out := make(map[string]string, len(d.skippedAgents))
for name, reason := range d.skippedAgents {
out[name] = reason
}
return out
}
// refreshAgentAvailability re-runs CLI discovery and publishes providers that
// appeared since the last probe. It performs no registration — that is
// convergeRuntimeRegistrations, driven by live state so a failure retries.
//
// Deliberately one-directional: only providers GAINED are acted on. A provider
// that stops resolving is kept, because a transient environment difference (a
// daemon restarted from a narrower PATH, a version manager mid-upgrade) would
// otherwise tear down a runtime that is working — and possibly executing a
// task. Removal stays the job of an explicit restart, where the user chose the
// environment.
//
// Returns the providers that were added, for logging and tests.
func (d *Daemon) refreshAgentAvailability() []string {
current := d.agents()
probed := probeAgentCLIs()
var gained []string
for name := range probed {
if _, known := current[name]; !known {
gained = append(gained, name)
}
}
if len(gained) == 0 {
return nil
}
sort.Strings(gained)
// Copy-on-write: build the union, then publish. Entries for providers we
// already knew about are preserved as-is so this never fights the pinned
// path / self-heal bookkeeping (resolvedPaths) for a running provider.
merged := make(map[string]AgentEntry, len(current)+len(gained))
for name, entry := range current {
merged[name] = entry
}
for _, name := range gained {
merged[name] = probed[name]
}
d.agentsAvailable.Store(&merged)
d.logger.Info("agent CLI discovered after startup", "providers", gained)
return gained
}
// refreshAgentVersions re-probes installed CLIs and re-registers workspaces
// whose last acknowledged versions differ, without restarting running tasks.
// Failed probes retain previous values and retry on a later round.
//
// Compare against each workspace's accepted registration, not the shared version
// cache: another probe path may update that cache first, and an older registration
// may arrive after a refresh. Per-workspace acknowledgements let the next round
// repair either ordering.
//
// Send the full built-in set and reconcile IDs in place. Run even when runtime
// convergence has pending providers so one below-minimum CLI cannot starve version
// updates for healthy providers.
func (d *Daemon) refreshAgentVersions(ctx context.Context) {
// Nothing has ever been version-detected: the daemon is still starting up,
// and initial registration is the sync path's job.
if !d.hasDetectedAgentVersions() {
return
}
builtins, demotable, _ := d.detectBuiltinRuntimes(ctx)
// Runs before the early return below: a condemned provider drops out of
// builtins entirely, so there is no version change left to detect and the
// machine could otherwise look idle while an unusable CLI keeps taking work.
if len(demotable) > 0 {
d.demoteUnusableRuntimes(ctx, demotable)
}
if len(builtins) == 0 {
return
}
// What this round's payload actually carries, per provider. A provider whose
// probe failed is absent — and only carried providers are compared below,
// which is what stops an uninstalled CLI from triggering registrations. It
// is never dropped from the availability set (refreshAgentAvailability is
// deliberately one-directional), so it keeps failing its probe and never
// reappears in the payload; comparing records against a payload that cannot
// mention it would otherwise turn into one register call per workspace
// every few minutes, forever.
carried := builtinVersionsFromPayload(builtins)
// A workspace is behind when some carried provider's last accepted register
// call for it carried a different version. A provider with no record for a
// workspace is skipped: it has never registered there, so bringing it
// online is the converge path's job, not a version change.
d.mu.Lock()
var behind []string
transitions := make(map[string]struct{})
for id, ws := range d.workspaces {
lagging := false
for provider, version := range carried {
sent, has := ws.builtinVersions[provider]
if !has || sent == version {
continue
}
lagging = true
if sent == "" {
transitions[fmt.Sprintf("%s %s", provider, version)] = struct{}{}
} else {
transitions[fmt.Sprintf("%s %s -> %s", provider, sent, version)] = struct{}{}
}
}
if lagging {
behind = append(behind, id)
}
}
d.mu.Unlock()
if len(behind) == 0 {
return
}
sort.Strings(behind)
advancing := make([]string, 0, len(transitions))
for t := range transitions {
advancing = append(advancing, t)
}
sort.Strings(advancing)
d.logger.Info("agent CLI version not yet on the server; refreshing registration without a restart",
"versions", advancing, "workspace_ids", behind)
// Register only the workspaces that are behind. A failure records nothing,
// so that workspace stays behind and the next round retries it.
var changed bool
for _, id := range behind {
// Send, merge and clean up as one ordered step — see
// workspaceRegisterLock.
_ = d.withWorkspaceRegisterLock(id, func() error {
resp, err := d.registerBuiltinRuntimesForWorkspaceLocked(ctx, id, builtins)
if err != nil {
d.logger.Warn("re-register after agent CLI version change failed; will retry",
"workspace_id", id, "error", err)
return nil
}
newIDs, revived, ok := d.mergeBuiltinRegisterResponse(id, resp)
if !ok {
return nil
}
if len(newIDs) > 0 {
changed = true
}
d.deregisterRevivedRuntimes(ctx, id, revived)
return nil
})
}
if changed {
d.notifyRuntimeSetChanged()
}
}
// demoteUnusableRuntimes takes offline the built-in runtimes of providers whose
// re-probe reached a confirmed verdict about the binary on disk: a version below
// the minimum supported one, or a file the OS refuses to execute at all.
//
// Without this, an in-place DOWNGRADE is the one machine change nothing reacts
// to. probeBuiltinRuntime drops the provider from the registration payload, so
// refreshAgentVersions sees no version to compare; the runtime row survives
// because the built-in merge is additive; and the provider is absent from
// providersMissingRuntimes precisely because it still holds a runtime — so
// converge ignores it too. The daemon keeps routing tasks to a binary it has
// already verified it cannot run correctly, under the previously cached
// version's policy.
//
// A CLI that cannot exec reaches the same dead end by a different road
// (MUL-6164): an npm placeholder stub passes every "is it installed" check the
// daemon makes, so the runtime stays online and every task it claims dies at
// fork/exec. Treating that as transient meant the user saw the same failure once
// per task instead of once, and had no signal the runtime was the problem.
//
// Deregistering is the narrowest honest response. It stops the server routing
// work there, the reason is already visible in skipped_agents, and recovery
// needs no new machinery: once the provider is upgraded it has no runtime, which
// puts it back in providersMissingRuntimes and lets the converge path register
// it again. The alternative — keeping the runtime and refusing the launch — puts
// a check on the hot path and leaves the UI advertising a runtime that rejects
// everything.
//
// Only ever acts on a confirmed verdict, never on a probe that merely failed:
// an unreadable version is transient, and tearing down a working runtime over
// one is exactly the mistake refreshAgentAvailability's one-directional rule
// exists to avoid. detectBuiltinRuntimes owns that distinction, including the
// confirmation window a not-executable verdict must clear first.
//
// causes maps each condemned provider to the evidence against it, which is
// already user-visible in skipped_agents and is what the log below reports.
func (d *Daemon) demoteUnusableRuntimes(ctx context.Context, causes map[string]runtimeVerdict) {
// Serialize with task claiming the way the restart paths do: the barrier
// only sets when no claim is in flight and no task is running, so a
// runtime is never deregistered under a task that is still executing —
// the server's sweep would fail-and-retry that task while the local
// process keeps going, and an upgrade-back would revive the runtime ID
// into a genuine duplicate execution. A busy daemon defers to the next
// refresh tick; the too-old CLI keeps its runtimes a little longer, which
// is the pre-demotion status quo, not a new exposure.
if !d.trySetClaimBarrier() {
d.logger.Info("defer runtime demotion: task or claim in flight",
"providers", causes)
return
}
defer d.releaseClaimBarrier()
d.mu.Lock()
var demoted []string
var installWaits []dshInstallWait
// Grouped per workspace because the cleanup below has to run under each
// workspace's register lock — see deregisterDroppedRuntimes.
demotedByWorkspace := make(map[string][]string)
demotedProviders := make(map[string]string)
offlineReasons := make(map[string]RuntimeOfflineReason)
for workspaceID, ws := range d.workspaces {
// A fresh array, not ws.runtimeIDs[:0]: the health handler copies this
// slice header under d.mu and serializes it after releasing the lock
// (removeStaleRuntime keeps the same rule), so filtering in place would
// write into a slice another goroutine is reading.
kept := ws.runtimeIDs[:0:0]
for _, rid := range ws.runtimeIDs {
rt, ok := d.runtimeIndex[rid]
if !ok || rt.ProfileID != "" {
kept = append(kept, rid)
continue
}
cause, condemned := causes[rt.Provider]
if !condemned {
kept = append(kept, rid)
continue
}
delete(d.runtimeIndex, rid)
demoted = append(demoted, rid)
demotedByWorkspace[workspaceID] = append(demotedByWorkspace[workspaceID], rid)
demotedProviders[rt.Provider] = cause.reason
// Carried to the server per runtime row: a client asking "why is my
// agent offline" reads it from there, and only this side knows both
// the verdict and the repair command (MUL-6164).
if cause.offline != nil {
offlineReasons[rid] = *cause.offline
// A reason that says an install is running is a promise this
// row comes back on its own. Remember it so the install can
// take the promise back if it ends without a profile.
if cause.offline.Installing {
installWaits = append(installWaits, dshInstallWait{
workspaceID: workspaceID, runtimeID: rid, reason: *cause.offline,
})
}
}
// The runtime is gone, so the record of what was registered for it
// goes too. When the provider recovers, converge re-registers it and
// the record is re-seeded — first sighting is converge's job, not a
// version change for the refresh round to chase.
delete(ws.builtinVersions, rt.Provider)
}
ws.runtimeIDs = kept
}
// Remember the verdict before releasing d.mu. Removing the rows is not
// enough: a register sent before this demotion is still in flight, and its
// response would re-index the provider the moment it lands. The apply paths
// consult this under the same lock, so the two are totally ordered.
d.markProvidersDemotedLocked(causes)
d.dshInstallWaits = append(d.dshInstallWaits, installWaits...)
d.mu.Unlock()
if len(demoted) == 0 {
return
}
d.logger.Warn("agent CLI is no longer usable on this machine; taking its runtimes offline",
"providers", demotedProviders, "runtime_ids", demoted)
// One workspace at a time, each under its own register lock: the rows were
// dropped under d.mu but the requests go out without it, so an upgrade that
// recovered the provider in between must not be undone by this older
// cleanup. Sorted so the log order is deterministic; never two locks at
// once, so there is no ordering to deadlock on.
workspaceIDs := make([]string, 0, len(demotedByWorkspace))
for workspaceID := range demotedByWorkspace {
workspaceIDs = append(workspaceIDs, workspaceID)
}
sort.Strings(workspaceIDs)
for _, workspaceID := range workspaceIDs {
_ = d.withWorkspaceRegisterLock(workspaceID, func() error {
d.deregisterDroppedRuntimes(ctx, workspaceID, demotedByWorkspace[workspaceID],
"agent CLI unusable", offlineReasons)
return nil
})
}
d.notifyRuntimeSetChanged()
}
// dshInstallWait is one runtime row whose offline reason claims an automatic
// profile install will bring it back.
type dshInstallWait struct {
workspaceID string
runtimeID string
reason RuntimeOfflineReason
}
// withdrawDshInstallWait rewrites the offline reason of every runtime that was
// taken offline while an automatic profile install was still running, once that
// install has ended without producing a profile.
//
// Without it the claim is permanent. `installing` is what tells the server this
// particular offline runtime is worth queueing behind (MUL-6164), and the row
// carrying it is written exactly once, by the demotion — which also removes the
// runtime from the index, so no later round finds a runtime to condemn and no
// later deregistration is sent. An install that fails after that point leaves
// every assignment and @mention for the agent queueing behind a wait that has
// already given up, which is the failure the structured reason exists to
// prevent, not a new one to introduce.
//
// Best-effort, like every other deregistration path: the reason is a hint on a
// row that is already offline, and the row is corrected again the moment the
// provider registers.
func (d *Daemon) withdrawDshInstallWait(ctx context.Context) {
d.mu.Lock()
waits := d.dshInstallWaits
d.dshInstallWaits = nil
d.mu.Unlock()
if len(waits) == 0 {
return
}
byWorkspace := make(map[string][]dshInstallWait, len(waits))
for _, wait := range waits {
byWorkspace[wait.workspaceID] = append(byWorkspace[wait.workspaceID], wait)
}
workspaceIDs := make([]string, 0, len(byWorkspace))
for workspaceID := range byWorkspace {
workspaceIDs = append(workspaceIDs, workspaceID)
}
sort.Strings(workspaceIDs)
for _, workspaceID := range workspaceIDs {
_ = d.withWorkspaceRegisterLock(workspaceID, func() error {
runtimeIDs := make([]string, 0, len(byWorkspace[workspaceID]))
reasons := make(map[string]RuntimeOfflineReason, len(byWorkspace[workspaceID]))
for _, wait := range byWorkspace[workspaceID] {
reason := wait.reason
reason.Installing = false
reason.Detail = dshInstallGaveUpReason
runtimeIDs = append(runtimeIDs, wait.runtimeID)
reasons[wait.runtimeID] = reason
}
// Same tracking re-check every deregistration path makes: a
// register that brought the runtime back between the demotion and
// now is newer than this correction, and its row already says
// online.
runtimeIDs = d.untrackedRuntimeIDs(runtimeIDs)
if len(runtimeIDs) == 0 {
return nil
}
d.logger.Warn("automatic DSH profile install gave up; withdrawing the wait from its runtimes",
"workspace_id", workspaceID, "runtime_ids", runtimeIDs)
if err := d.client.Deregister(ctx, runtimeIDs, reasons); err != nil {
d.logger.Warn("withdrawing the DSH install wait failed",
"workspace_id", workspaceID, "runtime_ids", runtimeIDs, "error", err)
}
return nil
})
}
}
// deregisterRevivedRuntimes takes offline the rows a register response brought
// back for a provider that was demoted while that register was in flight.
//
// The local apply already refused them, so without this the server would be the
// only side still believing the runtime is online: it would keep the row in the
// runtime list and route work to a daemon that no longer tracks it, and those
// tasks would sit unclaimed until the stale-heartbeat sweep. Best-effort for the
// same reason every other deregistration path is — the sweep is the backstop.
//
// The caller must hold the workspace's register lock (workspaceRegisterLock).
func (d *Daemon) deregisterRevivedRuntimes(ctx context.Context, workspaceID string, revived revivedRuntimes) {
runtimeIDs := revived.ids
// Re-check tracking first: the rejection happened under d.mu but this call
// runs without it, and a legitimate recovery register landing in that gap
// re-creates the same row — usually under the same ID. Deregistering then
// would take the recovered runtime offline on the strength of an older
// decision. Anything the daemon tracks now is newer than this cleanup, and
// the register lock is what stops a recovery slipping in between this check
// and the request below.
runtimeIDs = d.untrackedRuntimeIDs(runtimeIDs)
if len(runtimeIDs) == 0 {
return
}
d.logger.Warn("register response revived a demoted runtime; taking it offline again",
"workspace_id", workspaceID, "runtime_ids", runtimeIDs)
// Re-send the cause: that register's upsert overwrote the runtime row's
// metadata, so the reason the demotion stored is gone and the server would
// otherwise be left with a bare "offline" — which reads as "wait for the
// machine" on every admission path (MUL-6164).
if err := d.client.Deregister(ctx, runtimeIDs, revived.reasonsFor(runtimeIDs)); err != nil {
d.logger.Warn("deregister of revived demoted runtimes failed",
"workspace_id", workspaceID, "runtime_ids", runtimeIDs, "error", err)
}
}
// deregisterDroppedRuntimes takes offline the server-side rows a convergence
// decided this workspace should no longer host — a provider removed from the
// daemon's config, a disabled custom profile, a workspace converging to zero.
//
// Same contract as deregisterRevivedRuntimes: the caller must hold the
// workspace's register lock, so the tracking re-check and the request are one
// ordered step against every other registration for this workspace. Without the
// lock this is a TOCTOU — a recovery register completing between the two puts
// the row back and this older cleanup knocks it out, leaving the daemon tracking
// a runtime the server has offline.
//
// Best-effort: the daemon has already stopped heartbeating these rows, so the
// server's stale-heartbeat sweep is the backstop if the call fails.
func (d *Daemon) deregisterDroppedRuntimes(ctx context.Context, workspaceID string, runtimeIDs []string, reason string, offlineReasons map[string]RuntimeOfflineReason) {
runtimeIDs = d.untrackedRuntimeIDs(runtimeIDs)
if len(runtimeIDs) == 0 {
return
}
if err := d.client.Deregister(ctx, runtimeIDs, offlineReasons); err != nil {
d.logger.Warn("deregister of dropped runtimes failed",
"workspace_id", workspaceID, "runtime_ids", runtimeIDs, "reason", reason, "error", err)
}
}
// providersMissingRuntimes returns the discovered providers that do not have a
// built-in runtime registered for every tracked workspace.
//
// This is the retry signal for the discovery loop, and it is derived from live
// state rather than remembered: a provider only leaves this set once the daemon
// actually holds a runtime row for it in every workspace it watches. Custom
// profile runtimes (ProfileID set) are ignored — they register from a
// workspace's profile list, not from CLI discovery, and a profile that happens
// to share a protocol_family with a built-in must not mask the built-in.
//
// Returns nothing when no workspace is tracked yet: there is nothing to
// register against, and the initial registration will carry the full set.
func (d *Daemon) providersMissingRuntimes() []string {
available := d.agents()
if len(available) == 0 {
return nil
}
d.mu.Lock()
defer d.mu.Unlock()
if len(d.workspaces) == 0 {
return nil
}
missing := make(map[string]struct{})
for _, ws := range d.workspaces {
registered := make(map[string]struct{}, len(ws.runtimeIDs))
for _, id := range ws.runtimeIDs {
rt, ok := d.runtimeIndex[id]
if !ok || rt.ProfileID != "" {
continue
}
registered[rt.Provider] = struct{}{}
}
for name := range available {
if _, ok := registered[name]; !ok {
missing[name] = struct{}{}
}
}
}
if len(missing) == 0 {
return nil
}
out := make([]string, 0, len(missing))
for name := range missing {
out = append(out, name)
}
sort.Strings(out)
return out
}
// convergeRuntimeRegistrations registers the current built-in set for every
// tracked workspace that is missing one of them.
//
// One version-probe round serves every workspace (built-in CLIs are per
// machine, not per workspace). Failures are logged and left for the next
// discovery tick — nothing here records "already handled", so a partial
// failure across workspaces retries only the workspaces that still need it.
//
// Strictly built-ins: this path never fetches, sends, or observes custom runtime
// profiles. Custom profile add/edit/disable remains owned by the drift path
// (refreshWorkspaceRuntimeProfiles), which is the only place allowed to cache a
// profile-set signature.
//
// RecoverOrphans is deliberately NOT called: unlike the runtime_gone recovery
// path, the surviving runtime IDs may still be executing tasks for the user,
// and failing those as orphans would kill live work (MUL-3332).
func (d *Daemon) convergeRuntimeRegistrations(ctx context.Context) {
// detectBuiltinRuntimes version-gates the availability set and publishes
// this round's drops for /health, so a provider that cannot register still
// gets a visible reason even though registration is skipped.
builtins, demotable, _ := d.detectBuiltinRuntimes(ctx)
// Condemned providers drop out of builtins entirely, so acting on them
// before the early return is what keeps a dsh that goes bad mid-flight from
// staying online: the profile removed while dsh is registered leaves nothing
// missing a runtime, and a round reached only for that has no registration
// left to do. demoteUnusableRuntimes owns the claim barrier and the
// seq-stamped hold, so running it here is safe.
//
// Restricted to dsh on purpose. Every other provider is demoted from
// refreshAgentVersions alone, on its own ten-minute cadence, and that is the
// schedule their verdicts were tuned against; letting a converge round
// condemn them too would move Claude Code, Codex and the rest onto a
// different one as a side effect of a DSH fix. dsh is the only provider with
// a precondition that can change without any version changing, which is why
// it is the only one that needs a round it can be forced into.
if dshDemotable := dshOnlyVerdicts(demotable); len(dshDemotable) > 0 {
d.demoteUnusableRuntimes(ctx, dshDemotable)
}
if len(builtins) == 0 {
return
}
detected := make(map[string]struct{}, len(builtins))
for _, rt := range builtins {
detected[rt["type"]] = struct{}{}
}
d.mu.Lock()
type target struct {
id string
missing []string
}
var targets []target
for id, ws := range d.workspaces {
registered := make(map[string]struct{}, len(ws.runtimeIDs))
for _, rid := range ws.runtimeIDs {
rt, ok := d.runtimeIndex[rid]
if !ok || rt.ProfileID != "" {
continue
}
registered[rt.Provider] = struct{}{}
}
var missing []string
for name := range detected {
if _, ok := registered[name]; !ok {
missing = append(missing, name)
}
}
if len(missing) > 0 {
sort.Strings(missing)
targets = append(targets, target{id: id, missing: missing})
}
}
d.mu.Unlock()
if len(targets) == 0 {
return
}
sort.Slice(targets, func(i, j int) bool { return targets[i].id < targets[j].id })
var changed bool
for _, t := range targets {
// Send, merge and clean up as one ordered step — see
// workspaceRegisterLock.
_ = d.withWorkspaceRegisterLock(t.id, func() error {
resp, err := d.registerBuiltinRuntimesForWorkspaceLocked(ctx, t.id, builtins)
if err != nil {
// Left in the missing set on purpose: the next tick retries.
d.logger.Warn("register newly available runtimes failed; will retry",
"workspace_id", t.id, "providers", t.missing, "error", err)
return nil
}
newIDs, revived, ok := d.mergeBuiltinRegisterResponse(t.id, resp)
if !ok {
return nil
}
if len(newIDs) > 0 {
changed = true
d.logger.Info("registered runtime for newly installed agent CLI",
"workspace_id", t.id, "providers", t.missing, "runtime_ids", newIDs)
}
d.deregisterRevivedRuntimes(ctx, t.id, revived)
return nil
})
}
if changed {
d.notifyRuntimeSetChanged()
}
}