Files
multica/server/internal/daemon/daemon.go
700d25eef5 MUL-7420: render the workflow brief once on the server, for the assignee or an observer (#9293)
- issueworkflow.Brief is the only renderer: the claim carries it into every
  run's per-turn prompt; the editor preview and timeline show the same text.
- Workflow handoffs stop writing the brief into handoff_note, so a person's
  note no longer drops the brief and the handoff framing fits again.
- Agents that are not the issue's assignee get an observer brief without
  the step's commands.
- Clearer rule override and narrower wakeup guidance; user text is
  stripped of control characters.
- multica-platform skill documents project workflows.

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-10-16 17:28:45 +08:00

10625 lines
451 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package daemon
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"hash/fnv"
"log/slog"
"math/rand"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/google/uuid"
"golang.org/x/sync/errgroup"
"golang.org/x/sync/singleflight"
"github.com/multica-ai/multica/server/internal/cli"
"github.com/multica-ai/multica/server/internal/daemon/execenv"
"github.com/multica-ai/multica/server/internal/daemon/repocache"
"github.com/multica-ai/multica/server/internal/selfexec"
"github.com/multica-ai/multica/server/internal/util"
"github.com/multica-ai/multica/server/pkg/agent"
"github.com/multica-ai/multica/server/pkg/awakeclock"
"github.com/multica-ai/multica/server/pkg/protocol"
"github.com/multica-ai/multica/server/pkg/redact"
"github.com/multica-ai/multica/server/pkg/skillbundle"
"github.com/multica-ai/multica/server/pkg/taskfailure"
)
// ErrRepoNotConfigured is returned by ensureRepoReady when the requested repo
// URL is not present in the workspace's repo configuration after a fresh
// server refresh.
var ErrRepoNotConfigured = errors.New("repo is not configured for this workspace")
// ErrNoRuntimesToRegister is returned by registerRuntimesForWorkspace when
// the daemon has nothing to host on a workspace — typically a custom-only
// daemon whose only enabled custom runtime profile was just disabled, leaving
// zero built-in agents and zero resolvable profiles. Callers must
// differentiate by intent: initial registration (syncWorkspacesFromAPI's
// new-workspace branch) treats this as a config error and skips the
// workspace until something changes; the profile-drift refresh path
// (refreshWorkspaceRuntimeProfiles) treats it as a legitimate converged
// state and explicitly deregisters the now-stale local runtime IDs so the
// server marks them offline immediately instead of waiting on the 150 s
// stale-heartbeat sweep.
var ErrNoRuntimesToRegister = errors.New("no agent runtimes could be registered")
// errNoWorkspaceRuntimesRegistered is returned by syncWorkspacesFromAPI when a
// round left every workspace without a runtime. At startup that is normally
// fatal — a daemon hosting nothing has nothing to do, and failing loudly beats
// idling — which is why it is a sentinel rather than a bare string: exactly one
// caller is allowed to make an exception to it, and only for the one situation
// where the daemon knows the answer is on its way. See
// startupMayProceedWithoutRuntimes.
var errNoWorkspaceRuntimesRegistered = errors.New("failed to register runtimes")
// errTaskPrepareTimeout distinguishes the daemon's dispatched -> running
// startup deadline from provider execution timeouts. handleTask maps it to the
// platform-side timeout failure reason so the server's existing retry path can
// recover the task on a fresh attempt.
var errTaskPrepareTimeout = errors.New("task preparation timed out")
// errSkillBundleUnavailable marks a task that died in preparation because the
// daemon could not download one of the agent's skill bundles. Carrying it as a
// sentinel — rather than leaving handleTask to pattern-match the wrapped
// transport error — is what lets the failure land on the platform-side
// skill_bundle_unavailable reason instead of agent_error.unknown, which is not
// on the server's retry allowlist. (MUL-5370)
var errSkillBundleUnavailable = errors.New("skill bundle unavailable")
const (
taskSlotWaitTimeout = 2 * time.Second
taskSlotCapacityBackoff = 5 * time.Second
repoCheckoutModeEnv = "MULTICA_REPO_CHECKOUT_MODE"
repoCheckoutModeIsolated = "isolated"
// defaultTaskPrepareTimeout is a hard liveness bound for everything after
// claim and before StartTask succeeds: runtime resolution, skill bundles,
// execution-environment setup, and the StartTask request itself. It is
// intentionally independent from AgentTimeout, which only governs the
// provider process after the task reaches running.
defaultTaskPrepareTimeout = 5 * time.Minute
// pendingWorkHeartbeatTimeout bounds the out-of-band heartbeat a
// server-pushed daemon:pending_work hint triggers (MUL-5444). Short on
// purpose: the hint is only a latency optimisation, and the scheduled
// heartbeat still picks the request up if this attempt fails.
pendingWorkHeartbeatTimeout = 15 * time.Second
// pendingWorkHintBookkeepingTTL is how long a runtime's last-hint timestamp
// is retained before it is swept — purely to keep the map bounded.
pendingWorkHintBookkeepingTTL = 10 * time.Minute
// idleWatchdogMaxTick caps the idle watchdog's polling interval. At the
// base rate of window/2 the overshoot scales with the budget: a 2h window
// would only be checked hourly, so a genuinely stuck run could hold its
// slot for 3h. The cap makes worst-case detection window + 5m no matter how
// large an operator sets the budget.
//
// 5m is chosen as the largest overshoot worth tolerating on top of a budget
// already measured in hours, not for its polling cost — a tick is an atomic
// load and a channel length check, so it is free at any interval anyone
// would pick.
idleWatchdogMaxTick = 5 * time.Minute
)
// pendingWorkHintMinInterval is the floor between two hint-driven heartbeats
// for the same runtime. Keeps an interactive first open instant while stopping a
// caller-triggered hint from becoming a heartbeat amplifier. A var so tests can
// shrink it, same as the other timing knobs in this package.
var pendingWorkHintMinInterval = time.Second
// repoCheckoutModeFor picks the Git metadata layout for a task's
// `multica repo checkout`. Under Codex's workspace-write sandbox a linked
// worktree's gitdir resolves into the shared cache and stays read-only even
// when the task workdir is an explicit writable root, so `git add` /
// `git commit` fail from inside the checkout — Linux hit this in
// multica-ai/multica#2925, Codex's native Windows sandbox in
// multica-ai/multica#6449.
//
// Both platforms now default to danger-full-access (execenv's
// codexSandboxPolicyFor), so in practice only a user who opted into
// windows.sandbox still trips the Windows case. The layout stays a per-platform
// choice rather than a per-policy one: it is decided before a task's resolved
// sandbox config is known, one workdir is reused across tasks whose policies
// can differ, and task-local metadata is correct under either policy.
func repoCheckoutModeFor(provider, goos string) string {
if provider != "codex" {
return ""
}
switch goos {
case "linux", "windows":
return repoCheckoutModeIsolated
default:
return ""
}
}
const (
taskPrepareLeaseRefresh = 15 * time.Second
taskPrepareLeaseTimeout = 10 * time.Second
)
var errInvalidTaskIdentity = errors.New("invalid task identity")
func validateTaskIdentity(task Task) error {
if strings.TrimSpace(task.AgentID) == "" {
return fmt.Errorf("%w: task %s has no authoritative agent_id", errInvalidTaskIdentity, task.ID)
}
if task.Agent == nil {
return fmt.Errorf("%w: task %s has no agent payload (agent_id=%s)", errInvalidTaskIdentity, task.ID, task.AgentID)
}
if task.Agent.ID != task.AgentID {
return fmt.Errorf("%w: task %s agent_id=%s but agent.id=%s", errInvalidTaskIdentity, task.ID, task.AgentID, task.Agent.ID)
}
return nil
}
func taskScopedAuthToken(task Task) (string, error) {
token := strings.TrimSpace(task.AuthToken)
if token == "" {
return "", errors.New("server did not provide task-scoped auth token")
}
if !strings.HasPrefix(token, "mat_") {
return "", errors.New("server provided non-task-scoped auth token")
}
return token, nil
}
func taskMulticaEnvironment(task Task, provider, agentName, token, configRoot, workspacesRoot, serverURL string, healthPort, slot int, tempDir string) map[string]string {
env := map[string]string{
"MULTICA_TOKEN": token,
cli.TaskConfigRootEnv: configRoot,
TaskWorkspacesRootEnv: workspacesRoot,
"MULTICA_SERVER_URL": serverURL,
"MULTICA_DAEMON_PORT": strconv.Itoa(healthPort),
"MULTICA_WORKSPACE_ID": task.WorkspaceID,
"MULTICA_AGENT_NAME": agentName,
"MULTICA_AGENT_ID": task.AgentID,
"MULTICA_TASK_ID": task.ID,
"MULTICA_TASK_SLOT": strconv.Itoa(slot),
"TMPDIR": tempDir,
"TMP": tempDir,
"TEMP": tempDir,
}
if runtime.GOOS != "windows" {
return env
}
// MSYS (Git Bash) resolves its `/tmp usertemp` mount from TMP/TEMP once,
// in the first process, and every live MSYS process of this user shares
// that mount table — whichever provider started it. A task-private TMP/TEMP
// would pin /tmp to a directory this daemon deletes when that task ends,
// breaking /tmp for every later task (#8824). So on Windows no task
// overrides TMP/TEMP; they inherit the daemon user's host temp, and only
// TMPDIR stays task-private.
delete(env, "TMP")
delete(env, "TEMP")
if provider == "claude" {
// Claude Code reads CLAUDE_CODE_TMPDIR for its own temp files; default
// it to the task dir so they stay private, unless the user set it.
if os.Getenv("CLAUDE_CODE_TMPDIR") == "" {
env["CLAUDE_CODE_TMPDIR"] = tempDir
if task.Agent != nil {
for key := range task.Agent.CustomEnv {
if strings.EqualFold(key, "CLAUDE_CODE_TMPDIR") {
delete(env, "CLAUDE_CODE_TMPDIR")
}
}
}
}
}
return env
}
// taskRunner executes a single agent task and returns the result.
// Extracted as an interface so tests can inject a fake without spawning real
// agent processes, while keeping test scaffolding out of the production struct.
type taskRunner interface {
run(ctx context.Context, task Task, provider string, slot int, log *slog.Logger) (TaskResult, error)
}
// taskRunnerFunc adapts a plain function to the taskRunner interface.
type taskRunnerFunc func(context.Context, Task, string, int, *slog.Logger) (TaskResult, error)
func (f taskRunnerFunc) run(ctx context.Context, task Task, provider string, slot int, log *slog.Logger) (TaskResult, error) {
return f(ctx, task, provider, slot, log)
}
type terminalTaskReportKind uint8
const (
terminalTaskReportComplete terminalTaskReportKind = iota + 1
terminalTaskReportFail
// CompleteTask and FailTask can make six 30-second HTTP attempts around
// the five backoffs in defaultTerminalRetrySchedule (124 seconds total).
// Keep the detached callback's own deadline above that worst-case budget
// so it does not silently shorten the client's existing retry contract.
// During daemon restart pollLoop still imposes its separate 30-second
// process drain boundary.
terminalTaskReportTimeout = 6 * time.Minute
)
// terminalTaskReport is the single daemon-side representation of a terminal
// callback. Keeping every complete/fail path behind this value and
// reportTerminalTask gives the durable outbox one insertion point without
// revisiting every task exit when it is added.
type terminalTaskReport struct {
kind terminalTaskReportKind
taskID string
output string
branchName string
errorMessage string
sessionID string
workDir string
durableWorkDir string
failureReason string
// sessionRolloutMissing is true when the daemon withheld this task's Codex
// session because its rollout was not in the store (MUL-5305). The server
// clears the resume pointer and flags the continuity gap for the next claim.
sessionRolloutMissing bool
// retiredSessionID names a session this run was told to resume and then
// abandoned as unresumable (GH #6066). The server records it so no later
// run on the issue or chat can select it again, however many clean rows
// still reference it.
retiredSessionID string
}
type terminalReportSendFunc func(context.Context, terminalTaskReport, []time.Duration) error
type executionEnvironmentCommand func() ([]string, error)
func defaultExecutionEnvironmentCommand() ([]string, error) {
executable, err := resolveSelfExecutable()
if err != nil {
return nil, fmt.Errorf("resolve execution-environment helper: %w", err)
}
return []string{executable, execenv.PreparationHelperArg}, nil
}
var (
isBrewInstall = cli.IsBrewInstall
getBrewPrefix = cli.GetBrewPrefix
matchKnownBrewPrefix = cli.MatchKnownBrewPrefix
resolveSelfExecutable = selfexec.Resolve
// detectAgentVersion / checkAgentMinVersion are indirections over the
// real agent helpers so tests can run the registration path without
// shelling out to a real CLI. Mirrors the pattern used for the brew
// helpers above.
detectAgentVersion = agent.DetectVersion
checkAgentMinVersion = agent.CheckMinVersion
// listModels is an indirection over agent.ListModels so model-discovery
// tests can assert which executable path the daemon enumerates without
// shelling out to a real CLI. Mirrors the detectAgentVersion hook above.
listModels = agent.ListModels
// lookPath is an indirection over exec.LookPath so registration tests can
// resolve custom runtime-profile commands without manipulating the
// process PATH. Mirrors the detectAgentVersion hook above.
lookPath = exec.LookPath
// resolveProfileOverridePath is what appendProfileRuntimes uses before
// trusting a per-machine command path override (MUL-3284). It must be the
// same contract agent launches use — resolveAgentExecutablePath /
// exec.LookPath — so Windows PATHEXT completion (.cmd shims, extension-less
// pins) and unix exec-bit checks stay in one place. A stale or mistyped
// override must fall back to PATH rather than register a runtime that
// can't launch. Indirected as a package var so override-preference tests
// can decide which paths resolve without staging real files on disk.
resolveProfileOverridePath = resolveAgentExecutablePath
)
// workspaceState tracks registered runtimes for a single workspace.
//
// allowedRepoURLs covers the workspace-level repo bindings; it gets rebuilt on
// every refresh from the server. taskRepoURLs covers repos that the server
// surfaced through a per-task claim (project github_repo resources today,
// possibly other typed sources later) — those don't show up in
// GetWorkspaceRepos, so they would be wiped on refresh if we shared one map.
// taskRepoRefs tracks optional checkout refs for the specific task that
// surfaced each project repo so two projects using the same URL don't leak refs
// into each other.
type workspaceState struct {
workspaceID string
runtimeIDs []string
reposVersion string // stored for future use: skip refresh when version unchanged
allowedRepoURLs map[string]struct{}
taskRepoURLs map[string]struct{}
taskRepoRefs map[string]map[string]string // taskID -> repo URL -> checkout ref
settings json.RawMessage // workspace settings (JSONB)
lastRepoSyncErr string
repoRefreshMu contextLock
// coAuthorPublishMu serializes publication of the Co-authored-by verdict
// for this workspace. Unlike the fields above it is NOT guarded by
// Daemon.mu: it exists precisely so the verdict can be read and written as
// one step without holding the daemon's central lock across file I/O.
coAuthorPublishMu sync.Mutex
// profileSetSig is a content hash of the workspace's custom runtime
// profile list (MUL-3332) as last seen from the server. An on-demand
// refresh compares the live signature with this cached value; any drift
// triggers a re-register so newly-added (or edited / disabled) custom
// runtimes appear without a daemon restart. Empty before the first
// successful profile fetch (older server / network blip); guarded by
// Daemon.mu like every other field on this struct.
profileSetSig string
// builtinVersions records, per built-in provider, the version carried by
// the last register call the server ACCEPTED for this workspace. This is
// the daemon's per-workspace record of what the server knows — which the
// shared agentVersions cache deliberately is not: every probing path
// writes that cache, but each register call covers only the workspaces it
// was invoked for. refreshAgentVersions compares this record against the
// current probe round and re-registers exactly the workspaces that are
// behind. Scoping the acknowledgement to the workspace is what makes a
// concurrent older-generation registration safe: a register that probed
// before an upgrade and lands after everyone else was refreshed simply
// re-creates the mismatch for its own workspace, and the next round
// revisits it. A failed register records nothing, so the workspace stays
// behind and is retried. Guarded by Daemon.mu.
builtinVersions map[string]string
}
// contextLock is a zero-value-ready mutex whose wait can be cancelled. Repo
// checkout requests use it for workspace refresh coalescing so disconnecting a
// client never leaves the handler stuck behind another cold-cache refresh.
type contextLock struct {
once sync.Once
token chan struct{}
}
func (l *contextLock) Lock(ctx context.Context) error {
if err := ctx.Err(); err != nil {
return context.Cause(ctx)
}
l.once.Do(func() {
l.token = make(chan struct{}, 1)
l.token <- struct{}{}
})
select {
case <-ctx.Done():
return context.Cause(ctx)
case <-l.token:
if err := ctx.Err(); err != nil {
l.token <- struct{}{}
return context.Cause(ctx)
}
return nil
}
}
func (l *contextLock) Unlock() {
l.token <- struct{}{}
}
type repoCacheBackend interface {
Lookup(workspaceID, url string) string
BarePath(workspaceID, url string) string
Sync(workspaceID string, repos []repocache.RepoInfo) error
WithRepoLock(barePath string, fn func() error) error
CreateWorktree(params repocache.WorktreeParams) (*repocache.WorktreeResult, error)
}
// Daemon is the local agent runtime that polls for and executes tasks.
type Daemon struct {
cfg Config
client *Client
repoCache repoCacheBackend
skillCache *SkillBundleCache
logger *slog.Logger
// terminalReports is the durable outbox for complete/fail callbacks. The
// sender hook is production-wired through Client and overridable in focused
// tests; terminalReportWakeup coalesces new-report and reconnect nudges.
terminalReports *terminalReportStore
terminalReportSend terminalReportSendFunc
terminalReportWakeup chan struct{}
terminalReportNow func() time.Time
terminalReportMu sync.Mutex
terminalReportFlight map[string]struct{}
mu sync.Mutex
workspaces map[string]*workspaceState
runtimeIndex map[string]Runtime // runtimeID -> Runtime for provider lookups
// profileLaunchSpecs maps a custom runtime profile_id -> the absolute
// executable path plus fixed launch args resolved for that profile
// (MUL-3284). Populated in registerRuntimesForWorkspace when a profile's
// command resolves; read by runTask to launch the custom command for a
// claimed task. Guarded by mu.
profileLaunchSpecs map[string]profileLaunchSpec
reloading sync.Mutex // prevents concurrent workspace syncs
runtimeSet *runtimeSetWatcher // multi-subscriber pub/sub for runtime-set changes
// lifecycleCtx is the context Run was handed, kept so background work that
// must outlive a single probe round — the DSH profile install — is tied to
// the daemon's lifetime instead of to whichever round happened to notice the
// missing profile. Guarded by d.mu: it is written once in Run and read from
// the install goroutine, and "the write happens before every reader starts"
// is a property of statement order in Run that a later edit can quietly
// break. Read it through daemonLifecycleCtx.
lifecycleCtx context.Context
// dshInstallInFlight is true while an automatic DSH profile install is
// running. Read when a verdict becomes an offline reason, so the server can
// tell "wait, this resolves itself" from "a human has to act".
dshInstallInFlight atomic.Bool
// dshInstallWaits are the runtime rows taken offline while an automatic
// profile install was still running, so their stated cause claims the wait
// resolves itself. An install that ends without a profile has to withdraw
// that claim (withdrawDshInstallWait): the demotion already removed these
// runtimes from the index, so no later round condemns them again and no
// later deregistration would correct the row on its own. Guarded by d.mu.
dshInstallWaits []dshInstallWait
// dshProvisionOnce makes the automatic DSH runtime-profile install
// once-per-daemon: it is a network install that writes into the user's DSH
// home, so a failing registry must not be retried every discovery round.
// See startDshProfileProvision.
dshProvisionOnce sync.Once
// agentDiscoveryKick asks agentDiscoveryLoop for an immediate convergence
// round. Buffered and written non-blockingly (kickAgentDiscovery), so a
// producer never waits on the loop and a burst collapses into one round.
// Needed because the loop's scheduled retry can be
// agentConvergeMaxBackoff away, which is far too long to wait after a
// local state change that makes a provider registrable again.
agentDiscoveryKick chan struct{}
versionsMu sync.RWMutex // guards agentVersions
agentVersions map[string]string // provider -> detected CLI version (set during registration)
// registerSerial holds one mutex per workspace (workspace_id ->
// *sync.Mutex), serializing the "send Register, record what it carried"
// critical section (workspaceRegisterLock). Entries are never deleted — a
// daemon tracks a handful of workspaces, and a mutex for a workspace that
// went away is a few bytes, not a leak worth a lifecycle.
registerSerial sync.Map
// agentsAvailable holds the current built-in agent CLI availability set —
// the same shape as cfg.Agents, which it supersedes as the read path.
//
// It is copy-on-write, NOT a mutable map: refreshAgentAvailability swaps in
// a whole new map, and readers (agents()) take the pointer once and then
// only read. cfg.Agents used to be read unlocked from task-execution paths
// (resolveAgentEntry, runTask), so making the discovery set refreshable at
// runtime (MUL-5439) would otherwise be a data race.
agentsAvailable atomic.Pointer[map[string]AgentEntry]
// skippedAgents records why a discovered provider did not make it into the
// last registration round (version undetectable, below minimum). Purely
// diagnostic: surfaced on /health so the UI can tell "not installed" apart
// from "installed but dropped", instead of silently showing nothing
// (MUL-5439). Guarded by skippedAgentsMu.
skippedAgentsMu sync.RWMutex
skippedAgents map[string]string // provider -> human-readable reason
// demotedProviders remembers the built-in providers whose version was
// CONFIRMED below the minimum supported one and whose runtimes
// demoteBelowMinimumRuntimes has already taken offline.
//
// Removing the rows is not enough on its own: a register call that was
// already in flight when the demotion landed still carries the pre-demotion
// payload, and its response arrives afterwards. Every apply path treats a
// response as fresh truth, so it re-indexes the provider locally while the
// server's upsert puts the row back online — reviving a CLI the daemon has
// already proven it cannot run, until the next refresh tick notices.
// Remembering the verdict lets the apply paths reject that late response.
//
// Machine-level, because the verdict is a property of the binary on this
// host rather than of any one workspace: a stale register for workspace A
// must not revive the provider for workspace B either. Cleared by the next
// probe round that finds the provider acceptable again, which is also the
// round that lets converge register it.
//
// Guarded by d.mu — deliberately the same lock every register-response
// apply takes, which is what totally orders "record the verdict" against
// "apply a response" instead of merely narrowing the window between them.
demotedProviders map[string]demotionRecord // provider -> the evidence that condemned it and when
// condemnedSince is when each provider was FIRST observed to be in a
// condemnable state, keyed "<verdict>:<provider>" by condemnedKey, for the
// confirmation window in confirmCondemned. Entries are cleared by the round
// that finds the provider healthy again. Guarded by d.mu.
condemnedSince map[string]time.Time
// demotionSeq is a monotonic counter stamped onto each demotion record so a
// probe round can tell whether its evidence predates a verdict.
//
// Ordering the map writes is not enough on its own: version sampling
// happens outside d.mu, so a round that started earlier, sampled an
// acceptable version, and returned late could otherwise clear a hold
// established by a NEWER below-minimum verdict — and once the hold is gone,
// a stale register response walks through every guard above. A round
// snapshots this counter before it samples anything and may only clear
// holds recorded at or before that snapshot, which makes "my evidence is
// newer than that verdict" a fact rather than a hope. Guarded by d.mu.
demotionSeq uint64
// resolvedPathsMu guards concrete executable paths paired with the version
// detected for each. On POSIX these are self-heals cached after a pinned path
// vanishes (MUL-4486). On Windows they are launch targets resolved from a
// stable installer junction; that junction is followed on every launch so a
// retarget takes effect even while the old release remains installed. Path
// and version are stored together so no reader can launch a new binary under
// stale version policy. Keyed by provider.
resolvedPathsMu sync.RWMutex
resolvedPaths map[string]healedAgent
// healGroup coalesces concurrent self-heal re-resolutions per provider so a
// just-upgraded agent seen by many queued tasks at once pays for a single
// login-shell probe + version detection instead of one per task (MUL-4486).
healGroup singleflight.Group
wsHBMu sync.RWMutex // guards wsHBLastAck
wsHBLastAck map[string]time.Time // runtime_id -> last successful WS heartbeat ack timestamp
// reconcile fans out a "re-check server state now" signal to subscribers
// (watchTaskCancellation, workspaceSyncLoop) so the WS connect/reconnect
// path can shrink coarse fallback reconciliation gaps to sub-second. See
// reconcile.go and runTaskWakeupConnection.
reconcile *reconcileBroadcaster
// workspaceChanges is the account-scoped server hint for membership-set
// changes. It stays separate from reconcile because a membership hint only
// needs the minimal workspace list, while a WS reconnect also reconciles
// runtime profiles that may have changed during the gap.
workspaceChanges *workspaceChangeSignal
// wsRPC carries generic request/response RPCs (e.g. tasks.claim, MUL-4257)
// over the task-wakeup WS connection. It is attached to the live
// connection in runTaskWakeupConnection and detached on disconnect; when
// detached, callers fall back to HTTP.
wsRPC *wsRPCClient
// batchClaimUnsupported is set once a batch claim gets a 404 from the
// server (no /api/daemon/tasks/claim route — an un-upgraded server), so
// subsequent polls skip WS+batch and use the legacy per-runtime claim
// directly. Reset when the WS (re)connects, so a server upgrade that
// bounces the connection re-probes the batch route (MUL-4257).
batchClaimUnsupported atomic.Bool
// wsClaimHTTPFallbackAfter is set after an uncertain WS claim outcome. Once
// the safety delay elapses, the next claim bypasses WS once and uses HTTP so
// a flaky reconnecting WS cannot starve queued tasks indefinitely.
wsClaimHTTPFallbackAfter atomic.Int64
// runtimeGoneMu guards runtimeGoneInflight, reregisterNextAttempt, and
// reregisterLastCompletedAt. The state lets heartbeat / poller / WS-ack
// handlers converge on a single recovery path when they each detect that a
// runtime row was deleted server-side without three of them stampeding
// registerRuntimesForWorkspace.
runtimeGoneMu sync.Mutex
runtimeGoneInflight map[string]struct{} // runtime_id -> currently recovering
reregisterNextAttempt map[string]time.Time // workspace_id -> earliest time the next re-register attempt may run
reregisterLastCompletedAt map[string]time.Time // workspace_id -> wall-clock at which the last SUCCESSFUL re-register call returned (failures intentionally not stamped — see recordRegisterCompletion)
// pendingWorkMu guards pendingWorkInflight and pendingWorkLastRun, which
// coalesce and rate-limit server-pushed "heartbeat now" hints (MUL-5444).
// Several UI surfaces can request the same runtime's model list within
// milliseconds; without the guard each hint would fire its own out-of-band
// heartbeat, and an authenticated caller looping the list-models endpoint
// could turn that into a heartbeat amplifier.
pendingWorkMu sync.Mutex
pendingWorkInflight map[string]struct{} // runtime_id -> hint-driven heartbeat in flight
pendingWorkLastRun map[string]time.Time // runtime_id -> when the last hint-driven heartbeat started
cancelFunc context.CancelFunc // set by Run(); called by triggerRestart
rootCtx context.Context // set by Run(); used by long-running recoveries that must survive per-runtime ctx cancellation
// restartMu guards restartBinary. Two goroutines can reach triggerRestart —
// the server-triggered handleUpdate and the autoUpdateLoop — and
// trySelfReload reads RestartBinary() from the latter to avoid racing the
// former into a second handoff.
restartMu sync.Mutex
restartBinary string // non-empty after a successful update; path to the new binary
// brewTargetOnce caches the brew half of restartTargetBinary. The install
// method and brew prefix cannot change for the lifetime of the process, and
// trySelfReload now calls restartTargetBinary every check tick — without
// the cache that is up to two uncached `brew --prefix` forks per tick.
brewTargetOnce sync.Once
brewInstall bool // resolved once: was this binary installed via brew?
brewPrefix string // Homebrew prefix when brewInstall and it resolved; the stable path is derived per call by cli.StableExecutablePath
updating atomic.Bool // prevents concurrent update attempts
// activeTasks is the ownership-safe count of tasks currently in handleTask.
// It deliberately includes preparation and local-directory waiters because
// restart/update barriers must not kill any claimed task.
activeTasks atomic.Int64
// runningTasks counts live provider execution sessions, beginning only after
// backend.Execute returns. It can briefly lag the server-side running state,
// which starts during preparation before provider launch. resourceWaitTasks
// counts tasks blocked on a local_directory path mutex. Both are diagnostic
// /health dimensions and must never replace activeTasks in safety barriers.
runningTasks atomic.Int64
resourceWaitTasks atomic.Int64
ready atomic.Bool // false until preflight completes; gates /health status (starting -> running)
// reloadPendingReason explains why a confirmed multica version change hasn't
// restarted the daemon yet (a task was running at the barrier check). Set
// and cleared by trySelfReload, read by /health. Diagnostic only.
reloadPendingReason atomic.Pointer[string]
// claimMu guards pauseClaims and claimsInFlight. It is held only for the
// microseconds it takes to make a decision; ClaimTask itself runs without
// the lock so a slow per-runtime claim cannot stall auto-update or any
// other poller.
//
// The pair is the auto-update path's barrier against the issue's
// requirement that "升级过程中如果有 task 进来,会延后升级而不是中断 task":
// runRuntimePoller refuses to call ClaimTask while pauseClaims is set, and
// tryAutoUpdate refuses to flip pauseClaims while any poller is mid-claim
// or any task is in handleTask. Together that closes the fetch-then-claim
// race where a new task slipping in during the release-metadata fetch
// would be cancelled by triggerRestart's root-ctx cancel.
claimMu sync.Mutex
pauseClaims bool // when true, the batch poller skips claiming
claimsInFlight int // pollers that have decided to claim but haven't yet handed the task off to handleTask
activeEnvRootsMu sync.Mutex
activeEnvRootsCond *sync.Cond // signalled when an in-flight env-root GC mutation finishes
activeEnvRoots map[string]int // env root path -> reference count (handles reuse paths marked twice)
deletingEnvRoots map[string]bool // env roots reserved by GC; new tasks wait until the mutation finishes
activeStoresMu sync.Mutex
activeStoresCond *sync.Cond // signalled when an in-flight store deletion finishes, so a blocked markActive can proceed
activeStores map[string]int // persistent store path (per-conversation Codex sessions, per-agent Hermes memories) -> live-task refcount; guards the store from GC mid-task (MUL-4424)
deletingStores map[string]bool // store paths a GC delete has reserved; markActive waits these out so a task never mounts a store mid-removal
// repoCheckoutTasks binds the localhost /repo/checkout endpoint to the
// task-scoped bearer token of a currently running agent. The request body is
// never an identity source: workspace, task, agent, and allowed workdir all
// come from this registry.
repoCheckoutTasksMu sync.RWMutex
repoCheckoutTasks map[string]activeRepoCheckoutTask
// localPathLocks serialises agent tasks whose project resource is a
// local_directory pinned to this daemon. Two tasks targeting the same
// on-disk path run sequentially; the second blocks on the lock and is
// surfaced via the server-side waiting_local_directory status while it
// waits. See MUL-2663.
localPathLocks *LocalPathLocker
// bgSyncs tracks background goroutines started by registerTaskRepos so
// callers (notably tests using t.TempDir-backed cache roots) can wait for
// them to drain before tearing the daemon down. Without this the bg
// goroutine can race against t.TempDir cleanup, leaving a partially
// deleted bare clone and an unrelated `not empty` cleanup failure.
bgSyncs sync.WaitGroup
runner taskRunner // executes agent tasks; set to d.runTask by New(), overridable in tests
cancelPollInterval time.Duration // how often handleTask polls for server-side cancellation; overridable in tests
// taskSlotWait is the brief semaphore wait before the capacity backoff.
// New sets the production default; tests shorten it to reach that branch.
taskSlotWait time.Duration
// taskSupplementSignals carries content-free server hints to the exact
// negotiated task. The two intervals are production defaults in New and
// independently overridable by focused tests.
taskSupplementSignals taskSupplementSignals
taskSupplementPollInterval time.Duration
taskSupplementReadyInterval time.Duration
// envRootBusyWait is how long a task that is entitled to a prior env root
// waits for the previous run to let go of it before giving up and preparing
// a fresh one. New() sets it; the zero value means "do not wait", which is
// what focused unit tests want. See lockReusablePriorEnvRoot (MUL-6880).
envRootBusyWait time.Duration
// executionEnvironmentCommand resolves the killable helper used for
// Prepare/Reuse. New always sets it; nil keeps focused unit tests in-process.
executionEnvironmentCommand executionEnvironmentCommand
// taskPrepareTimeout is the dispatched -> running hard deadline. New sets
// the production default; zero-valued test daemons fall back to the same
// default in effectiveTaskPrepareTimeout.
taskPrepareTimeout time.Duration
// prepareLeaseRefresh is how often a preparing task extends its prepare
// lease. New sets the production default; zero-valued test daemons fall
// back to the same default in startTaskPrepareLeaseExtender.
prepareLeaseRefresh time.Duration
// runUpdateFn executes the brew-or-download upgrade. Set to d.runUpdate by
// New() and overridable in tests so the auto-update poller can be exercised
// without touching the real network or the brew CLI.
runUpdateFn func(targetVersion string) (string, error)
}
type profileLaunchSpec struct {
path string
version string
fixedArgs []string
}
// New creates a new Daemon instance.
func New(cfg Config, logger *slog.Logger) *Daemon {
cacheRoot := filepath.Join(cfg.WorkspacesRoot, ".repos")
skillCacheRoot := filepath.Join(cfg.WorkspacesRoot, ".skill-cache", "v1")
client := NewClient(cfg.ServerBaseURL)
// Tag every daemon HTTP request with the daemon's CLI version so the
// server can split logs/metrics by client version (parallel to the CLI).
client.SetVersion(cfg.CLIVersion)
d := &Daemon{
cfg: cfg,
client: client,
repoCache: repocache.New(cacheRoot, logger),
skillCache: NewSkillBundleCache(skillCacheRoot),
logger: logger,
terminalReports: newTerminalReportStore(cfg),
terminalReportWakeup: make(chan struct{}, 1),
terminalReportNow: time.Now,
terminalReportFlight: make(map[string]struct{}),
workspaces: make(map[string]*workspaceState),
runtimeIndex: make(map[string]Runtime),
profileLaunchSpecs: make(map[string]profileLaunchSpec),
runtimeSet: newRuntimeSetWatcher(),
agentDiscoveryKick: make(chan struct{}, 1),
agentVersions: make(map[string]string),
skippedAgents: make(map[string]string),
resolvedPaths: make(map[string]healedAgent),
wsHBLastAck: make(map[string]time.Time),
activeEnvRoots: make(map[string]int),
deletingEnvRoots: make(map[string]bool),
activeStores: make(map[string]int),
deletingStores: make(map[string]bool),
localPathLocks: NewLocalPathLocker(),
runtimeGoneInflight: make(map[string]struct{}),
pendingWorkInflight: make(map[string]struct{}),
pendingWorkLastRun: make(map[string]time.Time),
reregisterNextAttempt: make(map[string]time.Time),
reregisterLastCompletedAt: make(map[string]time.Time),
cancelPollInterval: 5 * time.Second,
taskSlotWait: taskSlotWaitTimeout,
taskSupplementPollInterval: defaultTaskSupplementPollInterval,
taskSupplementReadyInterval: defaultTaskSupplementReadyInterval,
envRootBusyWait: 15 * time.Second,
taskPrepareTimeout: defaultTaskPrepareTimeout,
prepareLeaseRefresh: taskPrepareLeaseRefresh,
reconcile: newReconcileBroadcaster(),
workspaceChanges: newWorkspaceChangeSignal(),
wsRPC: newWSRPCClient(wsRPCResponseGrace),
}
d.activeEnvRootsCond = sync.NewCond(&d.activeEnvRootsMu)
d.activeStoresCond = sync.NewCond(&d.activeStoresMu)
// Seed the copy-on-write availability set from the startup probe. Callers
// must go through d.agents() from here on; cfg.Agents is the initial value
// only and does not track later refreshes.
initialAgents := make(map[string]AgentEntry, len(cfg.Agents))
for name, entry := range cfg.Agents {
initialAgents[name] = entry
}
d.agentsAvailable.Store(&initialAgents)
d.executionEnvironmentCommand = defaultExecutionEnvironmentCommand
d.runner = taskRunnerFunc(d.runTask)
d.runUpdateFn = d.runUpdate
return d
}
// setAgentVersion records the detected CLI version for an agent provider so
// later task-dispatch code (e.g. Codex sandbox policy) can read it.
//
// A blank detection never replaces a version we already know. checkAgentMinVersion
// returns nil for any provider with no MinVersions entry, so a CLI whose
// `--version` exits 0 without printing anything parseable reaches here with an
// empty string — and overwriting the cache with it would strip the input every
// version-keyed policy reads, on a provider that was working a moment ago.
// "Couldn't read it" is not a version.
//
// This cache is the daemon's LOCAL knowledge only — it is not a record of
// what the server has been told. Every path that probes writes through here
// (the converge round, the workspace sync, a self-heal at task launch), while
// each register call covers only the workspaces it was invoked for. What the
// server accepted is therefore tracked per workspace, in
// workspaceState.builtinVersions; refreshAgentVersions compares the two.
func (d *Daemon) setAgentVersion(provider, version string) {
d.versionsMu.Lock()
prev := d.agentVersions[provider]
if version == "" && prev != "" {
d.versionsMu.Unlock()
d.logger.Warn("agent CLI reported no version; keeping the previous one",
"provider", provider, "previous", prev)
return
}
d.agentVersions[provider] = version
d.versionsMu.Unlock()
}
// agentVersion returns the last-detected CLI version for an agent provider,
// or an empty string if unknown.
func (d *Daemon) agentVersion(provider string) string {
d.versionsMu.RLock()
defer d.versionsMu.RUnlock()
return d.agentVersions[provider]
}
// builtinVersionsFromPayload extracts provider -> version from a registration
// payload's BUILT-IN entries. Custom profile entries (profile_id set) are not
// version-tracked — the drift path owns their lifecycle.
func builtinVersionsFromPayload(runtimes []map[string]string) map[string]string {
out := make(map[string]string, len(runtimes))
for _, rt := range runtimes {
if rt["profile_id"] != "" {
continue
}
out[rt["type"]] = rt["version"]
}
return out
}
// workspaceRegisterLock returns the mutex serializing one workspace's whole
// registration sequence: send Register, apply or reject the response, then
// deregister the rows that apply refused or dropped.
//
// The per-workspace version record (workspaceState.builtinVersions) must
// reflect the order the SERVER processed the register calls in, because the
// server's upsert order decides which payload's versions its rows end up
// holding. Registration entry points are concurrent (refresh, sync, a
// runtime_gone recovery, profile drift), and without this lock two calls for
// the same workspace could complete their HTTP responses in the opposite
// order from the server's processing — recording the NEWER payload locally
// while the server kept the OLDER one. The refresh round would then see the
// record agreeing with disk and never re-register: the mismatch is invisible
// precisely because the record is wrong, so it persists until the next
// version change or restart. Holding the lock across the request AND the
// record makes lock order = server order = record order.
//
// The section extends past the send because the cleanup has the same problem in
// a nastier form. A deregistration is decided under d.mu and issued after
// releasing it, so a recovery register completing in that gap re-creates the
// same row — usually under the same runtime ID — and the older Deregister lands
// on top of it. The daemon then tracks and heartbeats a runtime the server has
// marked offline, which is the direction that silently strands work: the server
// will not route to it, and neither side notices the disagreement. A
// point-in-time tracking re-check cannot close that, because the gap is between
// the check and the request; only putting the request itself inside the order
// can. Every apply and every cleanup on a workspace therefore runs under this
// lock, via withWorkspaceRegisterLock.
func (d *Daemon) workspaceRegisterLock(workspaceID string) *sync.Mutex {
mu, _ := d.registerSerial.LoadOrStore(workspaceID, &sync.Mutex{})
return mu.(*sync.Mutex)
}
// withWorkspaceRegisterLock runs one workspace's registration sequence as a
// single ordered step. Everything that sends a Register for a workspace, folds
// the response into local state, or deregisters rows that response cost, must
// run inside fn — see workspaceRegisterLock for why the boundary sits after the
// cleanup rather than after the send.
//
// The lock is per workspace, so sequences for different workspaces still run
// concurrently and nothing here is ever held across two of them.
func (d *Daemon) withWorkspaceRegisterLock(workspaceID string, fn func() error) error {
mu := d.workspaceRegisterLock(workspaceID)
mu.Lock()
defer mu.Unlock()
return fn()
}
// recordBuiltinVersionsSent stores, per provider, the version a SUCCESSFUL
// register call carried for a workspace (see workspaceState.builtinVersions).
// Merged per provider rather than replaced: a provider absent from this
// payload (its probe failed this round) was not re-sent, so the server still
// holds whatever the previous call carried and the record must keep saying so.
// Callers invoke this only after client.Register succeeds — a failed call
// records nothing, which is what keeps the workspace behind for the next
// refresh round — and while holding the workspace's register lock, so the
// record's write order matches the server's processing order
// (workspaceRegisterLock). A workspace not yet tracked records nothing here;
// the sync path seeds the record when it creates the workspaceState.
func (d *Daemon) recordBuiltinVersionsSent(workspaceID string, runtimes []map[string]string) {
sent := builtinVersionsFromPayload(runtimes)
if len(sent) == 0 {
return
}
d.mu.Lock()
defer d.mu.Unlock()
ws, ok := d.workspaces[workspaceID]
if !ok {
return
}
if ws.builtinVersions == nil {
ws.builtinVersions = make(map[string]string, len(sent))
}
for provider, version := range sent {
// Demoted while this register was in flight: its rows are being
// deregistered, so recording what the payload carried would tell the
// refresh path the server holds a version for a provider that is on its
// way offline — and demoteBelowMinimumRuntimes just deleted that entry.
if d.providerDemotedLocked(provider) {
continue
}
ws.builtinVersions[provider] = version
}
}
// refreshHealedVersion keeps a self-healed {path, version} pair honest when the
// healed binary is later replaced in place.
//
// resolveAgentEntry deliberately returns healed.version rather than the shared
// agentVersions cache whenever a previous self-heal is still live, so that a
// reader which observes the healed path necessarily observes the version
// detected for it. Nothing refreshed that pair when the SAME path was
// subsequently overwritten, so the daemon kept keying version-sensitive policy
// — the Codex sandbox in runTask — off the version captured at heal time. A
// re-probe would update agentVersions and the version reported to the server
// while tasks silently kept running under the old policy.
//
// Only touches the entry when the path still matches the one just probed: a
// heal that has since moved the provider elsewhere owns its own pairing.
func (d *Daemon) refreshHealedVersion(provider, path, version string) {
if version == "" {
return
}
d.resolvedPathsMu.Lock()
defer d.resolvedPathsMu.Unlock()
healed, ok := d.resolvedPaths[provider]
if !ok || healed.path != path || healed.version == version {
return
}
d.resolvedPaths[provider] = healedAgent{path: path, version: version}
}
// hasDetectedAgentVersions reports whether any CLI version has ever been
// detected. refreshAgentVersions uses it as the "has anything ever registered"
// guard.
func (d *Daemon) hasDetectedAgentVersions() bool {
d.versionsMu.RLock()
defer d.versionsMu.RUnlock()
return len(d.agentVersions) > 0
}
// healedAgent bundles a self-healed executable path with the CLI version
// detected for it. The two are published together under resolvedPathsMu and
// returned together from resolveAgentEntry, so a caller that observes the new
// path necessarily observes the matching version — closing the window where a
// just-upgraded binary would run under the daemon's previous version policy
// (MUL-4486 review).
type healedAgent struct {
path string
version string
}
// resolveAgentEntry returns a usable executable and its matching version.
// Resolve retargetable Windows junctions per launch. Prefer an existing healed
// path over a reappearing old path; otherwise keep a live pinned path even when
// PATH changes, so task launches cannot be redirected incidentally.
//
// If the pinned path vanished, rediscover the command with the normal hooks
// exclusion and login-shell fallback. Adopt only a version-detectable candidate
// meeting the registration minimum. Failed recovery returns the original entry
// so the launch error remains visible. Callers use the paired version rather
// than a shared cache another recovery may be updating.
func (d *Daemon) resolveAgentEntry(ctx context.Context, provider string, entry AgentEntry) (AgentEntry, string) {
resolved, version, _ := d.resolveAgentEntryWithHeal(ctx, provider, entry)
return resolved, version
}
// resolveAgentEntryForLaunch is the strict task-launch boundary. Windows
// installer junctions must yield a verified final target before the first
// launch; otherwise the stable entry could retarget after registration and run
// a binary whose version and minimum-version policy were never checked.
func (d *Daemon) resolveAgentEntryForLaunch(ctx context.Context, provider string, entry AgentEntry) (AgentEntry, string, error) {
resolved, version, outcome := d.resolveAgentEntryWithHeal(ctx, provider, entry)
if outcome.rejected != nil {
return entry, d.agentVersion(provider), outcome.rejected
}
if outcome.failure != nil {
return entry, d.agentVersion(provider), fmt.Errorf("resolve agent executable %q for launch: %w", entry.Path, outcome.failure)
}
if outcome.adopted.path != "" {
return resolved, version, nil
}
return resolved, version, nil
}
// healOutcome is what one self-heal attempt concluded. At most one half is
// meaningful: adopted names a binary that cleared the same gates registration
// applies, while rejected carries the typed verdict for a candidate that was
// found and version-detected but refused for being below the minimum supported
// version. rejected is nil unless the verdict is genuine — it is only ever set
// from a *agent.BelowMinimumError, which by construction carries a version
// that parsed.
type healOutcome struct {
adopted healedAgent
rejected *agent.BelowMinimumError
failure error
}
// resolveAgentEntryWithHeal is resolveAgentEntry plus what the self-heal
// concluded, for the one caller that must act on a refusal rather than just
// decline to launch it.
//
// A refusal is a verdict about disk, not a transient failure: the pinned path
// is gone AND the binary its command now resolves to is too old. Registration
// needs to hear that, because otherwise it goes on to probe the vanished path,
// fails, and reports "version detection failed" — which by design leaves the
// runtime online, claiming tasks for a CLI that cannot launch.
func (d *Daemon) resolveAgentEntryWithHeal(ctx context.Context, provider string, entry AgentEntry) (AgentEntry, string, healOutcome) {
// Windows installer entry points are stable junctions whose final target can
// change while the old release remains installed. Resolve the final path on
// every launch and adopt a changed target only after pairing it with a freshly
// detected, supported version. Other platforms return handled=false and keep
// the existing pinned-path self-heal semantics below.
var launchOutcome healOutcome
if launchPath, handled, err := executablePathForLaunch(entry.Path); handled {
if err != nil {
d.logger.Warn("resolve agent executable for launch failed; keeping discovered path",
"provider", provider, "path", entry.Path, "error", err)
launchOutcome.failure = err
} else if outcome, ok := d.resolveAgentLaunchTarget(ctx, provider, entry, launchPath); ok {
entry.Path = outcome.adopted.path
return entry, outcome.adopted.version, outcome
} else {
launchOutcome = outcome
}
}
// A prior self-heal wins over the original pinned path: it carries a
// {path, version} pair we already verified together, so it can never regress
// to the mismatched pairing a reappearing stale path would produce.
d.resolvedPathsMu.RLock()
healed, ok := d.resolvedPaths[provider]
d.resolvedPathsMu.RUnlock()
if ok && agentExecutablePresent(healed.path) {
entry.Path = healed.path
launchOutcome.adopted = healed
return entry, healed.version, launchOutcome
}
if agentExecutablePresent(entry.Path) {
return entry, d.agentVersion(provider), launchOutcome
}
if entry.Command == "" {
return entry, d.agentVersion(provider), healOutcome{}
}
// Coalesce concurrent heals for the same provider: the first task through
// pays for the re-resolve + version detection, the rest share its result
// instead of each spawning their own login shell and `--version` probe.
command := entry.Command
v, _, _ := d.healGroup.Do(provider, func() (any, error) {
return d.healAgentPath(ctx, provider, command), nil
})
outcome, _ := v.(healOutcome)
if outcome.adopted.path == "" {
return entry, d.agentVersion(provider), outcome
}
entry.Path = outcome.adopted.path
return entry, outcome.adopted.version, outcome
}
// resolveAgentLaunchTarget handles platforms whose stable discovered entry
// point can retarget a different still-live executable. It returns ok only
// when a verified {path, version} pair is available; a rejected or transiently
// unreadable target falls through to the existing path handling so it is never
// published under a stale version.
func (d *Daemon) resolveAgentLaunchTarget(ctx context.Context, provider string, entry AgentEntry, launchPath string) (healOutcome, bool) {
const maxRetargetAttempts = 4
for attempt := 0; attempt < maxRetargetAttempts; attempt++ {
d.resolvedPathsMu.RLock()
cached, cachedOK := d.resolvedPaths[provider]
d.resolvedPathsMu.RUnlock()
if cachedOK && cached.path == launchPath && agentExecutablePresent(cached.path) {
return healOutcome{adopted: cached}, true
}
// Coalesce only callers that observed the same concrete release. A
// provider-only key can make a post-retarget caller inherit the previous
// release's result even though both files remain present.
key := provider + "\x00" + launchPath
v, _, _ := d.healGroup.Do(key, func() (any, error) {
d.resolvedPathsMu.RLock()
current, ok := d.resolvedPaths[provider]
d.resolvedPathsMu.RUnlock()
if ok && current.path == launchPath && agentExecutablePresent(current.path) {
return healOutcome{adopted: current}, nil
}
return d.adoptAgentPath(ctx, provider, entry.Command, launchPath, "resolved stable entry point for launch"), nil
})
outcome, _ := v.(healOutcome)
// The installer may retarget while version detection is running. Resolve
// again before returning and retry against the target visible now.
currentPath, _, err := executablePathForLaunch(entry.Path)
if err != nil {
if outcome.adopted.path != "" {
return outcome, true
}
outcome.failure = err
return outcome, false
}
if currentPath != launchPath {
launchPath = currentPath
continue
}
if outcome.adopted.path != "" {
return outcome, true
}
if cachedOK && agentExecutablePresent(cached.path) {
outcome.adopted = cached
return outcome, true
}
return outcome, false
}
return healOutcome{failure: errors.New("installer entry point changed repeatedly while resolving it")}, false
}
// healAgentPath re-resolves command for provider and, if a usable binary is
// found, records it and returns it. "Usable" means: it resolves, its version
// can be detected, and that version meets the same minimum-version gate
// registration enforces. Path and version are published together under
// resolvedPathsMu so any observer of the path also sees the matching version;
// the shared d.agentVersion cache is refreshed too, for registration hygiene.
// It returns a zero adopted pair when nothing usable was found, so the caller
// keeps the (stale) pinned entry and the candidate is never launched. Runs
// under healGroup, one invocation at a time per provider.
//
// A candidate refused by the minimum-version gate is reported back rather than
// swallowed: not launching it is right, but it is also the whole verdict a
// registration round needs to take the provider's runtimes offline.
func (d *Daemon) healAgentPath(ctx context.Context, provider, command string) healOutcome {
// Re-check the cache: a predecessor under the same singleflight key may have
// already populated it, or a prior heal completed between the read above and
// entering here.
d.resolvedPathsMu.RLock()
cached, ok := d.resolvedPaths[provider]
d.resolvedPathsMu.RUnlock()
if ok && agentExecutablePresent(cached.path) {
return healOutcome{adopted: cached}
}
newPath, found := reresolveAgentCommand(command)
if !found {
return healOutcome{}
}
if launchPath, handled, err := executablePathForLaunch(newPath); handled {
if err != nil {
d.logger.Warn("resolve re-discovered agent executable for launch failed; keeping discovered path",
"provider", provider, "path", newPath, "error", err)
return healOutcome{failure: err}
} else {
newPath = launchPath
}
}
return d.adoptAgentPath(ctx, provider, command, newPath, "re-resolved after pinned path vanished")
}
func (d *Daemon) adoptAgentPath(ctx context.Context, provider, command, newPath, reason string) healOutcome {
// Verify before adopting. An in-place "upgrade" that actually repoints at an
// older or broken install must not be launched under the daemon's stale
// version policy, and must not slip past the minimum-version gate that the
// registration path applies (MUL-4486 review).
version, err := detectAgentVersion(ctx, agent.Command{Path: newPath})
if err != nil {
d.logger.Warn("re-resolved agent executable failed version detection; keeping pinned path",
"provider", provider, "command", command, "new_path", newPath, "error", err)
return healOutcome{failure: err}
}
if err := checkAgentMinVersion(provider, version); err != nil {
var tooOld *agent.BelowMinimumError
if !errors.As(err, &tooOld) {
// Read something, understood nothing: not a verdict. Refusing to
// adopt is still right, but reporting a rejection would let the
// caller demote a runtime on an unreadable version — the exact
// transient case the below-minimum machinery must never act on.
d.logger.Warn("re-resolved agent executable version could not be validated; keeping pinned path",
"provider", provider, "command", command, "new_path", newPath, "version", version, "error", err)
return healOutcome{failure: err}
}
d.logger.Warn("re-resolved agent executable is below the minimum supported version; not adopting it",
"provider", provider, "command", command, "new_path", newPath, "version", version, "error", err)
return healOutcome{rejected: tooOld}
}
adopted := healedAgent{path: newPath, version: version}
// Publish path + version atomically: any reader that sees the new path in
// resolveAgentEntry gets the matching version out of the same struct value.
d.resolvedPathsMu.Lock()
if d.resolvedPaths == nil {
d.resolvedPaths = make(map[string]healedAgent)
}
d.resolvedPaths[provider] = adopted
d.resolvedPathsMu.Unlock()
// Keep the registration version cache fresh too. The task path reads the
// version returned alongside the resolved path (above), not this map, so its
// staleness can never gate a launch — this is hygiene for the registration
// report and any future d.agentVersion reader.
d.setAgentVersion(provider, version)
d.logger.Info("adopted resolved agent executable",
"provider", provider, "command", command, "new_path", newPath, "version", version, "reason", reason)
return healOutcome{adopted: adopted}
}
func (d *Daemon) notifyRuntimeSetChanged() {
d.runtimeSet.notify()
}
// reregisterCoalesceWindow caps how often the daemon re-registers a workspace
// after detecting a runtime_not_found response. Many stale runtime IDs may be
// reported within seconds of each other (one delete clears all of a daemon's
// runtimes), and a single re-register call replaces every runtime in the
// workspace, so concurrent recoveries must collapse to one API call.
const reregisterCoalesceWindow = 30 * time.Second
// reregisterFailureBackoff is the additional wait inserted before the next
// re-register attempt when the previous one failed. This prevents heartbeat
// ticks (~15s) from converting a server-side log flood into a re-register
// flood when re-registration itself is failing (workspace removed, server
// unreachable, ...).
const reregisterFailureBackoff = 60 * time.Second
// handleRuntimeGone is the single recovery entry point shared by the HTTP
// heartbeat path, the runtime poller, and the WebSocket runtime_gone ack
// handler. All three may notice the same stale runtime within a few ms of
// each other, so this function:
//
// - keys an in-flight set on runtimeID to drop concurrent calls for the same
// ID after the first one is already cleaning up;
// - keys a per-workspace next-attempt timestamp on workspaceID so that
// concurrent recoveries triggered by the SAME initial event coalesce to a
// single registerRuntimesForWorkspace call. The slot is cleared on success
// so a later distinct runtime deletion in the same workspace can trigger
// its own recovery without waiting for the coalesce window to expire; and
// - keys a per-workspace last-completed timestamp so that a straggler whose
// removeStaleRuntime took long enough that a sibling fully ran AND cleared
// the slot can still recognize itself as same-wave and bail. Without this,
// the success-case slot clear opens a race where the late caller re-claims
// an empty slot and double-registers.
//
// On failure of the underlying re-register, the next-attempt timestamp is
// extended by reregisterFailureBackoff so we don't replace a server-side log
// flood with a daemon-side register flood. workspaceSyncLoop will retry
// independently every DefaultWorkspaceSyncInterval as a safety net.
//
// The recovery HTTP call uses the daemon root context, not the caller's. The
// heartbeat path's per-runtime ctx is cancelled by notifyRuntimeSetChanged the
// moment we prune the dead UUID, and if we forwarded that ctx the in-flight
// register would self-cancel mid-flight.
func (d *Daemon) handleRuntimeGone(runtimeID string) {
if runtimeID == "" {
return
}
// entryAt anchors the same-wave-straggler check at the bottom of the
// function. Captured at the very top so removeStaleRuntime mutex
// contention can't push it past a sibling's register completion.
entryAt := time.Now()
// Stampede control per runtime ID.
d.runtimeGoneMu.Lock()
if _, inflight := d.runtimeGoneInflight[runtimeID]; inflight {
d.runtimeGoneMu.Unlock()
return
}
d.runtimeGoneInflight[runtimeID] = struct{}{}
d.runtimeGoneMu.Unlock()
defer func() {
d.runtimeGoneMu.Lock()
delete(d.runtimeGoneInflight, runtimeID)
d.runtimeGoneMu.Unlock()
}()
workspaceID, removed := d.removeStaleRuntime(runtimeID)
if !removed {
// Already gone from local state — a parallel recovery already
// cleaned this up, or workspaceSyncLoop pruned the whole workspace.
return
}
d.logger.Info("runtime deleted server-side; pruned from local state",
"runtime_id", runtimeID, "workspace_id", workspaceID)
d.notifyRuntimeSetChanged()
if !d.tryClaimRegisterSlot(workspaceID, entryAt, time.Now()) {
d.logger.Debug("skip re-register: coalescing with recent attempt",
"workspace_id", workspaceID)
return
}
err := d.reregisterWorkspaceAfterRuntimeGone(d.recoveryContext(), workspaceID)
d.recordRegisterCompletion(workspaceID, time.Now(), err)
if err != nil {
// Logged at Warn (not Error) because workspaceSyncLoop retries
// independently every DefaultWorkspaceSyncInterval, so a transient
// failure here is not a stuck state — just an extra wait.
d.logger.Warn("re-register after runtime gone failed",
"workspace_id", workspaceID, "error", err)
}
}
// tryClaimRegisterSlot atomically decides whether the calling goroutine should
// run registerRuntimesForWorkspace. Returns true and claims the in-flight slot
// when the caller may proceed; returns false (without mutating state) when the
// call must be coalesced with a peer.
//
// Two gates are checked under runtimeGoneMu:
//
// 1. reregisterNextAttempt: a future timestamp means a peer holds the slot or
// a previous attempt failed and we are inside the failure backoff window.
// 2. reregisterLastCompletedAt: a timestamp at or after our entryAt means a
// peer's register SUCCEEDED after we entered handleRuntimeGone, so the
// workspace state is already covered for our wave and we can bail.
// Failures intentionally don't stamp this field (see
// recordRegisterCompletion), so a same-wave straggler whose entryAt
// predates a failed sibling can still retry once the failure backoff
// expires — failures don't cover anything.
//
// entryAt is the wall-clock captured at the top of handleRuntimeGone. now is
// passed in (rather than read inside) so tests can drive the gate
// deterministically without sleeping.
func (d *Daemon) tryClaimRegisterSlot(workspaceID string, entryAt, now time.Time) bool {
d.runtimeGoneMu.Lock()
defer d.runtimeGoneMu.Unlock()
if next, ok := d.reregisterNextAttempt[workspaceID]; ok && now.Before(next) {
return false
}
if last, ok := d.reregisterLastCompletedAt[workspaceID]; ok && !last.Before(entryAt) {
return false
}
d.reregisterNextAttempt[workspaceID] = now.Add(reregisterCoalesceWindow)
return true
}
// recordRegisterCompletion records the outcome of a register call. On success
// it stamps lastCompletedAt (which suppresses same-wave stragglers via
// tryClaimRegisterSlot) and clears the in-flight slot so a genuinely later
// runtime deletion can claim immediately. On failure it extends
// reregisterNextAttempt by the failure backoff and intentionally does NOT
// stamp lastCompletedAt — a failed register did not cover any workspace
// state, so a same-wave straggler whose entryAt predates the failure must
// still be allowed to retry once the backoff expires. workspaceSyncLoop only
// retries when the workspace's runtimeIDs fully drain, so partial-deletion
// recovery has to come from the straggler path.
func (d *Daemon) recordRegisterCompletion(workspaceID string, completedAt time.Time, err error) {
d.runtimeGoneMu.Lock()
defer d.runtimeGoneMu.Unlock()
if err != nil {
d.reregisterNextAttempt[workspaceID] = completedAt.Add(reregisterFailureBackoff)
return
}
d.reregisterLastCompletedAt[workspaceID] = completedAt
delete(d.reregisterNextAttempt, workspaceID)
}
// recoveryContext returns the daemon root context for long-running recovery
// HTTP calls (re-register, recover-orphans) that must survive the heartbeat
// loop tearing down a per-runtime context. Falls back to Background when the
// daemon was not started via Run(), e.g. unit-test fixtures.
func (d *Daemon) recoveryContext() context.Context {
if d.rootCtx != nil {
return d.rootCtx
}
return context.Background()
}
// removeStaleRuntime drops a runtime ID from its owning workspace's runtimeIDs
// list, the daemon-level runtimeIndex, and the WS heartbeat freshness map.
// Returns the workspace ID and true if the runtime was tracked, "" and false
// otherwise.
//
// Callers must NOT replace workspaceState pointers — only mutate fields in
// place — because ensureRepoReady holds workspaceState.repoRefreshMu through
// long repo-sync calls. See syncWorkspacesFromAPI for the same invariant.
func (d *Daemon) removeStaleRuntime(runtimeID string) (string, bool) {
d.mu.Lock()
var workspaceID string
for wsID, ws := range d.workspaces {
found := false
filtered := ws.runtimeIDs[:0:0]
for _, rid := range ws.runtimeIDs {
if rid == runtimeID {
found = true
continue
}
filtered = append(filtered, rid)
}
if found {
ws.runtimeIDs = filtered
workspaceID = wsID
break
}
}
if workspaceID == "" {
d.mu.Unlock()
return "", false
}
delete(d.runtimeIndex, runtimeID)
d.mu.Unlock()
d.wsHBMu.Lock()
delete(d.wsHBLastAck, runtimeID)
d.wsHBMu.Unlock()
return workspaceID, true
}
// workspaceNeedsRuntimeRecovery reports whether a tracked workspace currently
// has zero runtime IDs — the state reached when handleRuntimeGone pruned every
// runtime and its inline re-register failed. workspaceSyncLoop calls this on
// each tick so the workspace can recover without waiting for an external
// trigger.
func (d *Daemon) workspaceNeedsRuntimeRecovery(workspaceID string) bool {
d.mu.Lock()
defer d.mu.Unlock()
ws, ok := d.workspaces[workspaceID]
if !ok {
return false
}
return len(ws.runtimeIDs) == 0
}
// applyRegisterResponseInPlace reconciles the authoritative runtime response
// without replacing workspaceState, whose mutexes are shared with other callers.
// Return current IDs, dropped IDs to deregister, and false if the workspace has
// been forgotten while registration was in flight.
//
// An empty profileSig keeps the previous signature. preserveProviders retains
// built-in rows omitted by failed or non-authoritative probes; a confirmed demotion
// still wins over a response sent before the demotion. Callers own recovery and
// deregistration side effects.
func (d *Daemon) applyRegisterResponseInPlace(workspaceID string, resp *RegisterResponse, profileSig string, preserveProviders map[string]string) (newIDs, droppedIDs []string, ok bool) {
d.mu.Lock()
defer d.mu.Unlock()
ws, exists := d.workspaces[workspaceID]
if !exists {
return nil, nil, false
}
// Reject entries for providers demoted since this register was sent. The
// payload predates the verdict, so the response carries the provider as if
// it were healthy; indexing it here would undo demoteBelowMinimumRuntimes.
// Both sides do this under d.mu, which gives the two a total order: either
// the demotion runs first and this rejects the late response, or the apply
// runs first and the demotion removes the row it just added. Either way the
// provider ends up offline. Rejected IDs join droppedIDs so the caller
// deregisters the row the server upserted back into existence.
newIDs = make([]string, 0, len(resp.Runtimes))
newIDSet := make(map[string]struct{}, len(resp.Runtimes))
rejected := make(map[string]struct{})
for _, rt := range resp.Runtimes {
if rt.ProfileID == "" && d.providerDemotedLocked(rt.Provider) {
rejected[rt.ID] = struct{}{}
droppedIDs = append(droppedIDs, rt.ID)
continue
}
newIDs = append(newIDs, rt.ID)
newIDSet[rt.ID] = struct{}{}
}
// Drop runtimeIndex entries for prior runtime IDs that the server did not
// return — typically there are none for upsert-on-existing-provider, but
// a daemon config change (provider removed) or a profile disable would
// leak entries otherwise.
kept := newIDs
for _, oldID := range ws.runtimeIDs {
if _, stillThere := newIDSet[oldID]; stillThere {
continue
}
if _, alreadyRejected := rejected[oldID]; alreadyRejected {
// The server returned this ID for a demoted provider and it is
// already in droppedIDs; drop the index entry without recording it
// a second time.
delete(d.runtimeIndex, oldID)
continue
}
if rt, tracked := d.runtimeIndex[oldID]; tracked && rt.ProfileID == "" {
if _, preserve := preserveProviders[rt.Provider]; preserve {
kept = append(kept, oldID)
continue
}
}
delete(d.runtimeIndex, oldID)
droppedIDs = append(droppedIDs, oldID)
}
for _, rt := range resp.Runtimes {
if _, skip := rejected[rt.ID]; skip {
continue
}
d.runtimeIndex[rt.ID] = rt
}
// Response is authoritative — replace, do not append. Replacing also
// catches the rare case where UpsertAgentRuntime returns a different ID
// for a surviving provider (e.g. schema change); the daemon converges on
// what the server says without leaving stale heartbeat goroutines.
ws.runtimeIDs = kept
if resp.ReposVersion != "" {
ws.reposVersion = resp.ReposVersion
ws.allowedRepoURLs = repoAllowlist(resp.Repos)
}
if len(resp.Settings) > 0 {
ws.settings = resp.Settings
}
// Refresh the cached profile signature only when the fetch succeeded;
// an empty sig means the GetRuntimeProfiles call failed and we must
// preserve the previous signature so the next sync tick can still
// detect a real drift instead of falsely thinking everything is in sync.
if profileSig != "" {
ws.profileSetSig = profileSig
}
return newIDs, droppedIDs, true
}
// mergeBuiltinRegisterResponse applies a builtins-only register response
// ADDITIVELY: returned built-in runtimes are indexed and appended, and a prior
// runtime ID that the response did not mention is left alone.
//
// applyRegisterResponseInPlace treats the response as authoritative and drops
// unmentioned IDs, which is right for the convergence paths that deliberately
// re-derive a workspace's whole runtime set. It is wrong for CLI discovery
// (MUL-5439): that payload carries built-in runtimes only, so under the
// authoritative rule it would evict the workspace's custom profile runtimes from
// runtimeIndex and stop their heartbeats, possibly mid-task. The server's
// register endpoint is a pure per-entry upsert and prunes nothing, so omitted
// runtimes still exist server-side and must be kept locally.
//
// Two deliberate omissions keep this path out of custom-profile business:
//
// - Entries with a ProfileID are ignored. Discovery registers built-ins only,
// so this is an invariant guard: a custom runtime must never enter the set
// through here.
// - profileSetSig is neither read nor written. Caching a signature observed
// during discovery would tell refreshWorkspaceRuntimeProfiles that the
// profile set was already converged, and a profile disabled at that moment
// would keep its runtime alive forever.
//
// ID rotation is still handled: when the response returns a different ID for a
// built-in provider the workspace already had, that specific old ID is replaced
// rather than accumulating a duplicate heartbeat.
func (d *Daemon) mergeBuiltinRegisterResponse(workspaceID string, resp *RegisterResponse) (newIDs []string, revived revivedRuntimes, ok bool) {
d.mu.Lock()
defer d.mu.Unlock()
ws, exists := d.workspaces[workspaceID]
if !exists {
return nil, revivedRuntimes{}, false
}
// Index the workspace's current built-in runtimes by provider so a rotated
// ID replaces its predecessor instead of doubling it.
existingByProvider := make(map[string]string, len(ws.runtimeIDs))
kept := make([]string, 0, len(ws.runtimeIDs)+len(resp.Runtimes))
present := make(map[string]struct{}, len(ws.runtimeIDs))
for _, id := range ws.runtimeIDs {
if rt, found := d.runtimeIndex[id]; found && rt.ProfileID == "" {
existingByProvider[rt.Provider] = id
}
kept = append(kept, id)
present[id] = struct{}{}
}
for _, rt := range resp.Runtimes {
if rt.ProfileID != "" {
// Not ours to manage; the drift path owns custom profiles.
continue
}
// Demoted since this register was sent: the response predates the
// verdict, so bringing the provider back here would undo the demotion.
// Both run under d.mu, so the two are totally ordered — see the same
// guard in applyRegisterResponseInPlace. The caller deregisters the row
// the server upserted back.
if d.providerDemotedLocked(rt.Provider) {
revived.add(d, rt.ID, rt.Provider)
continue
}
d.runtimeIndex[rt.ID] = rt
if _, already := present[rt.ID]; already {
continue
}
if oldID, rotated := existingByProvider[rt.Provider]; rotated && oldID != rt.ID {
// Same runtime, new ID: swap in place and retire the old entry.
for i, id := range kept {
if id == oldID {
kept[i] = rt.ID
break
}
}
delete(d.runtimeIndex, oldID)
delete(present, oldID)
} else {
kept = append(kept, rt.ID)
}
present[rt.ID] = struct{}{}
existingByProvider[rt.Provider] = rt.ID
newIDs = append(newIDs, rt.ID)
}
ws.runtimeIDs = kept
if resp.ReposVersion != "" {
ws.reposVersion = resp.ReposVersion
ws.allowedRepoURLs = repoAllowlist(resp.Repos)
}
if len(resp.Settings) > 0 {
ws.settings = resp.Settings
}
return newIDs, revived, true
}
// providerDemotedLocked reports whether provider is currently held below the
// minimum supported version. Callers must hold d.mu — the demotion record and
// every register-response apply share that lock precisely so a late response
// can never slip between the two.
func (d *Daemon) providerDemotedLocked(provider string) bool {
_, demoted := d.demotedProviders[provider]
return demoted
}
// demotedOfflineReasonLocked returns the structured cause recorded for a
// demoted provider, or nil when the verdict carries none (below-minimum) or the
// provider is not demoted. Callers must hold d.mu.
func (d *Daemon) demotedOfflineReasonLocked(provider string) *RuntimeOfflineReason {
record, demoted := d.demotedProviders[provider]
if !demoted {
return nil
}
return record.offline
}
// revivedRuntimes are the rows a register response brought back for a provider
// the daemon has already condemned: the ids to take offline again, and the
// cause to re-attach per row because that register's upsert just overwrote it.
type revivedRuntimes struct {
ids []string
reasons map[string]RuntimeOfflineReason
}
// reasonsFor narrows the causes to the rows actually being deregistered. The
// caller re-checks tracking first — a row that came back legitimately in the
// meantime is dropped from the list — and sending a cause for a row we are no
// longer taking offline would attach it to a healthy runtime.
func (r revivedRuntimes) reasonsFor(runtimeIDs []string) map[string]RuntimeOfflineReason {
if len(r.reasons) == 0 {
return nil
}
out := make(map[string]RuntimeOfflineReason, len(runtimeIDs))
for _, id := range runtimeIDs {
if reason, ok := r.reasons[id]; ok {
out[id] = reason
}
}
if len(out) == 0 {
return nil
}
return out
}
// add records one revived row. Callers must hold d.mu (it reads the demotion
// record), and it is a no-op for a provider with no structured cause — those
// rows still need deregistering, they just have nothing to re-attach.
func (r *revivedRuntimes) add(d *Daemon, runtimeID, provider string) {
r.ids = append(r.ids, runtimeID)
reason := d.demotedOfflineReasonLocked(provider)
if reason == nil {
return
}
if r.reasons == nil {
r.reasons = make(map[string]RuntimeOfflineReason, 1)
}
r.reasons[runtimeID] = *reason
}
// demotionRecord is a confirmed verdict about the binary on disk: the evidence
// that produced it (the rejected version, or why the CLI could not be run),
// plus the demotionSeq tick that establishes when it was reached.
//
// offline is the structured half, kept because the server can lose it. A
// register sent before the verdict still upserts the runtime row, and that
// upsert overwrites metadata wholesale — so the reason this daemon just stored
// is gone, and the cleanup that takes the revived row offline again has to
// re-attach it. Without that the server ends up "offline, no reason", which
// downgrades the refusal back to "wait for the machine" (MUL-6164).
type demotionRecord struct {
evidence string
offline *RuntimeOfflineReason
seq uint64
}
// setLifecycleCtx records the context Run was handed.
func (d *Daemon) setLifecycleCtx(ctx context.Context) {
d.mu.Lock()
defer d.mu.Unlock()
d.lifecycleCtx = ctx
}
// daemonLifecycleCtx returns the context that bounds background work outliving
// a single round. Falls back to Background so a zero-value Daemon (tests, and
// any path reached before Run) still works.
func (d *Daemon) daemonLifecycleCtx() context.Context {
d.mu.Lock()
defer d.mu.Unlock()
if d.lifecycleCtx == nil {
return context.Background()
}
return d.lifecycleCtx
}
// markProvidersDemoted records a CONFIRMED verdict so a register response still
// in flight cannot revive the provider. Callers must hold d.mu.
func (d *Daemon) markProvidersDemotedLocked(providers map[string]runtimeVerdict) {
if len(providers) == 0 {
return
}
if d.demotedProviders == nil {
d.demotedProviders = make(map[string]demotionRecord, len(providers))
}
// One tick per verdict batch: every record written here is newer than any
// probe round that snapshotted the counter before this call.
d.demotionSeq++
for provider, verdict := range providers {
d.demotedProviders[provider] = demotionRecord{
evidence: verdict.reason,
offline: verdict.offline,
seq: d.demotionSeq,
}
}
}
// condemnedConfirmWindow is how long a condemnable verdict must keep reproducing
// before the daemon acts on it.
//
// The verdict itself is deterministic, but the observation behind it is not. The
// repair we tell users to run for a not-executable file overwrites the bin entry
// in place, and a probe that lands mid-copy sees a truncated file; the DSH
// profile probe boots a whole process, and a DSH upgrade replaces the CLI
// underneath it. Requiring a second sighting this far apart makes those windows
// impossible to mistake for a real verdict, and costs a genuinely broken
// provider only one extra probe round.
//
// This gates every condemnable verdict, not just the exec-format one: the
// profile verdicts rest on a single observation too, and demotion is what routes
// live work away from a machine.
// A var so tests can collapse the wait.
var condemnedConfirmWindow = time.Minute
// condemnedKey namespaces a confirmation clock per verdict, so a provider that
// moves between condemnable states starts a fresh window instead of inheriting
// the time earned by a different problem.
func condemnedKey(verdict builtinProbeVerdict, provider string) string {
return strconv.Itoa(int(verdict)) + ":" + provider
}
// confirmCondemned records that this round found provider in the given
// condemnable state and reports whether the verdict is now old enough to act on.
//
// The first sighting only starts the clock. Two sightings are required no matter
// how the window is configured, so concurrent probe rounds — four callers reach
// detectBuiltinRuntimes — cannot combine into an instant demotion.
func (d *Daemon) confirmCondemned(verdict builtinProbeVerdict, provider string, now time.Time) bool {
key := condemnedKey(verdict, provider)
d.mu.Lock()
defer d.mu.Unlock()
first, seen := d.condemnedSince[key]
if !seen {
if d.condemnedSince == nil {
d.condemnedSince = make(map[string]time.Time, 1)
}
d.condemnedSince[key] = now
return false
}
return now.Sub(first) >= condemnedConfirmWindow
}
// clearCondemned forgets every pending verdict for a provider that probed OK, so
// a later unrelated failure starts its own confirmation window instead of
// inheriting a stale one.
func (d *Daemon) clearCondemned(provider string) {
suffix := ":" + provider
d.mu.Lock()
defer d.mu.Unlock()
for key := range d.condemnedSince {
if strings.HasSuffix(key, suffix) {
delete(d.condemnedSince, key)
}
}
}
// demotionSeqSnapshot returns the current demotion counter. A probe round takes
// this BEFORE it samples any version, so a later clearProviderDemotions call can
// prove its evidence postdates a verdict rather than merely arriving after it.
func (d *Daemon) demotionSeqSnapshot() uint64 {
d.mu.Lock()
defer d.mu.Unlock()
return d.demotionSeq
}
// clearProviderDemotions drops the verdict for providers whose version probed
// acceptable again, which is what lets converge register them back.
//
// sampledAfter is the counter the calling round snapshotted before it probed. A
// hold recorded after that snapshot is NEWER evidence than anything this round
// saw, so it survives: four callers probe concurrently and sampling happens off
// the lock, which means "returned last" says nothing about "looked last". The
// round that overlapped a demotion simply declines to clear it and the next one
// — which starts after the verdict exists, so its sample cannot predate it —
// does the release. Recovery is at worst one round late; clearing on stale
// evidence would let a stale register response through every other guard.
func (d *Daemon) clearProviderDemotions(providers []string, sampledAfter uint64) {
if len(providers) == 0 {
return
}
d.mu.Lock()
defer d.mu.Unlock()
if len(d.demotedProviders) == 0 {
return
}
for _, provider := range providers {
record, was := d.demotedProviders[provider]
if !was {
continue
}
if record.seq > sampledAfter {
d.logger.Info("keeping demotion hold: this probe round started before the verdict",
"provider", provider, "verdict", record.evidence)
continue
}
delete(d.demotedProviders, provider)
d.logger.Info("agent CLI is usable again",
"provider", provider, "previous_verdict", record.evidence)
}
}
// untrackedRuntimeIDs filters ids down to those the daemon does not currently
// track, so a deregistration decided a moment ago cannot take a row offline that
// a NEWER legitimate registration has since brought back.
//
// Every deregistration is decided under d.mu and issued after releasing it —
// the HTTP call must not hold the daemon lock. A recovery register completing in
// that gap re-creates the same server-side row, usually under the same runtime
// ID, and the older cleanup would then knock out the row that just recovered.
//
// This filter is only half the guarantee, and on its own it is a TOCTOU: the
// recovery can just as easily complete between the filter and the request.
// Callers must therefore run both inside the workspace's register lock, which is
// what makes the check and the Deregister one ordered step against every other
// registration for that workspace.
func (d *Daemon) untrackedRuntimeIDs(ids []string) []string {
if len(ids) == 0 {
return nil
}
d.mu.Lock()
defer d.mu.Unlock()
out := make([]string, 0, len(ids))
for _, id := range ids {
if _, tracked := d.runtimeIndex[id]; tracked {
continue
}
out = append(out, id)
}
return out
}
func (d *Daemon) reregisterWorkspaceAfterRuntimeGone(ctx context.Context, workspaceID string) error {
var newIDs []string
// Send, apply and clean up as one ordered step — see workspaceRegisterLock.
err := d.withWorkspaceRegisterLock(workspaceID, func() error {
resp, profileSig, preserve, err := d.registerRuntimesForWorkspaceLocked(ctx, workspaceID)
if err != nil {
return fmt.Errorf("register runtimes: %w", err)
}
ids, droppedIDs, ok := d.applyRegisterResponseInPlace(workspaceID, resp, profileSig, preserve)
if !ok {
return fmt.Errorf("workspace %s no longer tracked", workspaceID)
}
newIDs = ids
for _, rid := range newIDs {
d.logger.Info("re-registered runtime after server-side deletion",
"workspace_id", workspaceID, "runtime_id", rid)
}
// A sibling runtime dropped by this recovery (a provider removed from the
// daemon's config, a disabled profile) still has a live server row — the
// runtime_gone trigger only deleted its own. Eagerly mark those offline,
// matching the drift path, instead of leaving them claimable until the
// stale-heartbeat sweep.
d.deregisterDroppedRuntimes(ctx, workspaceID, droppedIDs, "runtime_gone recovery", nil)
return nil
})
if err != nil {
return err
}
d.notifyRuntimeSetChanged()
// Tell the server about any tasks the previous (now-deleted) runtime
// was working on, mirroring the registration path's recover-orphans call.
// This is intentionally scoped to the runtime_gone recovery: the
// runtimes were truly gone server-side, so anything still in
// dispatched/running/waiting_local_directory on those rows is an orphan
// that needs to be failed-and-retried. The drift-refresh path (which
// also feeds applyRegisterResponseInPlace) deliberately skips this step
// because its surviving runtime IDs may still be actively executing
// tasks for the user (MUL-3332).
for _, rid := range newIDs {
if err := d.client.RecoverOrphans(ctx, rid); err != nil {
d.logger.Warn("recover-orphans after re-register failed",
"runtime_id", rid, "error", err)
}
}
return nil
}
// runtimeSetWatcher is a tiny pub/sub for runtime-set changes. It exists
// because more than one supervisor (taskWakeupLoop, heartbeatLoop, pollLoop)
// needs to react to runtime-set changes; a single buffered channel would
// race so only the first listener would learn about each change.
//
// Each subscriber gets a 1-slot channel; missed nudges coalesce into a
// single signal — the subscriber is expected to re-derive the current
// runtime set via allRuntimeIDs() rather than relying on edge counts.
type runtimeSetWatcher struct {
mu sync.Mutex
subscribers map[chan struct{}]struct{}
}
func newRuntimeSetWatcher() *runtimeSetWatcher {
return &runtimeSetWatcher{subscribers: make(map[chan struct{}]struct{})}
}
// Subscribe returns a channel that receives a non-blocking nudge whenever
// the runtime set changes, and an unsubscribe func the caller must invoke
// when done.
func (w *runtimeSetWatcher) Subscribe() (<-chan struct{}, func()) {
ch := make(chan struct{}, 1)
w.mu.Lock()
w.subscribers[ch] = struct{}{}
w.mu.Unlock()
return ch, func() {
w.mu.Lock()
delete(w.subscribers, ch)
w.mu.Unlock()
}
}
func (w *runtimeSetWatcher) notify() {
w.mu.Lock()
defer w.mu.Unlock()
for ch := range w.subscribers {
select {
case ch <- struct{}{}:
default:
}
}
}
// wsHeartbeatFreshness defines how long a WS heartbeat ack is considered
// "fresh enough" to suppress the HTTP heartbeat for that runtime. The window
// is 2× HeartbeatInterval so a single dropped WS ack still keeps HTTP
// suppressed, but two missed acks (~30s of WS silence) re-enable HTTP — well
// inside the server-side 45s offline threshold.
func (d *Daemon) wsHeartbeatFreshness() time.Duration {
if d.cfg.HeartbeatInterval <= 0 {
return 30 * time.Second
}
return 2 * d.cfg.HeartbeatInterval
}
// recordWSHeartbeatAck stamps the runtime as having received a fresh WS
// heartbeat ack from the server. Called by the WS read pump.
func (d *Daemon) recordWSHeartbeatAck(runtimeID string) {
if runtimeID == "" {
return
}
d.wsHBMu.Lock()
d.wsHBLastAck[runtimeID] = time.Now()
d.wsHBMu.Unlock()
}
// wsHeartbeatRecentlyAcked reports whether the runtime received a WS
// heartbeat ack inside the freshness window. The HTTP heartbeat loop uses
// this to skip duplicate work when WS is already keeping the runtime alive.
func (d *Daemon) wsHeartbeatRecentlyAcked(runtimeID string) bool {
d.wsHBMu.RLock()
last, ok := d.wsHBLastAck[runtimeID]
d.wsHBMu.RUnlock()
if !ok {
return false
}
return time.Since(last) < d.wsHeartbeatFreshness()
}
// clearWSHeartbeatAcks drops all WS heartbeat freshness records. Called on
// WS disconnect so HTTP heartbeats resume on the next tick.
func (d *Daemon) clearWSHeartbeatAcks() {
d.wsHBMu.Lock()
for k := range d.wsHBLastAck {
delete(d.wsHBLastAck, k)
}
d.wsHBMu.Unlock()
}
// Run starts the daemon: resolves auth, registers runtimes, then polls for tasks.
func (d *Daemon) Run(ctx context.Context) error {
return d.run(ctx, nil)
}
// RunWithHealthListener uses an already-bound loopback listener. The CLI keeps
// that listener open while publishing its actual port for other processes.
func (d *Daemon) RunWithHealthListener(ctx context.Context, healthLn net.Listener) error {
return d.run(ctx, healthLn)
}
func (d *Daemon) run(ctx context.Context, healthLn net.Listener) error {
var err error
// Wrap context so handleUpdate can cancel the daemon for restart.
ctx, cancel := context.WithCancel(ctx)
d.cancelFunc = cancel
d.setLifecycleCtx(ctx)
d.rootCtx = ctx
// Bind health port early to detect another running daemon.
if healthLn == nil {
healthLn, err = d.listenHealth()
if err != nil {
return err
}
}
defer healthLn.Close()
agentNames := make([]string, 0, len(d.agents()))
for name := range d.agents() {
agentNames = append(agentNames, name)
}
logFields := []any{"version", d.cfg.CLIVersion, "agents", agentNames, "server", d.cfg.ServerBaseURL}
if d.cfg.Profile != "" {
logFields = append(logFields, "profile", d.cfg.Profile)
}
d.logger.Info("starting daemon", logFields...)
d.logger.Debug("daemon config resolved",
"daemon_id", d.cfg.DaemonID,
"device_name", d.cfg.DeviceName,
"workspaces_root", d.cfg.WorkspacesRoot,
"health_port", d.cfg.HealthPort,
"poll_interval", d.cfg.PollInterval,
"ws_claim_poll_interval", d.cfg.WSClaimPollInterval,
"heartbeat_interval", d.cfg.HeartbeatInterval,
"agent_timeout", d.cfg.AgentTimeout,
"idle_watchdog", d.cfg.AgentIdleWatchdog,
// Logged explicitly because it is normally derived from idle_watchdog:
// without it an operator cannot read the tool budget actually in effect.
"tool_watchdog", d.cfg.AgentToolWatchdog,
"opencode_idle_watchdog", d.cfg.OpenCodeIdleWatchdog,
// Derived from the watchdog budget too (Codex's own timer is not
// tool-aware), so it needs the same treatment as tool_watchdog: without
// it the effective Codex budget is invisible until a timeout fires.
"codex_semantic_inactivity", d.cfg.CodexSemanticInactivityTimeout,
"max_concurrent_tasks", d.cfg.MaxConcurrentTasks,
"gc_enabled", d.cfg.GCEnabled,
"auto_update", d.cfg.AutoUpdateEnabled,
"launched_by", d.cfg.LaunchedBy,
)
// Mark the daemon-owned workspaces tree before any task runs. A sandbox
// fault can strip every MULTICA_* env var from an agent subprocess; the
// per-workdir marker then only protects cwds inside the workdir, and a
// subprocess that escaped to the workdir's parent would fall back to the
// user's config PAT. The root marker makes the CLI fail closed anywhere
// under the tree. Non-fatal: Prepare re-ensures it per task.
if err := execenv.EnsureWorkspacesRootMarker(d.cfg.WorkspacesRoot); err != nil {
d.logger.Warn("workspaces root marker not written; CLI fail-closed guard limited to task workdirs", "error", err)
}
// Load auth token from CLI config.
if err := d.resolveAuth(); err != nil {
return err
}
// Bind and serve the health port before the (potentially slow) preflight,
// so `daemon start` and the desktop see a live "starting" daemon instead
// of connection-refused while preflightAuth runs. preflightAuth's initial
// workspace sync detects every configured agent's version by exec'ing it,
// which on a cold cache with many agents takes ~20s. Liveness (port up) and
// readiness (status:"running") are reported separately: /health stays
// "starting" until d.ready is set after preflight, so a slow or *failing*
// preflight is never misreported as a started daemon. resolveAuth has
// already run, so a missing token still fails fast before we begin serving.
go d.serveHealth(ctx, healthLn, time.Now())
// Renew the PAT before the first API call, then do the initial
// workspace sync. Both steps live in preflightAuth so the ordering
// invariant (renew first) is enforced at one site instead of
// scattered into Run, and tests can exercise the failure paths
// without the full Run setup.
if err := d.preflightAuth(ctx); err != nil {
return err
}
// Deregister runtimes on shutdown (uses a fresh context since ctx will be cancelled).
defer d.deregisterRuntimes()
// Start workspace sync loop to discover newly created workspaces.
go d.workspaceSyncLoop(ctx)
go d.terminalReportReplayLoop(ctx)
// Discover agent CLIs installed after startup (MUL-5439). Separate from the
// workspace sync loop because that one runs on a thirty-minute consistency
// interval — far too slow for "install a CLI, see it under Runtimes".
go d.agentDiscoveryLoop(ctx)
taskWakeups := make(chan taskWakeup, 256)
go d.taskWakeupLoop(ctx, taskWakeups)
go d.heartbeatLoop(ctx)
go d.gcLoop(ctx)
go d.autoUpdateLoop(ctx)
go d.tokenRenewalLoop(ctx)
// Preflight succeeded and the background loops are up: the daemon has
// registered its runtimes and can now claim and run tasks. Flip /health
// from "starting" to "running" — this is the signal `daemon start`'s
// readiness wait blocks on, so success is reported only after startup
// actually completed, not merely because the health port came up.
d.ready.Store(true)
d.logger.Debug("background loops launched (workspace-sync, terminal-report-replay, task-wakeup, heartbeat, gc, auto-update, token-renewal); health now reporting ready")
err = d.pollLoop(ctx, taskWakeups)
d.logger.Debug("daemon main loop returning", "error", err)
return err
}
// RestartBinary returns the path to the new binary if the daemon needs to restart
// after a successful update, or empty string if no restart is needed.
func (d *Daemon) RestartBinary() string {
d.restartMu.Lock()
defer d.restartMu.Unlock()
return d.restartBinary
}
// deregisterRuntimes notifies the server that all runtimes are going offline.
func (d *Daemon) deregisterRuntimes() {
runtimeIDs := d.allRuntimeIDs()
if len(runtimeIDs) == 0 {
d.logger.Debug("deregister: no runtimes to deregister")
return
}
d.logger.Debug("deregistering runtimes on shutdown", "count", len(runtimeIDs), "runtime_ids", runtimeIDs)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := d.client.Deregister(ctx, runtimeIDs, nil); err != nil {
d.logger.Warn("failed to deregister runtimes on shutdown", "error", err)
} else {
d.logger.Info("deregistered runtimes", "count", len(runtimeIDs))
}
}
// resolveAuth loads the auth token from the CLI config for the active profile.
func (d *Daemon) resolveAuth() error {
cfg, err := cli.LoadCLIConfigForProfile(d.cfg.Profile)
if err != nil {
return fmt.Errorf("load CLI config: %w", err)
}
if cfg.Token == "" {
loginHint := "'multica login'"
if d.cfg.Profile != "" {
loginHint = fmt.Sprintf("'multica login --profile %s'", d.cfg.Profile)
}
d.logger.Warn("not authenticated — run " + loginHint + " to authenticate, then restart the daemon")
return fmt.Errorf("not authenticated: run %s first", loginHint)
}
d.client.SetToken(cfg.Token)
d.logger.Info("authenticated")
d.logger.Debug("auth token loaded", "profile", d.cfg.Profile, "token_len", len(cfg.Token))
return nil
}
// allRuntimeIDs returns all runtime IDs across all watched workspaces.
func (d *Daemon) allRuntimeIDs() []string {
d.mu.Lock()
defer d.mu.Unlock()
var ids []string
for _, ws := range d.workspaces {
ids = append(ids, ws.runtimeIDs...)
}
return ids
}
// findRuntime looks up a Runtime by its ID.
func (d *Daemon) findRuntime(id string) *Runtime {
d.mu.Lock()
defer d.mu.Unlock()
if rt, ok := d.runtimeIndex[id]; ok {
return &rt
}
return nil
}
// recordProfileLaunch remembers the absolute executable path and fixed launch
// args resolved for a custom runtime profile. Called from
// registerRuntimesForWorkspace. Lazily initializes the map so test fixtures
// that build a Daemon literal without seeding every map don't panic.
func (d *Daemon) recordProfileLaunch(profileID, path, version string, fixedArgs []string) {
if profileID == "" || path == "" {
return
}
d.mu.Lock()
defer d.mu.Unlock()
if d.profileLaunchSpecs == nil {
d.profileLaunchSpecs = make(map[string]profileLaunchSpec)
}
d.profileLaunchSpecs[profileID] = profileLaunchSpec{
path: path,
version: version,
fixedArgs: append([]string(nil), fixedArgs...),
}
}
// customProfileLaunchForRuntime returns the resolved custom executable path and
// fixed args for a claimed task's RuntimeID, and whether the runtime is a
// custom-profile runtime. It returns false for built-in runtimes (no profile)
// and for runtimes whose profile command was never resolved on this host.
func (d *Daemon) customProfileLaunchForRuntime(runtimeID string) (profileLaunchSpec, bool) {
if runtimeID == "" {
return profileLaunchSpec{}, false
}
d.mu.Lock()
defer d.mu.Unlock()
rt, ok := d.runtimeIndex[runtimeID]
if !ok || rt.ProfileID == "" {
return profileLaunchSpec{}, false
}
spec, ok := d.profileLaunchSpecs[rt.ProfileID]
if !ok || spec.path == "" {
return profileLaunchSpec{}, false
}
spec.fixedArgs = append([]string(nil), spec.fixedArgs...)
return spec, true
}
// runtimeVersionProbeConcurrency bounds how many `<cli> --version` probes run
// at once during registration. Version detection is process-spawn bound rather
// than CPU bound, and each probe already carries its own timeout, so a modest
// fan-out is safe even when a host has many agent CLIs installed.
const runtimeVersionProbeConcurrency = 8
// runtimeVersionProbeAttempts bounds how many times one provider's `<cli>
// --version` probe runs inside a single probe round before that provider is
// dropped from the registration payload.
//
// A round now serves a whole batch of workspace registrations (MUL-5225), so a
// single failed attempt no longer costs one workspace its runtime — it costs
// every workspace registered in that batch, and nothing re-probes until a
// daemon restart or a standalone re-registration. That amplification is worth
// one retry because the failure is usually transient: cfg.Agents only holds
// CLIs that resolved when the daemon started, so a probe failing later is
// typically a version manager swapping the binary in place (vanished path,
// ETXTBSY) or fork/exec briefly failing under a startup burst. Retrying only
// the failed providers keeps the round O(M) — it never reintroduces
// per-workspace probing.
const runtimeVersionProbeAttempts = 2
// runtimeVersionProbeRetryDelay spaces a retry so an in-flight binary swap or a
// momentary fork/exec failure has time to settle. Providers are probed
// concurrently, so a round pays this once, not once per failed provider.
// Overridable for tests.
var runtimeVersionProbeRetryDelay = 500 * time.Millisecond
// runtimeVersionProbeRetryWindow gates the retry on how quickly the failure
// came back. A probe that burned its full timeout is a hung CLI, not a hiccup,
// and retrying it would double the worst case for the whole round — the same
// latency that used to push the desktop runtime step into its empty "no runtime
// found" state before probes were parallelized (MUL-5119). Only fast failures,
// which are the transient ones, are retried.
//
// The window is measured over the WHOLE attempt, self-heal included: a vanished
// pinned path sends resolveAgentEntry through its own version probe of the
// re-resolved candidate, so an attempt can spend its entire budget there and
// still fail instantly on the outer probe of the stale path.
//
// A self-heal candidate rejected by the minimum-version gate is also retried
// within this window, deliberately: the rejection is a verdict about whatever
// PATH resolved to during the upgrade window the heal exists for, and the
// retry gives the upgrade a beat to publish the new binary before the verdict
// demotes runtimes (see probeBuiltinRuntime).
//
// Overridable for tests.
var runtimeVersionProbeRetryWindow = time.Second
// builtinProbeVerdict distinguishes the ways a provider can fail its probe,
// which callers must treat differently.
//
// "Could not read a version" is transient by construction — the CLI was busy,
// mid-upgrade, or fork/exec hiccuped — so the right response is to leave
// whatever is registered alone and try again. The other two are confirmed
// verdicts about a binary that is on disk right now, and leaving either
// registered means the daemon keeps handing work to a CLI it has already proven
// it cannot use: "read a version and it is below the minimum supported one",
// and "the OS refuses to execute the file at all". Collapsing these into one
// bool made a confirmed verdict indistinguishable from a hiccup, which is what
// let a downgraded CLI keep claiming tasks.
type builtinProbeVerdict int
const (
builtinProbeOK builtinProbeVerdict = iota
builtinProbeUnavailable
builtinProbeBelowMinimum
// builtinProbeNotExecutable: the file resolved, but the OS rejected it as
// not a runnable program (an npm placeholder stub whose postinstall was
// blocked is the case in the field — MUL-6164). Deterministic in the same
// sense as below-minimum: the same bytes will be refused every time until
// someone reinstalls, so retrying is not what fixes it.
builtinProbeNotExecutable
// builtinProbeMissingProfile: the CLI resolved and runs, but the runtime
// profile its backend speaks through is not installed for it. DSH is the
// case in the field: the Multica profile is what gives `dsh` its --stdio
// protocol, so a bare binary answers `--version` and `--probe` refuses.
// Deterministic like the two above — the same binary keeps refusing until
// someone installs the profile — and the reason carries that repair.
builtinProbeMissingProfile
// builtinProbeIncompatibleProfile: something answered `--probe`, with a
// protocol version this daemon does not drive — a bundle older than the
// check, or a newer one this build has yet to catch up with. Reported, and
// deliberately neither installable NOR demotable: the profile is there on
// purpose, so installing over it would replace a considered configuration,
// and this daemon can equally be the stale side of the skew, so taking a
// live runtime offline over it is a destructive answer to a version
// question. It stops a fresh registration and nothing more.
builtinProbeIncompatibleProfile
)
// demotableBuiltinProbeVerdict reports whether a verdict may take a LIVE
// runtime offline, as opposed to merely keeping an unregistered provider
// unregistered.
//
// The distinction is the whole safety property: demotion routes work away from
// a machine, so it belongs only to verdicts that say the CLI definitely cannot
// serve work AND that this daemon is in a position to judge.
//
// builtinProbeIncompatibleProfile fails the second half and is deliberately
// absent. A protocol version this daemon does not drive can equally mean the
// daemon is the stale side of the skew, and tearing down a live runtime because
// THIS build is behind is a destructive answer to a version question. It still
// blocks a fresh registration and is reported on /health; what it must not do is
// take work away from a runtime already serving it.
//
// builtinProbeUnavailable is absent for the plainer reason: it means nothing was
// learned this round.
func demotableBuiltinProbeVerdict(verdict builtinProbeVerdict) bool {
switch verdict {
case builtinProbeBelowMinimum, builtinProbeNotExecutable, builtinProbeMissingProfile:
return true
}
return false
}
// builtinProbeNeedsConfirmation reports whether a demotable verdict must
// reproduce across two probe rounds (confirmCondemned) before it is acted on.
//
// Only builtinProbeBelowMinimum does not: it is a pure function of a version
// string this round already parsed, so a second look reaches the same
// conclusion. The other two are deterministic about the state they describe but
// rest on an observation that is not — an installer overwriting the bin entry in
// place (the very repair we tell users to run) or a DSH upgrade moving the
// profile under a probe. Requiring a second sighting a window later makes those
// windows impossible to mistake for a verdict, and costs a genuinely broken
// provider one extra round.
func builtinProbeNeedsConfirmation(verdict builtinProbeVerdict) bool {
return verdict != builtinProbeBelowMinimum
}
// dshMissingProfileReason is the user-facing explanation for a
// builtinProbeMissingProfile drop. It names the repair because nothing else in
// the daemon's output would: the CLI itself is installed, resolvable and
// answers `--version`, so "not installed" is true only of the profile.
const dshMissingProfileReason = "the Multica runtime profile is not installed; add the Multica DSH runtime bundle to the `multica` profile with `dsh plugin`, or set MULTICA_DSH_PROFILE_BUNDLE so the daemon installs it"
// dshProfileInstallStartedReason replaces it when the operator configured a
// bundle for the daemon to install (MULTICA_DSH_PROFILE_BUNDLE), so /health
// separates "wait for the install" from "nothing is going to happen".
const dshProfileInstallStartedReason = "the Multica runtime profile is not installed; installing the configured bundle now and re-probing when it finishes"
// dshInstallGaveUpReason replaces the "installing now" detail once the
// automatic install has stopped without producing a profile. It says the
// attempt happened and ended, because a reader who saw the earlier "installing"
// reason needs to know which of the two states they are looking at.
const dshInstallGaveUpReason = "the Multica runtime profile is not installed; the automatic install failed, so it has to be installed by hand"
// dshIncompatibleProfileReason is the /health reason for a profile that answers
// with a protocol this daemon does not drive. It names both sides because
// either can be the stale one.
const dshIncompatibleProfileReason = "the Multica runtime profile answers with a protocol version this daemon does not drive; update the profile bundle or the daemon"
// dshProbeFailedReason is the transient reason: the probe did not answer with a
// probe frame at all — a timeout, a failed exec, or unparseable output. It is
// deliberately not phrased as a version problem, which would send a reader
// looking in the wrong place.
const dshProbeFailedReason = "the DSH runtime profile probe returned no usable answer"
// runtimeVerdict is one provider's confirmed verdict: the human reason that
// goes to /health and the daemon log, plus — when the cause is one the user has
// to act on — the structured record the server stores on the runtime row.
//
// The two halves are deliberately separate. The reason is prose for an operator
// reading logs; offline is a stable code plus a repair command for clients,
// which localize their own sentence around it. dispatch/reason.go's rule is
// that a reason code is decided at its source and never reverse-engineered from
// a human-readable string, and this is that source.
type runtimeVerdict struct {
reason string
offline *RuntimeOfflineReason
}
// newRuntimeVerdict pairs a probe verdict with what the server needs to know
// about it. Only a verdict the user must repair carries an offline reason: a
// below-minimum CLI keeps today's behaviour (the runtime goes offline and work
// queues) because changing when THAT blocks a trigger is a separate product
// decision from this one.
//
// execPath is the pinned entry point, which is the right path for this verdict:
// a file the OS refuses to execute is present, so the self-heal that would have
// re-resolved a vanished path never runs, and the probe failed on this exact
// file.
func newRuntimeVerdict(verdict builtinProbeVerdict, reason, execPath string, installing bool) runtimeVerdict {
switch verdict {
case builtinProbeNotExecutable:
offline := &RuntimeOfflineReason{Code: RuntimeOfflineCodeNotExecutable, Detail: reason}
if repair, ok := agent.ExecFormatRepairFor(execPath); ok {
offline.Repair = &repair
}
return runtimeVerdict{reason: reason, offline: offline}
case builtinProbeMissingProfile:
// The same class of finding as an unrunnable file: the machine is
// reachable and the CLI cannot serve work, so the server must refuse
// the trigger and say what is missing rather than queue behind a wait
// that never ends. The one exception is an install the daemon is
// running right now, which is stated explicitly instead of being
// implied by an absent reason — and withdrawn by
// withdrawDshInstallWait if that install gives up.
offline := &RuntimeOfflineReason{
Code: RuntimeOfflineCodeDshProfile,
Detail: reason,
Installing: installing,
}
repair := dshProfileRepair()
offline.Repair = &repair
return runtimeVerdict{reason: reason, offline: offline}
}
return runtimeVerdict{reason: reason}
}
// probeBuiltinRuntime resolves and version-detects one built-in provider,
// retrying a fast failure up to runtimeVersionProbeAttempts times. The verdict
// tells the caller how to treat a drop: builtinProbeUnavailable means the
// version could not be read (or not understood) — transient, leave whatever is
// registered alone — while builtinProbeBelowMinimum is a confirmed too-old
// verdict the caller may demote on. builtinProbeMissingProfile is confirmed in
// the same way, for a CLI that runs but whose backend has no runtime profile
// installed. See builtinProbeVerdict.
//
// The second return value is a short human-readable reason when the verdict is
// not OK. It is surfaced on /health as skipped_agents so a user can tell "CLI
// not installed" apart from "CLI installed but dropped at registration", which
// was previously only visible in the daemon log (MUL-5439).
func (d *Daemon) probeBuiltinRuntime(ctx context.Context, name string, entry AgentEntry) (string, string, builtinProbeVerdict) {
var (
lastErr error
attempts int
// transientReason lets a provider whose probe is not a version probe
// name its own failure. The generic tail would otherwise report "version
// detection failed" for a dsh that answered --version perfectly well,
// which sends a reader looking for the wrong problem.
transientReason string
)
probeLoop:
for attempts < runtimeVersionProbeAttempts {
if attempts > 0 {
select {
case <-ctx.Done():
case <-time.After(runtimeVersionProbeRetryDelay):
}
// A cancelled round is shutting down or already past its deadline;
// keep lastErr pointing at the real probe failure rather than the
// cancellation that stopped us from retrying it.
if ctx.Err() != nil {
break
}
}
attempts++
// Cleared per attempt: this names the DSH profile probe's own failure,
// and only for a round that ENDED there. An attempt that gets past the
// probe and then fails version detection has a more specific reason,
// and a stale value from an earlier attempt would overwrite it at the
// tail — reporting "the probe returned no usable answer" for a probe
// that answered fine.
transientReason = ""
// The attempt is timed from here, not from the detect call below:
// resolveAgentEntry runs a version probe of its own on the re-resolved
// candidate, and that probe can burn the whole timeout by itself. Timing
// only the outer call would read "slow self-heal, then an instant
// failure on the stale path" as a fast failure and retry it, paying the
// slow half twice.
startedAt := time.Now()
// Self-heal a pinned executable path an in-place upgrade deleted
// (MUL-4486) so version detection — and thus staying registered/online —
// recovers without a daemon restart. resolveAgentEntry already
// version-gates the healed binary; the detect + min-version check below
// still runs to produce the version string this registration reports.
// It is re-run per attempt because the heal itself can be what a retry
// fixes: the upgrade that removed the old path may not have published
// the new one yet on the first attempt.
resolved, _, heal := d.resolveAgentEntryWithHeal(ctx, name, entry)
// The pinned path is gone and the binary its command resolves to now is
// too old. Unlike the direct case below, this verdict is about whatever
// PATH resolves to at this instant — and the pinned path vanishing is
// exactly the mid-upgrade window the per-attempt heal exists for, where
// a stale sibling install can shadow the not-yet-published new binary.
// Give it the same bounded fast-failure retry as every other outcome
// before returning the demotable verdict; a retry that finds the
// upgraded binary adopts it instead. It is still the only way this
// shape reaches the caller: probing the vanished path can only produce
// "version detection failed", which by design leaves the runtime online
// and claiming tasks for a CLI that cannot launch.
if heal.rejected != nil {
if attempts < runtimeVersionProbeAttempts && time.Since(startedAt) < runtimeVersionProbeRetryWindow {
d.logger.Debug("re-resolved agent version too old; retrying probe",
"name", name, "attempt", attempts, "version", heal.rejected.Detected)
continue
}
d.logger.Warn("skip registering runtime: re-resolved version too old",
"name", name, "version", heal.rejected.Detected, "error", heal.rejected.Error())
return heal.rejected.Detected, heal.rejected.Error(), builtinProbeBelowMinimum
}
version, err := detectAgentVersion(ctx, agent.Command{Path: resolved.Path})
if err != nil {
lastErr = err
if time.Since(startedAt) >= runtimeVersionProbeRetryWindow {
break
}
if attempts < runtimeVersionProbeAttempts {
d.logger.Debug("agent version probe failed; retrying", "name", name, "attempt", attempts, "error", err)
}
continue
}
if err := checkAgentMinVersion(name, version); err != nil {
var tooOld *agent.BelowMinimumError
if errors.As(err, &tooOld) {
// The verdict is a pure function of a version that PARSED, so a
// retry would reach the same conclusion — drop the provider now.
d.logger.Warn("skip registering runtime: version too old", "name", name, "version", version, "error", err)
// The version is returned even though the provider is dropped: the
// caller needs it to report what it demoted.
return version, err.Error(), builtinProbeBelowMinimum
}
// The CLI ran but printed something (or nothing) the gate could not
// parse. That is "we didn't learn a version", not "we verified it is
// too old" — the same transient rule as a failed exec, because the
// below-minimum verdict tears runtimes down and must never fire on
// evidence this thin.
lastErr = err
if time.Since(startedAt) >= runtimeVersionProbeRetryWindow {
break
}
if attempts < runtimeVersionProbeAttempts {
d.logger.Debug("agent version unparseable; retrying", "name", name, "attempt", attempts, "error", err)
}
continue
}
// DSH is the one built-in whose binary being present, and runnable, and
// new enough still does not make it usable: the Multica runtime profile
// supplies the --stdio protocol the backend drives, so a `dsh` without
// it resolves, answers `--version`, clears the minimum, and then cannot
// run a single task. Checked here rather than in probeAgentCLIs so the
// drop produces a verdict: without it the provider disappeared from the
// availability set silently, which is indistinguishable to a user from
// "Multica cannot see my dsh at all".
//
// LAST, after version detection has succeeded, and that order is the
// point. probeDshMulticaProfile cannot tell "the profile refused" from
// "the thing I ran is not a working CLI", and exec.LookPath is far too
// weak a proxy for the second: on Windows the CLI is a .cmd shim that
// LookPath happily resolves and that exits 9009 — cmd.exe's "command
// not found" — when what it forwards to is missing. Probing the profile
// first reported that machine as "the Multica runtime profile is not
// installed", which is a repair for a problem it did not have, and with
// a bundle configured would have started installing into a DSH that
// cannot execute. A CLI that cannot answer `--version` is not one this
// daemon has any business holding an opinion about the profile of, so
// its failure is reported as what it is by the version-probe path
// above.
if name == "dsh" {
// What the probe said decides what may happen next. Only a
// confirmed-absent profile may install a bundle or condemn the
// runtime. An incompatible one is reported and neither installed
// over nor demoted — the profile is deliberately there, and this
// daemon may be the stale side of the skew. A probe that merely
// failed is transient, on the same rule every other provider's
// version probe gets.
switch probeDshMulticaProfile(ctx, resolved.Path) {
case dshProbeOK:
case dshProbeMissingProfile:
d.logger.Warn("skip registering runtime: DSH Multica runtime profile is not installed",
"name", name, "path", resolved.Path)
reason := dshMissingProfileReason
if d.startDshProfileProvision(resolved.Path) {
reason = dshProfileInstallStartedReason
}
return "", reason, builtinProbeMissingProfile
case dshProbeIncompatible:
d.logger.Warn("skip registering runtime: DSH Multica runtime profile speaks another protocol",
"name", name, "path", resolved.Path)
return "", dshIncompatibleProfileReason, builtinProbeIncompatibleProfile
default:
// Transient. Retry inside this round's budget, then leave the
// loop for the tail, which reports it as the transient drop it
// is. The labelled break matters: a bare one would leave only
// the switch and fall through to registering a runtime whose
// profile answered nothing.
lastErr = errors.New(dshProbeFailedReason)
transientReason = dshProbeFailedReason
if time.Since(startedAt) >= runtimeVersionProbeRetryWindow {
break probeLoop
}
if attempts < runtimeVersionProbeAttempts {
d.logger.Debug("dsh profile probe failed; retrying", "name", name, "attempt", attempts)
}
continue
}
}
d.setAgentVersion(name, version)
d.refreshHealedVersion(name, resolved.Path, version)
if version == "" {
// A provider with no minimum-version floor reaches here with a blank
// when its `--version` exits 0 printing nothing. setAgentVersion just
// refused to let it overwrite the cache; the payload needs the same
// protection, because a whole-set re-registration triggered by a
// NEIGHBOUR's upgrade would otherwise send the blank and wipe a
// version the server already knows. Reuse the last known one; a
// provider that never had a version stays blank, as before.
version = d.agentVersion(name)
}
d.logger.Debug("agent version detected", "name", name, "version", version, "path", resolved.Path)
return version, "", builtinProbeOK
}
// The OS refusing to execute the file is not a failed probe, it is a
// finding: the CLI is installed, resolvable, and unrunnable. Report it as
// its own verdict so the caller can take the runtime offline instead of
// keeping it online for a binary that cannot start (MUL-6164). The
// diagnosis attached in pkg/agent rides along as the reason, so /health
// carries the repair command and not just the errno.
if agent.IsExecFormatError(lastErr) {
d.logger.Warn("skip registering runtime: agent CLI is not executable on this machine",
"name", name, "attempts", attempts, "error", lastErr)
return "", fmt.Sprintf("agent CLI is not executable: %v", lastErr), builtinProbeNotExecutable
}
d.logger.Warn("skip registering runtime", "name", name, "attempts", attempts, "error", lastErr)
reason := "version detection failed"
if lastErr != nil {
reason = fmt.Sprintf("version detection failed: %v", lastErr)
}
if transientReason != "" {
reason = transientReason
}
return "", reason, builtinProbeUnavailable
}
// detectBuiltinRuntimes probes configured CLIs with bounded concurrency and
// returns registration entries that resolve and meet minimum versions. Reuse a
// single probe result when registering several workspaces.
//
// Return this round's confirmed demotion evidence and unavailable-provider reasons
// separately. Shared diagnostic snapshots may belong to another concurrent probe.
// Authoritative reconciliation must preserve unavailable providers' existing rows;
// a transient probe failure is not evidence that a runtime should be demoted.
func (d *Daemon) detectBuiltinRuntimes(ctx context.Context) ([]map[string]string, map[string]runtimeVerdict, map[string]string) {
type detected struct {
name string
version string
}
// Snapshot before any probe runs: everything sampled below is at least as
// new as every verdict recorded up to this point, which is exactly the
// claim clearProviderDemotions needs and cannot make from timing alone.
sampledAfter := d.demotionSeqSnapshot()
var (
mu sync.Mutex
results []detected
skipped = map[string]string{}
demotable = map[string]runtimeVerdict{}
unavailable = map[string]string{}
g errgroup.Group
)
g.SetLimit(runtimeVersionProbeConcurrency)
for name, entry := range d.agents() {
name, entry := name, entry
g.Go(func() error {
version, reason, verdict := d.probeBuiltinRuntime(ctx, name, entry)
if verdict != builtinProbeOK {
demote := demotableBuiltinProbeVerdict(verdict) &&
(!builtinProbeNeedsConfirmation(verdict) ||
d.confirmCondemned(verdict, name, time.Now()))
mu.Lock()
skipped[name] = reason
if demote {
demotable[name] = newRuntimeVerdict(verdict, reason, entry.Path, d.dshInstallInFlight.Load())
} else {
unavailable[name] = reason
}
mu.Unlock()
return nil
}
d.clearCondemned(name)
mu.Lock()
results = append(results, detected{name: name, version: version})
mu.Unlock()
return nil
})
}
// No probe returns a non-nil error — failures are logged and skipped above —
// so Wait only blocks for the in-flight probes to finish.
_ = g.Wait()
// Publish this round's drops for /health. Replacing (not merging) keeps the
// diagnostic honest: a provider that registered successfully this round must
// not stay listed as skipped.
d.setSkippedAgents(skipped)
// Source iteration (a map) and parallel completion order are both
// nondeterministic; sort by provider so the registration payload is stable
// across runs and order-sensitive tests stay deterministic.
sort.Slice(results, func(i, j int) bool { return results[i].name < results[j].name })
// A provider that probes OK is no longer below the minimum, so release any
// demotion held against it — otherwise the register that converge is about
// to make would be rejected by the very guard that protects the demotion.
// Only holds that already existed when this round started sampling are
// released; see clearProviderDemotions for why "returned last" is not the
// same as "sampled last".
recovered := make([]string, 0, len(results))
for _, r := range results {
recovered = append(recovered, r.name)
}
d.clearProviderDemotions(recovered, sampledAfter)
runtimes := make([]map[string]string, 0, len(results))
for _, r := range results {
displayName := providerDisplayName(r.name)
if d.cfg.DeviceName != "" {
displayName = fmt.Sprintf("%s (%s)", displayName, d.cfg.DeviceName)
}
runtimes = append(runtimes, map[string]string{
"name": displayName,
"type": r.name,
"version": r.version,
"status": "online",
})
}
return runtimes, demotable, unavailable
}
// cloneRuntimeEntries deep-copies a registration runtime payload. Callers that
// receive a shared built-in payload (see registerRuntimesForWorkspaceBatch) use
// this before appending their own workspace's custom runtime profiles, so one
// workspace's profiles can never leak into another's registration.
func cloneRuntimeEntries(in []map[string]string) []map[string]string {
out := make([]map[string]string, 0, len(in))
for _, entry := range in {
cp := make(map[string]string, len(entry))
for k, v := range entry {
cp[k] = v
}
out = append(out, cp)
}
return out
}
// registerRuntimesForWorkspace registers this host's runtimes for one
// workspace, probing the built-in agent CLIs itself. This is the entry point
// for every standalone registration — a runtime_gone re-register, a profile
// drift refresh, a recovery retry — so each of those still re-detects versions
// and picks up an in-place CLI upgrade.
//
// Registering a batch of workspaces at once (daemon startup) goes through
// registerRuntimesForWorkspaceBatch instead, which shares one probe round
// across the batch.
//
// The third return value is the set of providers this round's response must not
// be treated as authoritative about, provider to reason — see
// preserveProvidersFromProbe. Callers that apply the response as AUTHORITATIVE
// must pass it to applyRegisterResponseInPlace.
//
// The caller must hold the workspace's register lock (withWorkspaceRegisterLock)
// for this call, the apply, and the cleanup that follows it.
func (d *Daemon) registerRuntimesForWorkspaceLocked(ctx context.Context, workspaceID string) (*RegisterResponse, string, map[string]string, error) {
builtins, belowMinimum, unavailable := d.detectBuiltinRuntimes(ctx)
resp, profileSig, err := d.registerRuntimesForWorkspaceBatchLocked(ctx, workspaceID, builtins)
return resp, profileSig, preserveProvidersFromProbe(unavailable, belowMinimum), err
}
// preserveProvidersFromProbe returns the providers whose absence from a
// registration payload must NOT be read as "this workspace should stop hosting
// them", keyed by provider.
//
// Unavailable is the obvious half: the version could not be read, which is
// transient, so dropping the rows would tear a working runtime down over one
// failed probe.
//
// Demotable is the half that is easy to get wrong, because the verdict IS
// confirmed — a version was read and rejected, or the OS refused to run the
// file. What is missing on these paths is not the evidence but the authority to
// act on it. Taking a runtime offline requires two things neither the
// runtime_gone recovery nor the profile-drift refresh has: the claim barrier, so
// the rows are never pulled out from under a task that is still executing, and a
// seq-stamped hold, so a register sent before the verdict cannot revive the
// provider when it lands. demoteUnusableRuntimes has both and is the single
// owner of the demotion. A path that drops the rows without them reaches the
// same verdict and leaves no record that it did, so the next in-flight response
// quietly undoes it.
//
// Preserving here costs at most one refresh tick of an unusable CLI staying
// online, which is the pre-demotion status quo rather than a new exposure.
func preserveProvidersFromProbe(unavailable map[string]string, demotable map[string]runtimeVerdict) map[string]string {
if len(demotable) == 0 {
return unavailable
}
preserve := make(map[string]string, len(unavailable)+len(demotable))
for provider, reason := range unavailable {
preserve[provider] = reason
}
for provider, verdict := range demotable {
preserve[provider] = verdict.reason
}
return preserve
}
// registerRuntimesForWorkspaceBatch registers one workspace against an already
// detected built-in runtime payload.
//
// Built-in CLIs are a machine-level fact, not a per-workspace one, but
// registration is per-workspace — so probing inside every registration made a
// daemon serving N workspaces spawn N×M `<cli> --version` processes at startup
// (24 workspaces × 5 agents = 120 instead of 5, MUL-5225 / #5837). Beyond the
// wasted startup time, some CLI wrappers have visible side effects when
// executed, and a single slow probe gets multiplied by the workspace count.
//
// Taking the payload as a parameter lets one probe round serve a whole
// registration batch while keeping the refresh semantics: nothing is cached on
// the Daemon, so the next standalone registration re-probes.
//
// builtins is treated as read-only and is copied before this workspace's custom
// runtime profiles are appended.
//
// The caller must hold the workspace's register lock (withWorkspaceRegisterLock)
// for this call, the apply, and the cleanup that follows it.
func (d *Daemon) registerRuntimesForWorkspaceBatchLocked(ctx context.Context, workspaceID string, builtins []map[string]string) (*RegisterResponse, string, error) {
d.logger.Debug("registering runtimes for workspace", "workspace_id", workspaceID, "agent_count", len(d.agents()))
runtimes := cloneRuntimeEntries(builtins)
var failedProfiles []map[string]string
// Append any workspace custom runtime profiles whose command resolves on
// this host (MUL-3284). This is best-effort: a fetch error (e.g. an older
// server returning 404) must never fail registration — the daemon simply
// continues with the built-in runtimes it already collected. A profile
// whose command_name is neither on PATH nor already discovered for the same
// protocol family is skipped (the host doesn't have it).
//
// profileSig is a content hash of the workspace's profile list captured
// here so an on-demand server notification can skip re-registration when
// the effective profile set is already current (MUL-3332). An empty string
// means the fetch failed and the caller must keep whatever signature was
// previously cached on the workspaceState.
profileSig := d.appendProfileRuntimes(ctx, workspaceID, &runtimes, &failedProfiles)
if len(runtimes) == 0 && len(failedProfiles) == 0 {
// profileSig is still meaningful even when nothing resolves: the
// refresh path uses it to remember "we already converged on the
// disabled-everywhere state" so duplicate change notifications are a
// no-op instead of a re-empty-register loop. Initial-registration
// callers that don't care about the sig discard it via _.
return nil, profileSig, ErrNoRuntimesToRegister
}
req := map[string]any{
"workspace_id": workspaceID,
"daemon_id": d.cfg.DaemonID,
"legacy_daemon_ids": d.cfg.LegacyDaemonIDs,
"device_name": d.cfg.DeviceName,
"cli_version": d.cfg.CLIVersion,
"launched_by": d.cfg.LaunchedBy,
"runtimes": runtimes,
"failed_profiles": failedProfiles,
}
resp, err := d.client.Register(ctx, req)
if err != nil {
return nil, "", fmt.Errorf("register runtimes: %w", err)
}
if len(resp.Runtimes) == 0 && len(failedProfiles) == 0 {
return nil, "", fmt.Errorf("register runtimes: empty response")
}
d.logger.Debug("register response", "workspace_id", workspaceID, "runtimes", len(resp.Runtimes), "repos", len(resp.Repos), "repos_version", resp.ReposVersion)
d.recordBuiltinVersionsSent(workspaceID, runtimes)
return resp, profileSig, nil
}
// registerBuiltinRuntimesForWorkspace registers ONLY the built-in runtimes for a
// workspace: no custom runtime profiles are fetched or sent, and no profile
// signature is produced.
//
// This exists for the CLI-discovery path (MUL-5439), which must not participate
// in custom-profile convergence at all. Going through the profile-appending
// registration made discovery observe the profile set as a side effect, and
// caching that observation told the drift path "already converged" — so a
// profile disabled at the same moment a new CLI was discovered would keep its
// runtime alive forever (refreshWorkspaceRuntimeProfiles short-circuits on a
// matching signature). Custom profile add/edit/disable stays exclusively with
// the existing drift path.
//
// builtins is treated as read-only.
//
// The caller must hold the workspace's register lock (withWorkspaceRegisterLock)
// for this call, the merge, and the cleanup that follows it.
func (d *Daemon) registerBuiltinRuntimesForWorkspaceLocked(ctx context.Context, workspaceID string, builtins []map[string]string) (*RegisterResponse, error) {
runtimes := cloneRuntimeEntries(builtins)
if len(runtimes) == 0 {
return nil, ErrNoRuntimesToRegister
}
req := map[string]any{
"workspace_id": workspaceID,
"daemon_id": d.cfg.DaemonID,
"legacy_daemon_ids": d.cfg.LegacyDaemonIDs,
"device_name": d.cfg.DeviceName,
"cli_version": d.cfg.CLIVersion,
"launched_by": d.cfg.LaunchedBy,
"runtimes": runtimes,
// Deliberately empty: this call carries no profiles, so it must not
// report profile failures either.
"failed_profiles": []map[string]string{},
}
resp, err := d.client.Register(ctx, req)
if err != nil {
return nil, fmt.Errorf("register builtin runtimes: %w", err)
}
if len(resp.Runtimes) == 0 {
return nil, fmt.Errorf("register builtin runtimes: empty response")
}
d.logger.Debug("builtin register response", "workspace_id", workspaceID, "runtimes", len(resp.Runtimes))
d.recordBuiltinVersionsSent(workspaceID, runtimes)
return resp, nil
}
// appendProfileRuntimes fetches the workspace's enabled custom runtime
// profiles (MUL-3284) and appends a runtime registration entry for each one
// whose command_name resolves on this host. For each resolved profile
// it records the absolute command path and fixed args keyed by profile_id (via
// recordProfileLaunch) so runTask can later launch the custom executable for a
// claimed task.
//
// Best-effort by contract: any error fetching profiles (older server, network
// blip) is logged and swallowed — registration proceeds with the built-in
// runtimes already collected. A profile whose command cannot be resolved is
// skipped with an Info log (this host simply doesn't have that command).
//
// The registration entry mirrors the built-in shape: name = display_name
// (suffixed with the device name like the built-in path), type =
// runtime_type (the compatibility target), version = best-effort detected
// version, status = "online", plus the profile_id the server validates.
//
// Returns a content signature of the fetched profile list (MUL-3332). The
// signature is used by on-demand profile refreshes to ignore duplicate change
// notifications while still triggering a re-register without a daemon
// restart. Returns the empty string when the fetch failed — callers must treat
// that as "unknown, do not overwrite a previously-stored signature" (otherwise
// a transient 5xx would silently flip the daemon into thinking the workspace
// has zero profiles).
func (d *Daemon) appendProfileRuntimes(ctx context.Context, workspaceID string, runtimes *[]map[string]string, failedProfiles *[]map[string]string) string {
resp, err := d.client.GetRuntimeProfiles(ctx, workspaceID)
if err != nil {
// Best-effort: never fail registration because profiles couldn't be
// fetched. An older server with no profiles route returns 404.
d.logger.Info("skip custom runtime profiles: fetch failed (continuing with built-in runtimes)",
"workspace_id", workspaceID, "error", err)
return ""
}
if resp == nil {
// Empty payload — same shape as "server has zero profiles". Return
// the digest of an empty list so the sync loop can still detect a
// later transition (zero → first profile added).
return profileSetSignature(nil)
}
for _, profile := range resp.RuntimeProfiles {
runtimeType := agent.ProfileRuntimeType(profile.RuntimeType, profile.ProtocolFamily)
if profile.CommandName == "" || profile.ProtocolFamily == "" {
d.logger.Warn("skip custom runtime profile: missing command_name or protocol_family",
"workspace_id", workspaceID, "profile_id", profile.ID, "display_name", profile.DisplayName)
continue
}
if _, supported := agent.RuntimeProtocolFamily(runtimeType); !supported {
reason := "unsupported runtime_type: " + runtimeType
d.logger.Warn("skip custom runtime profile: unsupported runtime_type",
"workspace_id", workspaceID, "profile_id", profile.ID,
"display_name", profile.DisplayName, "protocol_family", profile.ProtocolFamily)
*failedProfiles = append(*failedProfiles, map[string]string{
"profile_id": profile.ID,
"command_name": profile.CommandName,
"reason": reason,
})
continue
}
// Resolve the executable to launch for this profile. A per-machine
// path override (MUL-3284, `multica runtime profile set-path`) wins
// over the PATH lookup when it is set AND points at a real
// executable — this is how an operator pins a profile to a binary
// that isn't on the daemon's PATH, or selects between multiple
// installs on the same host. A configured-but-unusable override
// (deleted/moved/non-executable) is logged and falls back to PATH
// and then to the matching provider command already discovered at
// daemon startup. The latter matters for GUI-launched daemons whose
// environment cannot resolve a CLI directly even though built-in
// discovery found it through a login shell or provider install path.
// When none of those sources resolves, the profile is skipped.
var resolved string
var failureReason string
if override := strings.TrimSpace(d.cfg.ProfileCommandOverrides[profile.ID]); override != "" {
if path, err := resolveProfileOverridePath(override); err == nil {
resolved = path
d.logger.Info("custom runtime profile: using per-machine command path override",
"workspace_id", workspaceID, "profile_id", profile.ID, "command_path", resolved)
} else {
failureReason = "Configured path override is not executable: " + override
d.logger.Warn("custom runtime profile: command path override not executable; falling back to PATH",
"workspace_id", workspaceID, "profile_id", profile.ID,
"override_path", override, "command_name", profile.CommandName)
}
}
if resolved == "" {
r, err := lookPath(profile.CommandName)
if err != nil {
if discovered, ok := d.agents()[runtimeType]; ok && discovered.Command == profile.CommandName && discovered.Path != "" {
resolved = discovered.Path
d.logger.Info("custom runtime profile: using discovered provider command path",
"workspace_id", workspaceID, "profile_id", profile.ID,
"protocol_family", profile.ProtocolFamily, "command_path", resolved)
} else {
// Host doesn't have this command — expected on hosts that aren't
// provisioned for this profile. Skip without failing.
d.logger.Info("skip custom runtime profile: command not found on PATH or provider discovery",
"workspace_id", workspaceID, "profile_id", profile.ID,
"command_name", profile.CommandName, "error", err)
if failureReason != "" {
failureReason += "; "
}
failureReason += "command not found on PATH or provider discovery: " + profile.CommandName
*failedProfiles = append(*failedProfiles, map[string]string{
"profile_id": profile.ID,
"command_name": profile.CommandName,
"reason": failureReason,
})
continue
}
} else {
resolved = r
}
}
// Best-effort version detection; an empty version is acceptable. The
// probe carries the profile's fixed_args so a wrapper reports the
// version of the CLI it execs rather than its own: `ccms start q36
// --version` is Claude Code's version, `ccms --version` is the
// wrapper's, and only the former means anything to the min-version
// gate (GH #7046).
version, verErr := detectAgentVersion(ctx, agent.NewCommand(resolved,
agent.FilterLaunchPrefix(runtimeType, profile.FixedArgs, d.logger)))
if verErr != nil {
d.logger.Debug("custom runtime profile: version probe failed (registering with empty version)",
"workspace_id", workspaceID, "profile_id", profile.ID, "path", resolved, "error", verErr)
version = ""
}
displayName := profile.DisplayName
if d.cfg.DeviceName != "" {
displayName = fmt.Sprintf("%s (%s)", displayName, d.cfg.DeviceName)
}
d.recordProfileLaunch(profile.ID, resolved, version, profile.FixedArgs)
d.logger.Info("registering custom runtime profile",
"workspace_id", workspaceID, "profile_id", profile.ID,
"protocol_family", profile.ProtocolFamily, "command_path", resolved)
*runtimes = append(*runtimes, map[string]string{
"name": displayName,
"type": runtimeType,
"version": version,
"status": "online",
"profile_id": profile.ID,
})
}
return profileSetSignature(resp.RuntimeProfiles)
}
// profileSetSignature is a stable content hash of the workspace's custom
// runtime profile list (MUL-3332). An on-demand refresh diffs this against the
// cached value after the server reports a create, edit, disable, or delete; a
// mismatch makes the daemon re-register so the new runtime instance appears
// without a restart.
//
// The hashed projection covers exactly the fields that affect what the
// daemon sends in a Register call: ID, Enabled, runtime identity, CommandName,
// FixedArgs (the launch args every agent on this runtime inherits) and
// Visibility (so a hypothetical future per-creator filter still triggers
// drift). Profiles are sorted by ID first so the digest is order-independent
// (the server is allowed to return them in any order).
func profileSetSignature(profiles []RuntimeProfile) string {
if len(profiles) == 0 {
return "0"
}
sorted := append([]RuntimeProfile(nil), profiles...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].ID < sorted[j].ID })
h := fnv.New64a()
// Field separator chosen to never appear in a UUID, slug, or arg.
const sep = "\x1f"
for _, p := range sorted {
fmt.Fprintf(h, "%s%s%t%s%s%s%s%s%s%s",
p.ID, sep,
p.Enabled, sep,
agent.ProfileRuntimeType(p.RuntimeType, p.ProtocolFamily), sep,
p.CommandName, sep,
p.Visibility, sep,
)
for _, a := range p.FixedArgs {
fmt.Fprintf(h, "%s%s", a, sep)
}
// Record list end so [a,b] and [ab] hash differently.
h.Write([]byte("\x1e"))
}
return strconv.FormatUint(h.Sum64(), 16)
}
func newWorkspaceState(workspaceID string, runtimeIDs []string, reposVersion string, repos []RepoData, settings json.RawMessage) *workspaceState {
return &workspaceState{
workspaceID: workspaceID,
runtimeIDs: runtimeIDs,
reposVersion: reposVersion,
allowedRepoURLs: repoAllowlist(repos),
settings: settings,
}
}
func repoAllowlist(repos []RepoData) map[string]struct{} {
allowed := make(map[string]struct{}, len(repos))
for _, repo := range repos {
if repo.URL == "" {
continue
}
allowed[repo.URL] = struct{}{}
}
return allowed
}
func (d *Daemon) setWorkspaceRepoSyncError(workspaceID, syncErr string) {
d.mu.Lock()
defer d.mu.Unlock()
if ws, ok := d.workspaces[workspaceID]; ok {
ws.lastRepoSyncErr = syncErr
}
}
func (d *Daemon) workspaceRepoAllowed(workspaceID, repoURL string) bool {
d.mu.Lock()
defer d.mu.Unlock()
ws, ok := d.workspaces[workspaceID]
if !ok {
return false
}
if _, allowed := ws.allowedRepoURLs[repoURL]; allowed {
return true
}
if _, allowed := ws.taskRepoURLs[repoURL]; allowed {
return true
}
return false
}
// repoBarePathIsLive reports whether some watched workspace still claims the
// repo cached at barePath, so the GC can refuse to evict it.
//
// Answering per-path rather than materializing the whole set lets the GC ask
// again immediately before it deletes. A snapshot taken once per cycle goes
// stale while the caller runs git and filesystem work on each repo in turn,
// which is exactly the window in which a workspace can re-attach one.
//
// It mirrors workspaceRepoAllowed by unioning both sources: allowedRepoURLs
// (workspace-level bindings) and taskRepoURLs (project repos the server
// surfaced through a task claim, which never appear in GetWorkspaceRepos).
// Missing the second set would make the GC evict repos that tasks actively
// check out.
//
// Read from in-memory state on purpose. The alternative — asking the server
// for each workspace's repo list during GC — would make a transient API
// failure look like "nothing is attached", and this set is what protects
// caches from deletion.
func (d *Daemon) repoBarePathIsLive(barePath string) bool {
if d.repoCache == nil || barePath == "" {
return false
}
d.mu.Lock()
defer d.mu.Unlock()
for workspaceID, ws := range d.workspaces {
for url := range ws.allowedRepoURLs {
if d.repoCache.BarePath(workspaceID, url) == barePath {
return true
}
}
for url := range ws.taskRepoURLs {
if d.repoCache.BarePath(workspaceID, url) == barePath {
return true
}
}
}
return false
}
func (d *Daemon) workspaceLastRepoSyncErr(workspaceID string) string {
d.mu.Lock()
defer d.mu.Unlock()
ws, ok := d.workspaces[workspaceID]
if !ok {
return ""
}
return ws.lastRepoSyncErr
}
// workspaceCoAuthoredByEnabled returns whether the Co-authored-by hook should
// be installed for the given workspace. Defaults to true when either setting
// is absent (new workspaces, older servers that don't send settings).
//
// The hook is gated by BOTH the GitHub master switch (`github_enabled`) and
// the dedicated co-author switch (`co_authored_by_enabled`) so flipping the
// workspace's master GitHub toggle off also stops new trailers from landing
// in commits, matching the contract documented in RFC MUL-2414 §4.8.
func (d *Daemon) workspaceCoAuthoredByEnabled(workspaceID string) bool {
d.mu.Lock()
defer d.mu.Unlock()
ws, ok := d.workspaces[workspaceID]
if !ok || len(ws.settings) == 0 {
return true // default: enabled
}
var s struct {
GitHubEnabled *bool `json:"github_enabled"`
CoAuthoredByEnabled *bool `json:"co_authored_by_enabled"`
}
if err := json.Unmarshal(ws.settings, &s); err != nil {
return true // default: enabled when payload is malformed
}
if s.GitHubEnabled != nil && !*s.GitHubEnabled {
return false
}
if s.CoAuthoredByEnabled == nil {
return true // default: enabled
}
return *s.CoAuthoredByEnabled
}
// registerTaskRepos merges task-scoped repos (e.g. project github_repo
// resources lifted into resp.Repos by the claim handler) into the workspace's
// allowlist and kicks off a cache sync for any URLs that aren't yet cached.
//
// It's safe to call with the workspace's own repos — duplicates are
// idempotent. Called from runTask before the agent spawns so
// `multica repo checkout` accepts project-only URLs without an extra round
// trip back to GetWorkspaceRepos (which doesn't carry project resources).
func (d *Daemon) registerTaskRepos(workspaceID, taskID string, repos []RepoData) {
if len(repos) == 0 {
return
}
type repoCandidate struct {
url string
tracked bool
}
d.mu.Lock()
ws, ok := d.workspaces[workspaceID]
if !ok {
d.mu.Unlock()
return
}
if ws.taskRepoURLs == nil {
ws.taskRepoURLs = make(map[string]struct{}, len(repos))
}
if taskID != "" && ws.taskRepoRefs == nil {
ws.taskRepoRefs = make(map[string]map[string]string)
}
candidates := make([]repoCandidate, 0, len(repos))
for _, repo := range repos {
url := strings.TrimSpace(repo.URL)
if url == "" {
continue
}
// Don't re-sync if the URL is already tracked (workspace or task-scoped)
// AND the cache already has it.
_, inWorkspace := ws.allowedRepoURLs[url]
_, inTask := ws.taskRepoURLs[url]
ws.taskRepoURLs[url] = struct{}{}
if taskID != "" {
if ws.taskRepoRefs[taskID] == nil {
ws.taskRepoRefs[taskID] = make(map[string]string, len(repos))
}
if _, exists := ws.taskRepoRefs[taskID][url]; !exists {
ws.taskRepoRefs[taskID][url] = strings.TrimSpace(repo.Ref)
}
}
candidates = append(candidates, repoCandidate{
url: url,
tracked: inWorkspace || inTask,
})
}
d.mu.Unlock()
toSync := make([]RepoData, 0, len(candidates))
for _, candidate := range candidates {
if candidate.tracked && d.repoCache != nil && d.repoCache.Lookup(workspaceID, candidate.url) != "" {
continue
}
toSync = append(toSync, RepoData{URL: candidate.url})
}
if d.repoCache != nil && len(toSync) > 0 {
// Sync in the background — same shape used at workspace registration.
// `ensureRepoReady` reports a meaningful error if the cache isn't ready
// yet, so the agent's first checkout will surface a sync failure
// without silently treating it as a config bug.
d.bgSyncs.Add(1)
go func() {
defer d.bgSyncs.Done()
d.syncWorkspaceRepos(workspaceID, toSync)
}()
}
}
func (d *Daemon) taskRepoDefaultRef(workspaceID, taskID, repoURL string) string {
taskID = strings.TrimSpace(taskID)
repoURL = strings.TrimSpace(repoURL)
if taskID == "" || repoURL == "" {
return ""
}
d.mu.Lock()
defer d.mu.Unlock()
ws, ok := d.workspaces[workspaceID]
if !ok || ws.taskRepoRefs == nil {
return ""
}
return strings.TrimSpace(ws.taskRepoRefs[taskID][repoURL])
}
func (d *Daemon) clearTaskRepoRefs(workspaceID, taskID string) {
taskID = strings.TrimSpace(taskID)
if taskID == "" {
return
}
d.mu.Lock()
defer d.mu.Unlock()
if ws, ok := d.workspaces[workspaceID]; ok && ws.taskRepoRefs != nil {
delete(ws.taskRepoRefs, taskID)
}
}
// waitBackgroundSyncs blocks until every background sync started by
// registerTaskRepos has finished. Intended for test teardown: tests that
// hand the daemon a t.TempDir-backed repo cache must call this before
// returning, otherwise an in-flight clone/fetch can race against TempDir
// cleanup and surface as an unrelated "directory not empty" failure.
func (d *Daemon) waitBackgroundSyncs() {
d.bgSyncs.Wait()
}
func (d *Daemon) syncWorkspaceRepos(workspaceID string, repos []RepoData) {
d.syncWorkspaceReposContext(context.Background(), workspaceID, repos)
}
func (d *Daemon) syncWorkspaceReposContext(ctx context.Context, workspaceID string, repos []RepoData) {
if d.repoCache == nil {
return
}
var err error
if cache, ok := d.repoCache.(interface {
SyncContext(context.Context, string, []repocache.RepoInfo) error
}); ok {
err = cache.SyncContext(ctx, workspaceID, repoDataToInfo(repos))
} else {
err = d.repoCache.Sync(workspaceID, repoDataToInfo(repos))
}
if err != nil {
d.setWorkspaceRepoSyncError(workspaceID, err.Error())
d.logger.Warn("repo cache sync failed", "workspace_id", workspaceID, "error", err)
return
}
d.setWorkspaceRepoSyncError(workspaceID, "")
}
func (d *Daemon) refreshWorkspaceRepos(ctx context.Context, workspaceID string) (*WorkspaceReposResponse, error) {
refreshCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
resp, err := d.client.GetWorkspaceRepos(refreshCtx, workspaceID)
if err != nil {
return nil, err
}
d.mu.Lock()
if ws, ok := d.workspaces[workspaceID]; ok {
ws.reposVersion = resp.ReposVersion
ws.allowedRepoURLs = repoAllowlist(resp.Repos)
// Keep the cached settings in sync with the server. The daemon's
// feature gates (e.g. workspaceCoAuthoredByEnabled) read directly from
// this field, so toggling a Setting in the web UI must update it here
// without requiring a daemon restart. An empty payload from the server
// clears the override and falls back to defaults.
ws.settings = resp.Settings
}
d.mu.Unlock()
// Publish the refreshed Co-authored-by verdict to the repo cache. Hooks
// installed by earlier checkouts read that file at commit time, so this is
// what makes a toggled-off setting apply to checkouts that already exist
// instead of only to the next one (MUL-6921).
d.persistCoAuthoredByState(workspaceID)
return resp, nil
}
// coAuthoredByPublisher is the repo cache's half of the Co-authored-by wiring:
// the state file every installed hook reads at commit time, and the hooks
// themselves in the workspace's bare caches. Optional so test daemons can run
// with a minimal repoCacheBackend.
type coAuthoredByPublisher interface {
WriteCoAuthoredByState(workspaceID string, enabled bool) error
ReconcileCoAuthoredByHooks(workspaceID string, enabled bool) error
ReconcileCoAuthoredByHookInCheckout(checkoutPath, workspaceID string, enabled bool) error
}
// persistCoAuthoredByState records the workspace's current Co-authored-by
// verdict where the installed prepare-commit-msg hooks can see it, and brings
// hooks written by earlier daemon releases — which read no state at all — in
// line with it. Called after every settings refresh and on every workspace
// sync; best-effort, since a failed write only leaves the previous value in
// place.
//
// The daemon is the only writer of this state, and publishes under a
// per-workspace lock with the verdict read INSIDE that lock. Two publishers
// racing (a settings refresh and a sync tick, say) therefore serialize, and the
// one that writes last is the one that read last — a publisher that started
// before a settings update can never overwrite the value that update produced.
func (d *Daemon) persistCoAuthoredByState(workspaceID string) {
d.publishCoAuthoredByState(workspaceID, d.workspaceCoAuthoredByEnabled)
}
// publishCoAuthoredByState is persistCoAuthoredByState with the verdict read
// injected. Production always passes workspaceCoAuthoredByEnabled; tests pass a
// wrapper that parks a publisher between taking the lock and reading, which is
// the one ordering the lock exists to guarantee and cannot be observed
// otherwise.
func (d *Daemon) publishCoAuthoredByState(workspaceID string, verdict func(string) bool) {
if d.repoCache == nil || workspaceID == "" {
return
}
cache, ok := d.repoCache.(coAuthoredByPublisher)
if !ok {
return
}
d.mu.Lock()
ws := d.workspaces[workspaceID]
d.mu.Unlock()
if ws == nil {
return
}
ws.coAuthorPublishMu.Lock()
defer ws.coAuthorPublishMu.Unlock()
enabled := verdict(workspaceID)
if err := cache.WriteCoAuthoredByState(workspaceID, enabled); err != nil {
d.logger.Warn("record co-authored-by state failed", "workspace_id", workspaceID, "error", err)
}
if err := cache.ReconcileCoAuthoredByHooks(workspaceID, enabled); err != nil {
d.logger.Warn("reconcile co-authored-by hooks failed", "workspace_id", workspaceID, "error", err)
}
d.reconcileIsolatedCoAuthoredByHooks(cache, workspaceID, enabled)
}
// isolatedCheckoutScanDepth bounds how far below an env root the sweep looks
// for a checkout. Repos land at <env root>/<workdir>/<repo>, so two levels
// covers the layout with one to spare and keeps the walk from wandering into
// the checked-out source tree.
const isolatedCheckoutScanDepth = 2
// reconcileIsolatedCoAuthoredByHooks applies the workspace setting to hooks
// that live inside task workdirs rather than in the shared bare cache.
//
// Codex on Linux and the Windows sandbox check out with isolated git metadata,
// so their hook sits in <checkout>/.git/hooks and the repo cache cannot see it.
// Left alone, a workdir prepared by an earlier release keeps its unconditional
// hook — and its next commit keeps adding the trailer — until that repo happens
// to be checked out again. Env roots are attributed by their owner record, so a
// workspace never rewrites hooks belonging to another one.
func (d *Daemon) reconcileIsolatedCoAuthoredByHooks(cache coAuthoredByPublisher, workspaceID string, enabled bool) {
root := d.cfg.WorkspacesRoot
if root == "" || workspaceID == "" {
return
}
wsEntries, err := os.ReadDir(root)
if err != nil {
if !os.IsNotExist(err) {
d.logger.Debug("co-authored-by sweep: read workspaces root failed", "error", err)
}
return
}
for _, wsEntry := range wsEntries {
// Dot directories are daemon-internal caches (.repos, .skill-cache),
// never workspace directories — the same rule runGC walks by.
if !wsEntry.IsDir() || strings.HasPrefix(wsEntry.Name(), ".") {
continue
}
wsDir := filepath.Join(root, wsEntry.Name())
envRoots, err := os.ReadDir(wsDir)
if err != nil {
continue
}
for _, envEntry := range envRoots {
if !envEntry.IsDir() || strings.HasPrefix(envEntry.Name(), ".") {
continue
}
envRoot := filepath.Join(wsDir, envEntry.Name())
if !d.envRootBelongsToWorkspace(wsEntry.Name(), envRoot, workspaceID) {
continue
}
for _, checkout := range isolatedCheckoutCandidates(envRoot, isolatedCheckoutScanDepth) {
if err := cache.ReconcileCoAuthoredByHookInCheckout(checkout, workspaceID, enabled); err != nil {
d.logger.Warn("reconcile co-authored-by hook in checkout failed",
"workspace_id", workspaceID, "path", checkout, "error", err)
}
}
}
}
}
// envRootBelongsToWorkspace reports whether an env root is this workspace's, by
// the strongest evidence the release that created it left behind.
//
// Since v0.4.35 the owner record carries the workspace ID, and an exact match
// is required. Env roots prepared before that recorded only a task ID — or, on
// older releases still, no marker at all — and they live under the layout that
// release used: <workspaces root>/<workspace ID>/<task key>. So for an owner
// record that names no workspace, the directory name is the evidence, and it
// has to BE the workspace ID. The readable layout that replaced it always
// appends a suffix (`<slug>-<id tail>`), so a bare workspace UUID can only be
// one of those older roots and this can never alias a modern one.
//
// An unreadable marker attributes nothing: skipping costs a stale hook in one
// workdir, guessing would rewrite hooks in another workspace's.
func (d *Daemon) envRootBelongsToWorkspace(wsDirName, envRoot, workspaceID string) bool {
owner, err := execenv.ReadEnvRootOwner(envRoot)
if err != nil || owner == nil {
return false
}
if owner.WorkspaceID != "" {
return owner.WorkspaceID == workspaceID
}
return wsDirName == workspaceID
}
// isolatedCheckoutCandidates returns directories at or below dir that hold
// their own git metadata. A linked worktree's .git is a file, so only isolated
// checkouts match, and matching stops descending: nothing nested inside a
// checkout is one.
func isolatedCheckoutCandidates(dir string, depth int) []string {
if depth <= 0 {
return nil
}
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
var found []string
for _, entry := range entries {
if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
continue
}
child := filepath.Join(dir, entry.Name())
if info, err := os.Stat(filepath.Join(child, ".git")); err == nil && info.IsDir() {
found = append(found, child)
continue
}
found = append(found, isolatedCheckoutCandidates(child, depth-1)...)
}
return found
}
// publishTrackedCoAuthoredByState writes the current verdict for every tracked
// workspace and reconciles the hooks in their bare caches. Purely local — no
// request — and both halves skip the write when what is on disk already
// matches, so a sync tick on a converged host costs a handful of small reads.
func (d *Daemon) publishTrackedCoAuthoredByState() {
d.mu.Lock()
ids := make([]string, 0, len(d.workspaces))
for id := range d.workspaces {
ids = append(ids, id)
}
d.mu.Unlock()
for _, id := range ids {
d.persistCoAuthoredByState(id)
}
}
// trackedSettingsRefreshTimeout bounds one refreshTrackedWorkspaceSettings
// pass. A workspace it does not reach keeps its cached settings and picks the
// change up on its next repo checkout.
const trackedSettingsRefreshTimeout = 30 * time.Second
// refreshTrackedWorkspaceSettings re-reads repos and settings for workspaces
// this daemon already tracks. Only the server's workspaces-changed hint calls
// it: the periodic sync deliberately makes no repos request for a tracked
// workspace (see syncWorkspacesFromAPI), so without this a settings edit —
// the GitHub master switch, the Co-authored-by toggle — would sit unseen
// until the workspace's next repo checkout.
func (d *Daemon) refreshTrackedWorkspaceSettings(ctx context.Context) {
// The sync loop is single-threaded and still owes the hint its membership
// reconcile, so bound the whole pass rather than letting one slow
// workspace hold every other signal behind it.
ctx, cancel := context.WithTimeout(ctx, trackedSettingsRefreshTimeout)
defer cancel()
d.mu.Lock()
tracked := make(map[string]*workspaceState, len(d.workspaces))
for id, ws := range d.workspaces {
tracked[id] = ws
}
d.mu.Unlock()
for id, ws := range tracked {
if ctx.Err() != nil {
return
}
// Share ensureRepoReady's lock so a checkout in flight and this
// refresh cannot interleave their writes to the same workspace.
if err := ws.repoRefreshMu.Lock(ctx); err != nil {
return
}
_, err := d.refreshWorkspaceRepos(ctx, id)
ws.repoRefreshMu.Unlock()
if err != nil {
d.logger.Debug("workspace settings refresh failed", "workspace_id", id, "error", err)
}
}
}
// refreshWorkspaceRuntimeProfiles fetches the workspace's enabled custom
// runtime profile list (MUL-3332), compares its content signature against
// the value cached on the workspaceState, and triggers a re-register when
// the signature has drifted. This is the entry point used by the daemon
// WebSocket change notification so profiles added / edited / disabled via the
// web UI or CLI become visible without a daemon restart.
//
// Best-effort: a fetch error (older server, network blip) preserves the cached
// signature. A successfully-fetched-but-unchanged signature can result from a
// duplicate notification and short-circuits without any further work.
//
// On drift the function takes a path that deliberately differs from
// reregisterWorkspaceAfterRuntimeGone in two ways:
//
// 1. It does NOT call RecoverOrphans for the returned runtime IDs. The
// server's RecoverOrphanedTasksForRuntime hard-fails every
// dispatched/running/waiting_local_directory task on a runtime, which is
// the correct response when a runtime row was actually deleted server-
// side, but a catastrophic false positive on profile drift: a built-in
// runtime still actively executing tasks would have its work killed
// just because the user added a sibling custom profile.
//
// 2. It tolerates ErrNoRuntimesToRegister (custom-only daemon disables its
// only profile) by Deregistering the now-stale local runtime IDs and
// clearing local tracking. Without this, registerRuntimesForWorkspace
// would short-circuit on the empty list, the daemon would keep polling
// and heartbeating runtimes that should be offline, and the server
// would leave them online for the full 150 s stale-heartbeat window.
//
// The workspaceState pointer is never replaced (matches the invariant
// documented on syncWorkspacesFromAPI and reregisterWorkspaceAfterRuntimeGone).
func (d *Daemon) refreshWorkspaceRuntimeProfiles(ctx context.Context, workspaceID string) error {
refreshCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
resp, err := d.client.GetRuntimeProfiles(refreshCtx, workspaceID)
if err != nil {
// Older server (no profiles route) returns 404; let the on-demand caller
// log the failure at debug level.
return err
}
var profiles []RuntimeProfile
if resp != nil {
profiles = resp.RuntimeProfiles
}
live := profileSetSignature(profiles)
d.mu.Lock()
ws, ok := d.workspaces[workspaceID]
if !ok {
d.mu.Unlock()
// Workspace was removed while the refresh was in flight — nothing to do.
return nil
}
cached := ws.profileSetSig
d.mu.Unlock()
if cached == live {
return nil
}
d.logger.Info("custom runtime profile set changed; refreshing workspace runtimes",
"workspace_id", workspaceID, "previous_sig", cached, "current_sig", live,
"profile_count", len(profiles))
// Send, apply and clean up as one ordered step — see workspaceRegisterLock.
return d.withWorkspaceRegisterLock(workspaceID, func() error {
return d.applyProfileDriftRegistration(ctx, workspaceID)
})
}
// applyProfileDriftRegistration is refreshWorkspaceRuntimeProfiles' registration
// half. The caller must hold the workspace's register lock.
func (d *Daemon) applyProfileDriftRegistration(ctx context.Context, workspaceID string) error {
regResp, profileSig, preserve, err := d.registerRuntimesForWorkspaceLocked(ctx, workspaceID)
if err != nil {
if errors.Is(err, ErrNoRuntimesToRegister) {
// Convergence-to-zero: a custom-only daemon's only enabled
// profile was just disabled / deleted, and there are no built-in
// agents to fall back on. Drop the daemon's local tracking and
// proactively Deregister the orphaned server-side rows so the
// runtime list converges to empty without waiting on the 150 s
// stale-heartbeat sweep.
//
// An empty payload only proves "nothing to host" for the providers
// whose probe actually concluded, and only for the verdicts this path
// is allowed to act on. A provider dropped as UNAVAILABLE or confirmed
// below-minimum is absent for a reason this path must not treat as
// authoritative (see preserveProvidersFromProbe), so its existing
// built-in rows are preserved — the same rule
// applyRegisterResponseInPlace applies — while the profile runtimes
// the drift is actually about still converge. Skipping the whole
// convergence instead would let one permanently unprobeable CLI
// keep a disabled profile's runtime online forever.
return d.convergeWorkspaceRuntimesToZero(ctx, workspaceID, profileSig, preserve)
}
return err
}
newIDs, droppedIDs, ok := d.applyRegisterResponseInPlace(workspaceID, regResp, profileSig, preserve)
if !ok {
return fmt.Errorf("workspace %s no longer tracked", workspaceID)
}
for _, rid := range newIDs {
d.logger.Info("re-registered runtime after profile drift",
"workspace_id", workspaceID, "runtime_id", rid)
}
d.notifyRuntimeSetChanged()
// Drift may have shrunk the runtime set (a profile got disabled while
// other runtimes survive). Eagerly mark those server-side rows offline
// so the runtime list reflects reality immediately; a 5xx blip here is
// fine because the server's stale-heartbeat sweep will pick them up
// within ~150 s as a backstop.
d.deregisterDroppedRuntimes(ctx, workspaceID, droppedIDs, "profile drift", nil)
// Intentionally NO RecoverOrphans here: see method doc.
return nil
}
// convergeWorkspaceRuntimesToZero handles the drift-refresh case where
// registerRuntimesForWorkspaceLocked would have short-circuited because the
// daemon has nothing to host on this workspace anymore. It Deregisters the
// previously-tracked runtime IDs (best-effort) and clears the daemon's local
// tracking so taskWakeup / heartbeat / poll loops stop attempting work
// against runtimes that should now be offline.
//
// The caller must hold the workspace's register lock — this is a cleanup, and it
// has the same ordering requirement as every other one (workspaceRegisterLock).
//
// preserveProviders lists the built-in providers this round's absence from the
// payload says nothing authoritative about (see preserveProvidersFromProbe), so
// their existing built-in rows survive — the same preserve rule
// applyRegisterResponseInPlace applies. Everything else (the disabled/deleted
// profiles' runtimes, and any builtin whose probe concluded) converges away.
//
// The workspaceState pointer is preserved: the workspace itself is still a
// valid workspace the user belongs to, just one with no agents on this
// daemon for the moment. If the user re-enables a profile or installs a
// built-in agent, the profile-change notification or the next daemon WS
// reconnect will register it again.
func (d *Daemon) convergeWorkspaceRuntimesToZero(ctx context.Context, workspaceID, profileSig string, preserveProviders map[string]string) error {
d.mu.Lock()
ws, ok := d.workspaces[workspaceID]
if !ok {
d.mu.Unlock()
return nil
}
// 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).
kept := ws.runtimeIDs[:0:0]
var dropped []string
for _, rid := range ws.runtimeIDs {
if rt, tracked := d.runtimeIndex[rid]; tracked && rt.ProfileID == "" {
if _, preserve := preserveProviders[rt.Provider]; preserve {
kept = append(kept, rid)
continue
}
// A builtin row converging away here was confirmed gone this
// round; drop its version record too, mirroring the demote path.
delete(ws.builtinVersions, rt.Provider)
}
delete(d.runtimeIndex, rid)
dropped = append(dropped, rid)
}
ws.runtimeIDs = kept
if profileSig != "" {
// Cache the converged signature so we don't loop into re-converging
// on every subsequent sync tick.
ws.profileSetSig = profileSig
}
d.mu.Unlock()
d.logger.Info("custom runtime profile drift converged to zero; clearing local tracking",
"workspace_id", workspaceID, "deregistered_runtime_ids", dropped,
"preserved_providers", preserveProviders)
d.deregisterDroppedRuntimes(ctx, workspaceID, dropped, "zero-runtime convergence", nil)
d.notifyRuntimeSetChanged()
return nil
}
func (d *Daemon) ensureRepoReady(ctx context.Context, workspaceID, repoURL string) error {
if d.repoCache == nil {
return fmt.Errorf("repo cache not initialized")
}
repoURL = strings.TrimSpace(repoURL)
d.mu.Lock()
ws, ok := d.workspaces[workspaceID]
d.mu.Unlock()
if !ok {
return fmt.Errorf("workspace is not watched by this daemon: %s", workspaceID)
}
// Record whether the cache already had this repo before we took the
// per-workspace mutex. The two states behave differently below:
//
// - cacheHitOnEntry=true: the repo is already cloned; we still must
// refresh `workspaceState.settings` because the /repo/checkout
// handler reads workspaceCoAuthoredByEnabled right after this. The
// periodic workspace sync deliberately does not refresh repos or
// settings, so this is the point at which a freshly-flipped GitHub
// master switch / `co_authored_by_enabled` toggle becomes live.
//
// - cacheHitOnEntry=false but cache hit *after* we acquire the mutex:
// a sibling goroutine on a concurrent cold-miss already refreshed
// and populated the cache. We can skip the duplicate refresh — the
// sibling's refresh is fresh enough for our gate read.
cacheHitOnEntry := d.workspaceRepoAllowed(workspaceID, repoURL) && d.repoCache.Lookup(workspaceID, repoURL) != ""
if err := ws.repoRefreshMu.Lock(ctx); err != nil {
return err
}
defer ws.repoRefreshMu.Unlock()
if !cacheHitOnEntry && d.workspaceRepoAllowed(workspaceID, repoURL) && d.repoCache.Lookup(workspaceID, repoURL) != "" {
return nil
}
resp, err := d.refreshWorkspaceRepos(ctx, workspaceID)
if err != nil {
return fmt.Errorf("refresh workspace repos: %w", err)
}
if !d.workspaceRepoAllowed(workspaceID, repoURL) {
return ErrRepoNotConfigured
}
if d.repoCache.Lookup(workspaceID, repoURL) != "" {
return nil
}
d.syncWorkspaceReposContext(ctx, workspaceID, resp.Repos)
if err := ctx.Err(); err != nil {
return context.Cause(ctx)
}
if d.repoCache.Lookup(workspaceID, repoURL) != "" {
return nil
}
if syncErr := d.workspaceLastRepoSyncErr(workspaceID); syncErr != "" {
return fmt.Errorf("repo is configured but not synced: %s", syncErr)
}
return fmt.Errorf("repo is configured but not synced")
}
// DefaultTokenRenewalInterval is how often the daemon asks the server to
// extend its PAT. The server-side threshold is 7 days of remaining lifetime;
// polling every ~3 days gives at least two chances to renew before the
// window closes, so a single failed call (network blip, server restart) does
// not push the token out of the renewal window.
const DefaultTokenRenewalInterval = 3 * 24 * time.Hour
// preflightAuth runs the two auth-sensitive startup steps in their
// required order: a synchronous PAT renewal first, then the initial
// workspace sync. The order matters — running tryRenewToken before any
// other API call is what surfaces a user-actionable "run multica login"
// WARN when the PAT is already revoked or expired. If we let the
// workspace sync go first, its 401 would short-circuit Run before the
// renewal loop's first tick ever fires, and the operator would see only
// a generic auth failure in the workspace-sync log with no hint that
// re-login is the fix.
//
// The renewal is best-effort: tryRenewToken logs and returns, never
// propagating errors. preflightAuth's exit status is driven entirely by
// the workspace sync — so a transient renewal failure (network blip,
// 500) does not by itself block startup. A successful sync with zero
// workspaces is fine: a newly-signed-up user may start the daemon
// before creating their first workspace, and workspaceSyncLoop will
// register runtimes once one appears.
func (d *Daemon) preflightAuth(ctx context.Context) error {
d.tryRenewToken(ctx)
err := d.syncWorkspacesFromAPI(ctx, false)
if d.startupMayProceedWithoutRuntimes(err) {
d.logger.Warn("starting with no runtimes registered: an automatic DSH runtime profile install is still running; " +
"the runtime registers when it finishes")
return nil
}
return err
}
// startupMayProceedWithoutRuntimes reports whether a startup that registered
// nothing may continue anyway.
//
// Exactly one situation qualifies: the daemon has just started installing the
// DSH runtime profile itself. On a host whose only provider is a dsh without
// that profile, failing here is a deadlock rather than a fail-fast — the probe
// starts the install, this error kills the process milliseconds later, the
// install's process tree dies with it, and the next start repeats all of it.
// The feature exists precisely for that host, so on it the feature could never
// once complete.
//
// Nothing else is forgiven. An unreachable server, a rejected token, and a
// genuinely empty machine all still fail startup, because for those the daemon
// has no reason to believe the answer is coming.
func (d *Daemon) startupMayProceedWithoutRuntimes(err error) bool {
return errors.Is(err, errNoWorkspaceRuntimesRegistered) && d.dshInstallInFlight.Load()
}
// trackedWorkspaceCount reports how many workspaces the daemon currently hosts.
func (d *Daemon) trackedWorkspaceCount() int {
d.mu.Lock()
defer d.mu.Unlock()
return len(d.workspaces)
}
// registerAfterDshProfileInstall brings a newly usable dsh online without
// waiting for a scheduled round.
//
// A converge round is enough in the ordinary case, but not after the bootstrap
// startupMayProceedWithoutRuntimes allows: a daemon that registered nothing
// tracks no workspace, and every converge path iterates the workspaces it
// tracks, so there would be nothing for the round to look at. The workspace has
// to be picked up first, and waiting for the periodic consistency sync to do it
// would leave the runtime offline for its full interval after an install the
// user watched succeed.
func (d *Daemon) registerAfterDshProfileInstall(ctx context.Context) {
if d.trackedWorkspaceCount() > 0 {
d.kickAgentDiscovery()
return
}
if err := d.syncWorkspacesFromAPI(ctx, false); err != nil {
d.logger.Warn("workspace sync after the DSH runtime profile install failed; "+
"the periodic sync is the backstop", "error", err)
}
}
// tokenRenewalLoop keeps the daemon's PAT alive by periodically asking the
// server to extend its expires_at in-place. The startup renewal happens
// synchronously in preflightAuth so a daemon coming back online after a
// week of downtime gets a fresh expiry before its next heartbeat could
// 401; this loop owns the long-running ~3-day cadence after that.
//
// The server is authoritative on the renewal threshold (it sees expires_at;
// we don't), so this loop is intentionally dumb: call, log, sleep, repeat.
// On 401 we surface a clear "re-login required" warning because the daemon
// has no way to recover automatically — but we keep the loop running so the
// user sees the same warning on every cycle until they fix it, rather than
// silently exiting and forcing them to read scrollback to find the cause.
func (d *Daemon) tokenRenewalLoop(ctx context.Context) {
ticker := time.NewTicker(DefaultTokenRenewalInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
d.tryRenewToken(ctx)
}
}
}
// tryRenewToken performs one renewal round-trip with a short, isolated
// timeout. Errors are logged but never propagated — there is no caller to
// handle them. Failures are debug-level except for 401, which gets a
// user-actionable warning.
func (d *Daemon) tryRenewToken(ctx context.Context) {
reqCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
resp, err := d.client.RenewToken(reqCtx)
if err != nil {
if isUnauthorizedError(err) {
loginHint := "'multica login'"
if d.cfg.Profile != "" {
loginHint = fmt.Sprintf("'multica login --profile %s'", d.cfg.Profile)
}
d.logger.Warn("auth token rejected by server — run "+loginHint+" to re-authenticate, then restart the daemon", "error", err)
return
}
d.logger.Debug("token renewal failed; will retry on next cycle", "error", err)
return
}
if resp.Renewed {
d.logger.Info("auth token renewed", "expires_at", resp.ExpiresAt)
} else {
d.logger.Debug("auth token not yet eligible for renewal", "expires_at", resp.ExpiresAt)
}
}
// workspaceSyncLoop reconciles the user's workspace membership set. Daemons
// with runtimes and account-scoped WS support use a thirty-minute jittered
// consistency check; daemons talking to older servers retain a five-minute
// fallback. Bootstrap daemons without runtimes keep the shorter interval needed
// to discover their first workspace. Account-scoped WS hints trigger an
// immediate minimal sync, while a WS reconnect also reconciles runtime profiles
// changed during the connection gap.
func (d *Daemon) workspaceSyncLoop(ctx context.Context) {
timer := time.NewTimer(jitterDuration(d.workspaceSyncBaseInterval()))
defer timer.Stop()
var reconcileCh <-chan struct{}
if d.reconcile != nil {
reconcileCh = d.reconcile.notify()
}
var workspaceChangesCh <-chan struct{}
if d.workspaceChanges != nil {
workspaceChangesCh = d.workspaceChanges.notify()
}
var consecutiveFailures int
resetTimer := func() {
interval := workspaceSyncBackoff(d.workspaceSyncBaseInterval(), consecutiveFailures)
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
timer.Reset(jitterDuration(interval))
}
syncNow := func(reconcileProfiles bool) {
if err := d.syncWorkspacesFromAPI(ctx, reconcileProfiles); err != nil {
consecutiveFailures++
d.logger.Debug("workspace sync failed", "error", err)
} else {
consecutiveFailures = 0
}
resetTimer()
}
for {
select {
case <-ctx.Done():
return
case <-reconcileCh:
if d.reconcile != nil {
reconcileCh = d.reconcile.notify()
}
// A settings edit made while the websocket was down produced a
// workspaces-changed hint nobody received. Reconnecting is the
// daemon's chance to catch up on it: without this the stale
// verdict would just get republished by the sync below.
d.refreshTrackedWorkspaceSettings(ctx)
syncNow(true)
case <-workspaceChangesCh:
// The hint fires for membership changes AND for workspace edits,
// including settings. Refresh cached settings for the workspaces
// we already track before reconciling the membership set.
d.refreshTrackedWorkspaceSettings(ctx)
syncNow(false)
case <-timer.C:
syncNow(false)
}
}
}
func (d *Daemon) workspaceSyncBaseInterval() time.Duration {
if len(d.allRuntimeIDs()) == 0 {
return DefaultWorkspaceBootstrapSyncInterval
}
if d.client.usesLegacyWorkspaceEndpoint() {
return DefaultWorkspaceLegacySyncInterval
}
return DefaultWorkspaceSyncInterval
}
func workspaceSyncBackoff(base time.Duration, consecutiveFailures int) time.Duration {
maxInterval := DefaultWorkspaceSyncMaxBackoff
if base == DefaultWorkspaceBootstrapSyncInterval {
maxInterval = DefaultWorkspaceLegacySyncInterval
}
interval := base
for i := 0; i < consecutiveFailures; i++ {
if interval >= maxInterval/2 {
return maxInterval
}
interval *= 2
}
if interval > maxInterval {
return maxInterval
}
return interval
}
// syncWorkspacesFromAPI fetches all workspaces the user belongs to and
// registers runtimes for any that aren't already tracked. When
// reconcileProfiles is true (after a daemon WS connect/reconnect), tracked
// workspaces reconcile custom runtime profiles once so a change made while the
// WS was unavailable is not lost. Normal timers and workspace-change hints
// pass false and make no runtime-profile requests. Workspaces the user has
// left are cleaned up.
func (d *Daemon) syncWorkspacesFromAPI(ctx context.Context, reconcileProfiles bool) error {
d.reloading.Lock()
defer d.reloading.Unlock()
apiCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
workspaces, err := d.client.ListWorkspaces(apiCtx)
if err != nil {
return fmt.Errorf("list workspaces: %w", err)
}
d.logger.Debug("workspace sync: fetched workspaces", "count", len(workspaces))
apiIDs := make(map[string]string, len(workspaces)) // id -> name
for _, ws := range workspaces {
apiIDs[ws.ID] = ws.Name
}
d.mu.Lock()
currentIDs := make(map[string]bool, len(d.workspaces))
for id := range d.workspaces {
currentIDs[id] = true
}
d.mu.Unlock()
// Built-in agent CLIs are installed per machine, so one probe round serves
// every workspace this sync has to register (MUL-5225). Probing is lazy —
// a sync that finds nothing new to register never shells out at all, which
// is the common case for the periodic sync — and scoped to this call, so
// the next sync re-detects and an in-place CLI upgrade is still reported.
var (
builtins []map[string]string
builtinsProbed bool
)
probeBuiltins := func() []map[string]string {
if !builtinsProbed {
builtins, _, _ = d.detectBuiltinRuntimes(ctx)
builtinsProbed = true
}
return builtins
}
var registered int
var removed int
for id, name := range apiIDs {
if currentIDs[id] {
if reconcileProfiles {
if err := d.refreshWorkspaceRuntimeProfiles(ctx, id); err != nil {
d.logger.Debug("workspace reconcile: profile refresh failed", "workspace_id", id, "error", err)
}
}
// Only intervene further if the workspace lost all of its
// runtimes (most commonly because handleRuntimeGone pruned them
// and its inline re-register failed). The pointer is not replaced
// here either — ensureRepoReady holds repoRefreshMu from the
// original pointer.
if !d.workspaceNeedsRuntimeRecovery(id) {
continue
}
d.logger.Info("workspace has no runtimes; retrying registration", "workspace_id", id, "name", name)
if err := d.reregisterWorkspaceAfterRuntimeGone(ctx, id); err != nil {
d.logger.Warn("retry register failed", "workspace_id", id, "error", err)
continue
}
registered++
continue
}
payload := probeBuiltins()
var (
resp *RegisterResponse
runtimeIDs []string
)
// Send, publish and clean up as one ordered step — see
// workspaceRegisterLock.
err := d.withWorkspaceRegisterLock(id, func() error {
var profileSig string
var err error
resp, profileSig, err = d.registerRuntimesForWorkspaceBatchLocked(ctx, id, payload)
if err != nil {
return err
}
// First registration is the third path a response reaches local state
// through — it builds the workspaceState directly instead of going via
// applyRegisterResponseInPlace / mergeBuiltinRegisterResponse — so it
// needs the same demotion guard, taken in the same d.mu section that
// publishes the state. A workspace joining while a provider is held
// below-minimum must not be the way that provider comes back.
d.mu.Lock()
runtimeIDs = make([]string, 0, len(resp.Runtimes))
var revived revivedRuntimes
for _, rt := range resp.Runtimes {
if rt.ProfileID == "" && d.providerDemotedLocked(rt.Provider) {
revived.add(d, rt.ID, rt.Provider)
continue
}
runtimeIDs = append(runtimeIDs, rt.ID)
d.logger.Info("registered runtime", "workspace_id", id, "runtime_id", rt.ID, "provider", rt.Provider)
}
ws := newWorkspaceState(id, runtimeIDs, resp.ReposVersion, resp.Repos, resp.Settings)
// Seed the profile signature so later on-demand change notifications can
// detect drift without re-registering on duplicates (empty sig is the
// explicit "unknown — keep the previous value" sentinel from
// appendProfileRuntimes; on first registration there is no previous
// value, so empty stays empty).
ws.profileSetSig = profileSig
// Seed the per-workspace record of what the server was told (the
// register call above ran before this workspaceState existed, so
// recordBuiltinVersionsSent inside it had nowhere to write).
ws.builtinVersions = builtinVersionsFromPayload(payload)
for provider := range ws.builtinVersions {
// Same reason recordBuiltinVersionsSent skips these: a version
// record for a provider whose rows are being refused reads as
// "this workspace is current" to the next refresh round.
if d.providerDemotedLocked(provider) {
delete(ws.builtinVersions, provider)
}
}
d.workspaces[id] = ws
for _, rt := range resp.Runtimes {
if rt.ProfileID == "" && d.providerDemotedLocked(rt.Provider) {
continue
}
d.runtimeIndex[rt.ID] = rt
}
d.mu.Unlock()
// The server upserted the refused rows into existence, so it alone
// would believe they are online and keep routing work to them. Take
// them offline before anything else touches this workspace — and never
// RecoverOrphans them below: that reports tasks for a runtime the
// daemon does not track and will never claim for.
d.deregisterRevivedRuntimes(ctx, id, revived)
return nil
})
if err != nil {
d.logger.Error("failed to register runtimes", "workspace_id", id, "name", name, "error", err)
continue
}
if d.repoCache != nil && len(resp.Repos) > 0 {
go d.syncWorkspaceRepos(id, resp.Repos)
}
// Tell the server about any tasks the previous daemon process was
// running on these runtimes. Without this, an issue can stay stuck
// at in_progress until the slow heartbeat sweeper or the in-flight
// task timeout (2.5h) kicks in.
for _, rid := range runtimeIDs {
if err := d.client.RecoverOrphans(ctx, rid); err != nil {
d.logger.Warn("recover-orphans failed", "runtime_id", rid, "error", err)
}
}
d.logger.Info("watching workspace", "workspace_id", id, "name", name, "runtimes", len(runtimeIDs), "repos", len(resp.Repos))
registered++
}
// Remove workspaces the user no longer belongs to.
for id := range currentIDs {
if _, ok := apiIDs[id]; !ok {
d.mu.Lock()
if ws, exists := d.workspaces[id]; exists {
for _, rid := range ws.runtimeIDs {
delete(d.runtimeIndex, rid)
}
}
delete(d.workspaces, id)
d.mu.Unlock()
d.logger.Info("stopped watching workspace", "workspace_id", id)
removed++
}
}
if registered > 0 || removed > 0 {
d.notifyRuntimeSetChanged()
}
// Republish each tracked workspace's Co-authored-by verdict. This costs no
// request — it writes the daemon's cached verdict where prepare-commit-msg
// hooks read it, and migrates hooks left by earlier releases — and is what
// covers the cases no refresh reaches: a toggle flipped while the daemon
// was down (settings arrive with the register response above), a host that
// just upgraded, and a state file removed by repo cache GC.
d.publishTrackedCoAuthoredByState()
if len(d.allRuntimeIDs()) == 0 && registered == 0 && len(workspaces) > 0 {
return fmt.Errorf("%w for any of the %d workspace(s)", errNoWorkspaceRuntimesRegistered, len(workspaces))
}
if registered > 0 || removed > 0 {
d.logger.Debug("workspace sync done", "registered", registered, "removed", removed, "tracked", len(apiIDs))
}
return nil
}
// heartbeatLoop supervises per-runtime HTTP heartbeat goroutines. Each runtime
// gets an independent ticker so a slow heartbeat for one runtime cannot block
// heartbeats for any other runtime — this matters when a single daemon serves
// multiple workspaces, because the previous shared loop would serialize an
// up-to-30s HTTP timeout across every runtime in the set.
func (d *Daemon) heartbeatLoop(ctx context.Context) {
runtimeSetCh, unsub := d.runtimeSet.Subscribe()
defer unsub()
cancels := make(map[string]context.CancelFunc)
defer func() {
for _, cancel := range cancels {
cancel()
}
}()
sync := func() {
want := make(map[string]struct{})
for _, rid := range d.allRuntimeIDs() {
want[rid] = struct{}{}
}
for rid, cancel := range cancels {
if _, ok := want[rid]; !ok {
cancel()
delete(cancels, rid)
}
}
for rid := range want {
if _, ok := cancels[rid]; ok {
continue
}
rctx, rcancel := context.WithCancel(ctx)
cancels[rid] = rcancel
go d.runRuntimeHeartbeat(rctx, rid)
}
}
sync()
for {
select {
case <-ctx.Done():
return
case <-runtimeSetCh:
sync()
}
}
}
// runRuntimeHeartbeat owns the HTTP heartbeat schedule for a single runtime.
// The first tick fires after a small jittered delay (up to one full interval)
// to avoid a thundering herd when the daemon registers many runtimes at once.
func (d *Daemon) runRuntimeHeartbeat(ctx context.Context, rid string) {
interval := d.cfg.HeartbeatInterval
if interval <= 0 {
interval = 15 * time.Second
}
// Jittered initial delay; cap at the interval so the first beat still
// happens within one period.
if jitter := time.Duration(rand.Int63n(int64(interval))); jitter > 0 {
select {
case <-ctx.Done():
return
case <-time.After(jitter):
}
}
consecutiveTransientFailures := 0
tick := func() {
if d.runHeartbeatTick(ctx, rid) {
consecutiveTransientFailures++
if consecutiveTransientFailures == 2 {
d.client.CloseIdleConnections()
}
return
}
consecutiveTransientFailures = 0
}
tick()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
tick()
}
}
}
// runHeartbeatTick returns true when the HTTP heartbeat hit a transient
// failure that should count toward stale idle-connection cleanup.
func (d *Daemon) runHeartbeatTick(ctx context.Context, rid string) bool {
// Skip HTTP heartbeat for runtimes that successfully acked a recent
// WebSocket heartbeat. The WS path keeps last_seen_at fresh and delivers
// actions, so the HTTP write would be a duplicate DB update. If the WS
// heartbeat goes silent the freshness window expires and HTTP resumes
// automatically on the next tick — that is the fallback the WS path
// relies on.
if d.wsHeartbeatRecentlyAcked(rid) {
d.logger.Debug("heartbeat: skipping HTTP tick, WS recently acked", "runtime_id", rid)
return false
}
d.logger.Debug("heartbeat: HTTP tick", "runtime_id", rid)
resp, err := d.client.SendHeartbeat(ctx, rid)
if err != nil {
if ctx.Err() == nil {
if isRuntimeNotFoundError(err) {
// Server says this runtime is gone — recover instead of
// looping on the dead UUID. handleRuntimeGone coalesces
// concurrent callers and runs the recovery HTTP call under
// the daemon root context so notifyRuntimeSetChanged
// tearing down this heartbeat goroutine cannot abort it.
go d.handleRuntimeGone(rid)
return false
}
d.logger.Warn("heartbeat failed", "runtime_id", rid, "error", err)
}
return ctx.Err() == nil && isTransientError(err)
}
if resp != nil && resp.RuntimeGone {
// The WS path returns a successful ack with RuntimeGone=true for the
// same scenario; treat it the same way here in case HTTP starts
// surfacing this signal too.
go d.handleRuntimeGone(rid)
return false
}
d.handleHeartbeatActions(ctx, rid, resp)
return false
}
// handleHeartbeatActions dispatches the pending-action set returned by either
// transport (HTTP POST /api/daemon/heartbeat or WS daemon:heartbeat_ack).
// Each action is dispatched in its own goroutine so a slow handler cannot
// block subsequent heartbeats.
func (d *Daemon) handleHeartbeatActions(ctx context.Context, runtimeID string, resp *HeartbeatResponse) {
if resp == nil {
return
}
if resp.PendingUpdate != nil || resp.PendingModelList != nil || resp.PendingLocalSkills != nil || resp.PendingLocalSkillImport != nil {
d.logger.Debug("heartbeat: pending actions",
"runtime_id", runtimeID,
"update", resp.PendingUpdate != nil,
"model_list", resp.PendingModelList != nil,
"local_skills", resp.PendingLocalSkills != nil,
"local_skill_import", resp.PendingLocalSkillImport != nil,
)
}
if resp.PendingUpdate != nil {
go d.handleUpdate(ctx, runtimeID, resp.PendingUpdate)
}
if resp.PendingModelList != nil {
if rt := d.findRuntime(runtimeID); rt != nil {
go d.handleModelList(ctx, *rt, resp.PendingModelList.ID)
}
}
if resp.PendingLocalSkills != nil {
if rt := d.findRuntime(runtimeID); rt != nil {
go d.handleLocalSkillList(ctx, *rt, resp.PendingLocalSkills.ID)
}
}
// Prefer the batch field (new backend); fall back to singular (old backend).
if len(resp.PendingLocalSkillImports) > 0 {
if rt := d.findRuntime(runtimeID); rt != nil {
for _, imp := range resp.PendingLocalSkillImports {
go d.handleLocalSkillImport(ctx, *rt, imp)
}
}
} else if resp.PendingLocalSkillImport != nil {
if rt := d.findRuntime(runtimeID); rt != nil {
go d.handleLocalSkillImport(ctx, *rt, *resp.PendingLocalSkillImport)
}
}
}
// handlePendingWorkHint reacts to a server-pushed daemon:pending_work frame by
// sending ONE immediate heartbeat for the runtime, then dispatching whatever
// that heartbeat claimed (MUL-5444).
//
// Why a heartbeat and not the work itself: the hint deliberately carries no
// request payload, so the server never has to un-claim anything when delivery
// fails, and a duplicated or replayed hint cannot duplicate work — the claim
// stays atomic inside the store's PopPending. A hint that never arrives (daemon
// offline, WS gap, relay drop) costs nothing but the pre-existing wait for the
// next scheduled heartbeat.
//
// The HTTP heartbeat is used on purpose rather than queueing a WS frame: the
// hint arrives on the read pump, the WS write path may be backed up or tearing
// down, and this is a human-interactive, low-frequency path where one extra
// request is cheaper than a missed wakeup. Note it intentionally bypasses the
// wsHeartbeatRecentlyAcked suppression that the scheduled HTTP tick honours —
// that suppression exists to avoid duplicate periodic writes, not to block an
// explicitly requested pull.
func (d *Daemon) handlePendingWorkHint(runtimeID, kind string) {
if runtimeID == "" {
return
}
if d.findRuntime(runtimeID) == nil {
// Not one of ours (stale relay fanout, or the runtime was just pruned).
return
}
d.pendingWorkMu.Lock()
if d.pendingWorkInflight == nil {
d.pendingWorkInflight = make(map[string]struct{})
}
if d.pendingWorkLastRun == nil {
d.pendingWorkLastRun = make(map[string]time.Time)
}
// Drop long-idle bookkeeping so the map can't grow with every runtime this
// process has ever seen.
for id, at := range d.pendingWorkLastRun {
if time.Since(at) > pendingWorkHintBookkeepingTTL {
delete(d.pendingWorkLastRun, id)
}
}
if _, inflight := d.pendingWorkInflight[runtimeID]; inflight {
d.pendingWorkMu.Unlock()
return
}
// Rate limit per runtime. The hint is caller-triggered by interactive model
// or capability endpoints, so without a floor a request loop would become a
// heartbeat amplifier. A suppressed hint costs nothing but the pre-existing
// wait for the scheduled heartbeat.
if last, ok := d.pendingWorkLastRun[runtimeID]; ok && time.Since(last) < pendingWorkHintMinInterval {
d.pendingWorkMu.Unlock()
d.logger.Debug("pending work hint throttled", "runtime_id", runtimeID, "kind", kind)
return
}
d.pendingWorkInflight[runtimeID] = struct{}{}
d.pendingWorkLastRun[runtimeID] = time.Now()
d.pendingWorkMu.Unlock()
defer func() {
d.pendingWorkMu.Lock()
delete(d.pendingWorkInflight, runtimeID)
d.pendingWorkMu.Unlock()
}()
// Root context: handleHeartbeatActions hands this ctx to the actual work, so
// it must outlive this function. Capability discovery and local-skill imports
// are normally quick filesystem operations, while model discovery may shell
// out to a CLI for up to ~40s on the slowest provider (see
// agent.hermesDiscoveryTimeout). Only the heartbeat request itself is
// time-bounded.
ctx := d.recoveryContext()
if ctx.Err() != nil {
return
}
hbCtx, cancel := context.WithTimeout(ctx, pendingWorkHeartbeatTimeout)
resp, err := d.client.SendHeartbeat(hbCtx, runtimeID)
cancel()
if err != nil {
if isRuntimeNotFoundError(err) {
go d.handleRuntimeGone(runtimeID)
return
}
d.logger.Debug("pending work hint heartbeat failed", "runtime_id", runtimeID, "kind", kind, "error", err)
return
}
if resp == nil {
return
}
if resp.RuntimeGone {
go d.handleRuntimeGone(runtimeID)
return
}
d.logger.Debug("pending work hint served", "runtime_id", runtimeID, "kind", kind)
d.handleHeartbeatActions(ctx, runtimeID, resp)
}
// handleModelList resolves the provider's supported models (via static
// catalog or by shelling out to the agent CLI) and reports the result
// back to the server.
//
// How a discovery failure is reported is the provider's decision, not this
// function's. Providers with a safe static catalog swallow the error and return
// the stand-in (marked Fallback); providers without one — hermes — return the
// error, and it is forwarded as status=failed so the picker can show the reason
// and keep manual entry (MUL-6606). Both outcomes leave the creatable dropdown
// usable; only the second one tells the user why it is empty.
func (d *Daemon) handleModelList(ctx context.Context, rt Runtime, requestID string) {
d.logger.Info("model list requested", "runtime_id", rt.ID, "request_id", requestID, "provider", rt.Provider)
// Discovery must enumerate the binary this runtime will actually execute,
// otherwise the picker advertises a catalog the launched CLI never agreed
// to (MUL-5789). Mirror runTask's resolution order: a custom runtime
// profile (MUL-3284) owns the executable path, and such a runtime can live
// on a host with NO built-in agent of the same protocol family installed —
// so a custom runtime must never fail on the built-in lookup. A custom
// path is also never re-resolved: like runTask, we don't second-guess a
// path the profile pinned.
var execPath string
// fixedArgs mirrors the launch prefix runTask would use. Discovery has to
// enumerate the CLI the profile actually runs, so a subcommand wrapper is
// probed as `ccms start q36 models`, not `ccms models` (GH #7046).
var fixedArgs []string
if customSpec, isCustom := d.customProfileLaunchForRuntime(rt.ID); isCustom {
execPath = customSpec.path
fixedArgs = agent.FilterLaunchPrefix(rt.Provider, customSpec.fixedArgs, d.logger)
d.logger.Info("model list uses custom runtime profile command",
"runtime_id", rt.ID, "provider", rt.Provider, "command_path", execPath,
"fixed_args", len(fixedArgs))
} else if entry, ok := d.agents()[rt.Provider]; ok {
// Built-in provider: self-heal a pinned executable path an in-place
// upgrade deleted (MUL-4486).
entry, _ = d.resolveAgentEntry(ctx, rt.Provider, entry)
execPath = entry.Path
} else {
d.reportModelListResult(ctx, rt, requestID, map[string]any{
"status": "failed",
"error": fmt.Sprintf("no agent configured for provider %q", rt.Provider),
})
return
}
catalog, err := listModels(ctx, rt.Provider, agent.NewCommand(execPath, fixedArgs))
if err != nil {
d.reportModelListResult(ctx, rt, requestID, map[string]any{
"status": "failed",
"error": err.Error(),
})
return
}
models := catalog.Models
if catalog.Fallback {
d.logger.Warn("model discovery fell back to a static catalog; reporting it as non-authoritative",
"runtime_id", rt.ID, "provider", rt.Provider, "path", execPath, "count", len(models))
}
if rt.Provider == "codearts" && len(models) == 0 {
if home, homeErr := os.UserHomeDir(); homeErr == nil {
configuredModels, configErr := loadCodeArtsConfiguredModels(home)
if configErr != nil {
d.logger.Warn("CodeArts custom model discovery failed",
"runtime_id", rt.ID, "path", codeArtsUserConfigPath(home), "error", configErr)
} else if len(configuredModels) > 0 {
models = configuredModels
d.logger.Info("CodeArts model discovery used user-configured providers",
"runtime_id", rt.ID, "path", codeArtsUserConfigPath(home), "count", len(models))
}
}
}
// Wire format matches handler.ModelEntry. Use a struct (not
// map[string]string) so the Default bool and the per-model
// Thinking catalog round-trip — without it the UI loses its
// "default" badge on the advertised pick and the thinking-level
// picker for claude/codex (MUL-2339).
type thinkingLevelWire struct {
Value string `json:"value"`
Label string `json:"label"`
Description string `json:"description,omitempty"`
}
type modelThinkingWire struct {
SupportedLevels []thinkingLevelWire `json:"supported_levels"`
DefaultLevel string `json:"default_level,omitempty"`
}
type modelServiceTierWire struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
}
type modelWire struct {
ID string `json:"id"`
Label string `json:"label"`
Provider string `json:"provider,omitempty"`
Default bool `json:"default,omitempty"`
Thinking *modelThinkingWire `json:"thinking,omitempty"`
ServiceTiers []modelServiceTierWire `json:"service_tiers,omitempty"`
SupportsExplicitStandardServiceTier bool `json:"supports_explicit_standard_service_tier,omitempty"`
}
// Models the runtime named but will not run here (Claude Code reporting one
// that needs a newer CLI). Reported in their own list, never inside
// `models`: a client that does not know this field — an installed desktop
// build predating it — then cannot offer one as a real model, which a flag
// on the model itself could not guarantee (MUL-6961).
type unavailableModelWire struct {
ID string `json:"id"`
Label string `json:"label"`
Reason string `json:"reason,omitempty"`
}
wire := make([]modelWire, 0, len(models))
for _, m := range models {
entry := modelWire{
ID: m.ID,
Label: m.Label,
Provider: m.Provider,
Default: m.Default,
SupportsExplicitStandardServiceTier: m.SupportsExplicitStandardServiceTier,
}
if m.Thinking != nil {
levels := make([]thinkingLevelWire, 0, len(m.Thinking.SupportedLevels))
for _, lvl := range m.Thinking.SupportedLevels {
levels = append(levels, thinkingLevelWire{
Value: lvl.Value,
Label: lvl.Label,
Description: lvl.Description,
})
}
entry.Thinking = &modelThinkingWire{
SupportedLevels: levels,
DefaultLevel: m.Thinking.DefaultLevel,
}
}
for _, tier := range m.ServiceTiers {
entry.ServiceTiers = append(entry.ServiceTiers, modelServiceTierWire{
ID: tier.ID,
Name: tier.Name,
Description: tier.Description,
})
}
wire = append(wire, entry)
}
unavailableWire := make([]unavailableModelWire, 0, len(catalog.Unavailable))
for _, m := range catalog.Unavailable {
unavailableWire = append(unavailableWire, unavailableModelWire{
ID: m.ID,
Label: m.Label,
Reason: m.Reason,
})
}
d.reportModelListResult(ctx, rt, requestID, map[string]any{
"status": "completed",
"models": wire,
"supported": agent.ModelSelectionSupported(rt.Provider),
// Additive: an older server drops the key and the picker simply shows
// no unavailable section, which is the pre-MUL-6961 behaviour.
"unavailable_models": unavailableWire,
// Additive field: the models are still worth rendering, but the server
// must not persist them as this runtime's real catalog (MUL-5549).
// Older servers ignore it and keep the previous behaviour.
"fallback": catalog.Fallback,
})
}
func (d *Daemon) handleLocalSkillList(ctx context.Context, rt Runtime, requestID string) {
d.logger.Info("runtime local skills requested", "runtime_id", rt.ID, "request_id", requestID, "provider", rt.Provider)
skills, supported, err := listRuntimeLocalSkills(rt.Provider)
if err != nil {
d.reportLocalSkillListResult(ctx, rt, requestID, map[string]any{
"status": "failed",
"error": err.Error(),
})
return
}
mcpServers, mcpSupported, err := listRuntimeLocalMcpServers(rt.Provider)
if err != nil {
d.logger.Warn("runtime local MCP discovery failed", "runtime_id", rt.ID, "provider", rt.Provider, "error", err)
mcpServers = []runtimeLocalMcpServerSummary{}
mcpSupported = false
}
d.reportLocalSkillListResult(ctx, rt, requestID, map[string]any{
"status": "completed",
"skills": skills,
"supported": supported,
"mcp_servers": mcpServers,
"mcp_supported": mcpSupported,
})
}
func (d *Daemon) handleLocalSkillImport(ctx context.Context, rt Runtime, pending PendingLocalSkillImport) {
d.logger.Info("runtime local skill import requested", "runtime_id", rt.ID, "request_id", pending.ID, "provider", rt.Provider, "skill_key", pending.SkillKey)
skill, supported, err := loadRuntimeLocalSkillBundle(rt.Provider, pending.SkillKey)
if err != nil {
d.reportLocalSkillImportResult(ctx, rt, pending.ID, map[string]any{
"status": "failed",
"error": err.Error(),
})
return
}
if !supported {
d.reportLocalSkillImportResult(ctx, rt, pending.ID, map[string]any{
"status": "failed",
"error": fmt.Sprintf("provider %q does not expose runtime local skills", rt.Provider),
})
return
}
d.reportLocalSkillImportResult(ctx, rt, pending.ID, map[string]any{
"status": "completed",
"skill": skill,
})
}
// runtimeReportBackoffs defines the retry schedule for delivering any
// daemon→server async result (model list, local-skill list, local-skill
// import). First attempt runs immediately, then we back off. The sum
// (≈6.5s) leaves most of the server-side running timeout (60s) available for
// discovery and report attempts. Each HTTP attempt has its own client timeout,
// so this schedule alone is not an end-to-end delivery deadline.
//
// Overridable for tests to avoid real sleeps.
var runtimeReportBackoffs = []time.Duration{0, 500 * time.Millisecond, 2 * time.Second, 4 * time.Second}
// reportLocalSkillListResult delivers a list-report to the server with retry
// on transient failures. See reportRuntimeResultWithRetry for semantics.
func (d *Daemon) reportLocalSkillListResult(ctx context.Context, rt Runtime, requestID string, payload map[string]any) {
d.reportRuntimeResultWithRetry(ctx, "local_skill_list", rt.ID, requestID, func(ctx context.Context) error {
return d.client.ReportLocalSkillListResult(ctx, rt.ID, requestID, payload)
})
}
// reportLocalSkillImportResult delivers an import-report to the server with
// retry on transient failures.
func (d *Daemon) reportLocalSkillImportResult(ctx context.Context, rt Runtime, requestID string, payload map[string]any) {
d.reportRuntimeResultWithRetry(ctx, "local_skill_import", rt.ID, requestID, func(ctx context.Context) error {
return d.client.ReportLocalSkillImportResult(ctx, rt.ID, requestID, payload)
})
}
// reportModelListResult delivers a model-list report to the server with retry
// on transient failures. Without this the daemon used to fire once and
// swallow any 5xx, leaving the request stranded in "running" on the server
// until its 60s timeout — defeating the multi-node store fix.
func (d *Daemon) reportModelListResult(ctx context.Context, rt Runtime, requestID string, payload map[string]any) {
d.reportRuntimeResultWithRetry(ctx, "model_list", rt.ID, requestID, func(ctx context.Context) error {
return d.client.ReportModelListResult(ctx, rt.ID, requestID, payload)
})
}
// reportRuntimeResultWithRetry retries `fn` on 5xx / network errors and
// stops on success, 4xx, or after exhausting runtimeReportBackoffs.
//
// Why this exists: the server persists the report through a Redis / DB
// write; on a transient store failure it correctly returns 500. Without a
// client-side retry the daemon would fire once, swallow the error, and the
// pending request stays in "running" on the server until its timeout — which
// is exactly the "daemon did not respond" failure mode the multi-node store
// fix was meant to eliminate. 4xx is treated as permanent (request-not-found,
// cross-workspace token rejected, bad body) — retrying those just wastes
// heartbeat cycles.
func (d *Daemon) reportRuntimeResultWithRetry(ctx context.Context, kind, runtimeID, requestID string, fn func(context.Context) error) {
var lastErr error
for attempt, wait := range runtimeReportBackoffs {
if wait > 0 {
select {
case <-ctx.Done():
d.logger.Error("runtime async report cancelled",
"kind", kind, "runtime_id", runtimeID, "request_id", requestID,
"attempt", attempt, "error", ctx.Err())
return
case <-time.After(wait):
}
}
err := fn(ctx)
if err == nil {
if attempt > 0 {
d.logger.Info("runtime async report succeeded after retry",
"kind", kind, "runtime_id", runtimeID, "request_id", requestID,
"attempt", attempt+1)
}
return
}
lastErr = err
// 4xx is permanent (request expired, workspace mismatch, malformed
// body). No amount of retrying will make it succeed.
var reqErr *requestError
if errors.As(err, &reqErr) && reqErr.StatusCode >= 400 && reqErr.StatusCode < 500 {
d.logger.Error("runtime async report rejected — not retrying",
"kind", kind, "runtime_id", runtimeID, "request_id", requestID,
"status", reqErr.StatusCode, "error", err)
return
}
d.logger.Warn("runtime async report failed — will retry",
"kind", kind, "runtime_id", runtimeID, "request_id", requestID,
"attempt", attempt+1, "error", err)
}
d.logger.Error("runtime async report exhausted retries",
"kind", kind, "runtime_id", runtimeID, "request_id", requestID, "error", lastErr)
}
// handleUpdate performs the CLI update when triggered by the server via heartbeat.
func (d *Daemon) handleUpdate(ctx context.Context, runtimeID string, update *PendingUpdate) {
// Desktop-managed daemons share their CLI binary with the Electron app,
// which is responsible for shipping and replacing it. Letting the daemon
// self-update would just get overwritten on the next Desktop launch and
// could brick the embedded binary mid-update. Refuse cleanly.
if d.cfg.LaunchedBy == "desktop" {
d.logger.Info("refusing CLI self-update: daemon is managed by Desktop", "runtime_id", runtimeID, "update_id", update.ID)
d.reportUpdateResult(ctx, runtimeID, update.ID, map[string]any{
"status": "failed",
"error": "CLI is managed by Multica Desktop — update the Desktop app to upgrade the CLI",
})
return
}
switch d.tryBeginServerUpdate(ctx) {
case serverUpdateAlreadyRunning:
d.logger.Warn("update deferred: another update is already in progress", "runtime_id", runtimeID, "update_id", update.ID)
d.reportUpdateResult(ctx, runtimeID, update.ID, map[string]any{
"status": "failed",
"error": "another runtime update is already in progress on this machine",
})
return
case serverUpdateRuntimeBusy:
d.logger.Info("update deferred: task or claim in progress", "runtime_id", runtimeID, "update_id", update.ID)
d.reportUpdateResult(ctx, runtimeID, update.ID, map[string]any{
"status": "failed",
"error": "runtime update deferred because agent work is starting or still active; retry when the machine is idle",
})
return
}
restarting := false
defer func() {
if !restarting {
d.releaseClaimBarrier()
d.updating.Store(false)
}
}()
d.logger.Info("CLI update requested", "runtime_id", runtimeID, "update_id", update.ID, "target_version", update.TargetVersion)
// Report running status.
d.reportUpdateResult(ctx, runtimeID, update.ID, map[string]any{
"status": "running",
})
output, err := d.runUpdateFn(update.TargetVersion)
if err != nil {
d.logger.Error("CLI update failed", "error", err, "output", output)
d.reportUpdateResult(ctx, runtimeID, update.ID, map[string]any{
"status": "failed",
"error": err.Error(),
})
return
}
d.logger.Info("CLI update completed successfully", "output", output)
d.reportUpdateResult(ctx, runtimeID, update.ID, map[string]any{
"status": "completed",
"output": fmt.Sprintf("Updated to %s", update.TargetVersion),
})
// Trigger daemon restart with the new binary.
d.triggerRestart()
restarting = d.RestartBinary() != ""
}
type serverUpdateAcquireResult uint8
const (
serverUpdateAcquired serverUpdateAcquireResult = iota
serverUpdateAlreadyRunning
serverUpdateRuntimeBusy
)
// tryBeginServerUpdate atomically claims update ownership, pauses new claims,
// and lets any claim already in flight finish its handoff. An empty claim can
// therefore never starve an update; a claim that returns work increments
// activeTasks before exitClaim, so the final check still defers safely.
func (d *Daemon) tryBeginServerUpdate(ctx context.Context) serverUpdateAcquireResult {
if !d.updating.CompareAndSwap(false, true) {
return serverUpdateAlreadyRunning
}
d.claimMu.Lock()
if d.pauseClaims || d.activeTasks.Load() > 0 {
d.claimMu.Unlock()
d.updating.Store(false)
return serverUpdateRuntimeBusy
}
d.pauseClaims = true
d.claimMu.Unlock()
ticker := time.NewTicker(10 * time.Millisecond)
defer ticker.Stop()
for {
d.claimMu.Lock()
if d.claimsInFlight == 0 {
if d.activeTasks.Load() == 0 {
d.claimMu.Unlock()
return serverUpdateAcquired
}
d.pauseClaims = false
d.claimMu.Unlock()
d.updating.Store(false)
return serverUpdateRuntimeBusy
}
d.claimMu.Unlock()
select {
case <-ctx.Done():
d.releaseClaimBarrier()
d.updating.Store(false)
return serverUpdateRuntimeBusy
case <-ticker.C:
}
}
}
// runUpdate executes the brew-or-download upgrade against targetVersion and
// returns the human-readable output (always populated, even on failure when
// brew gives us a useful diagnostic). The caller is responsible for the
// `updating` CAS guard and for reporting status back to the server / triggering
// the restart — extracted so the server-triggered path (handleUpdate) and the
// auto-update poller (autoUpdateLoop) share the exact same execution body.
func (d *Daemon) runUpdate(targetVersion string) (string, error) {
if cli.IsBrewInstall() {
d.logger.Info("updating CLI via Homebrew...")
out, err := cli.UpdateViaBrew()
if err != nil {
return out, fmt.Errorf("brew upgrade failed: %w", err)
}
return out, nil
}
d.logger.Info("updating CLI via direct download...", "target_version", targetVersion)
out, err := cli.UpdateViaDownload(targetVersion)
if err != nil {
return out, fmt.Errorf("download update failed: %w", err)
}
return out, nil
}
// updateReportBackoffs defines the retry schedule for delivering CLI update
// status back to the server. This mirrors localSkillReportBackoffs because
// both features have the same user-visible failure mode: the daemon completed
// work locally, but a transient report failure leaves the UI waiting until the
// server-side request times out.
//
// Overridable for tests to avoid real sleeps.
var updateReportBackoffs = []time.Duration{0, 500 * time.Millisecond, 2 * time.Second, 4 * time.Second}
func (d *Daemon) reportUpdateResult(ctx context.Context, runtimeID, updateID string, payload map[string]any) {
d.reportUpdateResultWithRetry(ctx, runtimeID, updateID, func(ctx context.Context) error {
return d.client.ReportUpdateResult(ctx, runtimeID, updateID, payload)
})
}
func (d *Daemon) reportUpdateResultWithRetry(ctx context.Context, runtimeID, updateID string, fn func(context.Context) error) {
var lastErr error
for attempt, wait := range updateReportBackoffs {
if wait > 0 {
select {
case <-ctx.Done():
d.logger.Error("CLI update report cancelled",
"runtime_id", runtimeID, "update_id", updateID,
"attempt", attempt, "error", ctx.Err())
return
case <-time.After(wait):
}
}
err := fn(ctx)
if err == nil {
if attempt > 0 {
d.logger.Info("CLI update report succeeded after retry",
"runtime_id", runtimeID, "update_id", updateID,
"attempt", attempt+1)
}
return
}
lastErr = err
var reqErr *requestError
if errors.As(err, &reqErr) && reqErr.StatusCode >= 400 && reqErr.StatusCode < 500 {
d.logger.Error("CLI update report rejected — not retrying",
"runtime_id", runtimeID, "update_id", updateID,
"status", reqErr.StatusCode, "error", err)
return
}
d.logger.Warn("CLI update report failed — will retry",
"runtime_id", runtimeID, "update_id", updateID,
"attempt", attempt+1, "error", err)
}
d.logger.Error("CLI update report exhausted retries",
"runtime_id", runtimeID, "update_id", updateID, "error", lastErr)
}
// tryEnterClaim records the intent to call ClaimTask. Returns true if the
// caller may proceed, false if the auto-update barrier is in effect. Every
// successful call MUST be paired with an exitClaim() on every exit path —
// either right after a failed/empty claim, or via the handleTask goroutine's
// defer once the task is handed off.
func (d *Daemon) tryEnterClaim() bool {
d.claimMu.Lock()
defer d.claimMu.Unlock()
if d.pauseClaims {
return false
}
d.claimsInFlight++
return true
}
// exitClaim releases the in-flight claim recorded by tryEnterClaim.
func (d *Daemon) exitClaim() {
d.claimMu.Lock()
defer d.claimMu.Unlock()
d.claimsInFlight--
}
// trySetClaimBarrier atomically pauses new ClaimTask calls if the daemon is
// fully idle (no claims in flight, no tasks running). Returns true if the
// caller now holds the barrier and must release it with releaseClaimBarrier
// on every non-restart exit path; false if the daemon is busy and the caller
// should defer to the next tick. Used by tryAutoUpdate to close the race
// where a task slips in between the cheap pre-fetch idle check and the
// actual upgrade kick-off.
func (d *Daemon) trySetClaimBarrier() bool {
d.claimMu.Lock()
defer d.claimMu.Unlock()
// Refuse when the barrier is already held. Without this the function silently
// double-acquires: two holders both believe they own it, and whichever
// finishes first releases it out from under the other. tryBeginServerUpdate
// makes the same check for the same reason.
if d.pauseClaims || d.claimsInFlight > 0 || d.activeTasks.Load() > 0 {
return false
}
d.pauseClaims = true
return true
}
// releaseClaimBarrier clears the auto-update claim barrier so pollers may
// resume claiming. Called on failure paths only — a successful upgrade leaves
// the barrier set because triggerRestart is about to take the process down
// and clearing it would open a window for new claims during shutdown.
func (d *Daemon) releaseClaimBarrier() {
d.claimMu.Lock()
defer d.claimMu.Unlock()
d.pauseClaims = false
}
// triggerRestart initiates a graceful daemon restart into the binary at
// restartTargetBinary(). The caller (cmd_daemon.go) checks RestartBinary() and
// launches the new process.
//
// Returns false when the target path could not be resolved, so a caller holding
// the claim barrier can release it instead of leaving the daemon paused for a
// restart that will never happen. A restart already scheduled counts as success:
// the process is going down either way.
func (d *Daemon) triggerRestart() bool {
d.restartMu.Lock()
defer d.restartMu.Unlock()
if d.restartBinary != "" {
d.logger.Debug("daemon restart already scheduled", "new_binary", d.restartBinary)
return true
}
newBin, err := d.restartTargetBinary()
if err != nil {
d.logger.Error("could not resolve executable path for restart", "error", err)
return false
}
d.logger.Info("scheduling daemon restart", "new_binary", newBin)
d.restartBinary = newBin
// Cancel the main context to trigger graceful shutdown.
if d.cancelFunc != nil {
d.cancelFunc()
}
return true
}
// restartTargetBinary resolves the path a restart would re-exec.
//
// For brew installs it keeps the stable symlink path (e.g.
// /opt/homebrew/bin/multica) so the restarted daemon picks up the new Cellar
// version automatically: on Linux os.Executable() reads /proc/self/exe, which
// the kernel resolves to the Cellar path, and brew cleanup deletes that path
// after an upgrade. For non-brew installs it resolves to the absolute path of
// the replaced binary.
//
// The mapping itself is cli.StableExecutablePath, shared with boot-autostart
// registration: a restart target and a recorded ExecStart must never disagree
// about which path survives brew cleanup, or the systemd handoff would
// restart a binary `brew upgrade` already deleted.
//
// Shared with trySelfReload, which must version-probe the same binary the
// restart would run — probing os.Executable() directly would read the old
// Cellar path under brew and miss the upgrade entirely.
func (d *Daemon) restartTargetBinary() (string, error) {
newBin, err := resolveSelfExecutable()
if err != nil {
return "", err
}
// The install method and brew prefix are fixed for the process lifetime;
// resolve them once so the per-tick reload probe doesn't fork
// `brew --prefix` every 5 minutes. Detection order matches
// cli.StableSelfExecutable — the path's own Cellar shape first, then
// `brew --prefix` — so the restart target and a recorded autostart
// ExecStart always agree.
d.brewTargetOnce.Do(func() {
d.brewInstall = isBrewInstall()
if !d.brewInstall {
return
}
if prefix := matchKnownBrewPrefix(newBin); prefix != "" {
d.brewPrefix = prefix
} else {
d.brewPrefix = getBrewPrefix()
}
})
if d.brewInstall && d.brewPrefix == "" {
d.logger.Warn("brew install detected but prefix could not be resolved; restart may fail",
"executable", newBin)
}
return cli.StableExecutablePath(newBin, d.brewPrefix, d.brewInstall), nil
}
// pollLoop runs the machine-level batch claim poller (MUL-4257): a single
// goroutine claims across ALL of the daemon's runtimes per cycle via
// ClaimTasksWSFirst (WS-first, HTTP fallback), replacing the previous
// one-HTTP-poller-per-runtime model. Wake-up signals — a WS task_available /
// catch-up nudge or a runtime-set change — all collapse to one nudge because a
// batch claim already covers every runtime. On shutdown it stops the poller,
// then drains in-flight tasks.
//
// This trades the per-runtime isolation the old model gave (MUL-1744) for a
// single request; the head-of-line risk is bounded by ClaimTasksWSFirst's short
// per-request timeout (WS) / the client's timeout (HTTP fallback), and the
// server-side batch claim is index-backed + short.
func (d *Daemon) pollLoop(ctx context.Context, taskWakeups <-chan taskWakeup) error {
sem := newTaskSlotSemaphore(d.cfg.MaxConcurrentTasks)
var taskWG sync.WaitGroup // tracks in-flight handleTask goroutines
runtimeSetCh, unsub := d.runtimeSet.Subscribe()
defer unsub()
wakeup := make(chan struct{}, 1)
nudge := func() {
signalPollerWakeup(wakeup)
}
pollerCtx, pollerCancel := context.WithCancel(ctx)
pollerDone := make(chan struct{})
go func() {
defer close(pollerDone)
d.runBatchPoller(pollerCtx, ctx, sem, wakeup, &taskWG)
}()
for {
select {
case <-ctx.Done():
d.logger.Info("poll loop stopping, waiting for in-flight tasks", "max_wait", "30s")
pollerCancel()
// Wait for the poller to fully return before waiting on taskWG so a
// poller between ClaimTasksWSFirst and taskWG.Add(1) cannot race
// taskWG.Wait at a zero counter.
<-pollerDone
waitDone := make(chan struct{})
go func() { taskWG.Wait(); close(waitDone) }()
select {
case <-waitDone:
case <-time.After(30 * time.Second):
d.logger.Warn("timed out waiting for in-flight tasks")
}
return ctx.Err()
case <-runtimeSetCh:
// The batch poller re-derives allRuntimeIDs() each cycle; nudge it to
// pick up a registered/removed runtime promptly.
nudge()
case <-taskWakeups:
// Targeted-runtime and catch-up wakeups both trigger one batch claim
// across the whole runtime set.
nudge()
}
}
}
// runBatchPoller is the single machine-level claim+dispatch loop. Each cycle it
// acquires whatever execution slots are free (slot-before-claim, so a claimed
// task never sits server-side `dispatched` without local capacity to run it and
// race the dispatch-timeout sweeper), asks the server for up to that many tasks
// across all of the daemon's runtimes in one call, and dispatches each returned
// task — routed to its runtime by handleTask — into a slot.
//
// pollerCtx is cancelled on shutdown. parentCtx is the daemon root ctx passed to
// handleTask so an in-flight task is not killed just because the poll loop is
// stopping mid-flight.
func (d *Daemon) runBatchPoller(pollerCtx, parentCtx context.Context, sem chan int, wakeup chan struct{}, taskWG *sync.WaitGroup) {
releaseSlots := func(slots []int) {
for _, sl := range slots {
sem <- sl
}
}
for {
if pollerCtx.Err() != nil {
return
}
runtimeIDs := d.allRuntimeIDs()
if len(runtimeIDs) == 0 {
if err := sleepWithContextOrWakeup(pollerCtx, d.cfg.PollInterval, wakeup); err != nil {
return
}
continue
}
// Acquire at least one slot (blocking briefly), then grab any other free
// slots so a single batch claim can fill them all.
slotWait := d.taskSlotWait
if slotWait <= 0 {
slotWait = taskSlotWaitTimeout
}
slot, acquired, woke, err := waitForTaskSlot(pollerCtx, sem, wakeup, slotWait)
if err != nil {
return
}
if !acquired {
if woke {
continue
}
if err := sleepWithContextOrWakeup(pollerCtx, capacityBackoff(d.cfg.PollInterval), wakeup); err != nil {
return
}
continue
}
slots := append([]int{slot}, drainAvailableSlots(sem, d.cfg.MaxConcurrentTasks-1)...)
// Auto-update barrier: refuse to claim while an update prepares to roll
// the process (paired with the re-check in tryAutoUpdate).
if !d.tryEnterClaim() {
releaseSlots(slots)
if err := sleepWithContextOrWakeup(pollerCtx, d.cfg.PollInterval, wakeup); err != nil {
return
}
continue
}
claimResult, err := d.claimTasksWSFirst(pollerCtx, d.cfg.DaemonID, runtimeIDs, len(slots))
if err != nil {
d.exitClaim()
releaseSlots(slots)
if pollerCtx.Err() == nil {
d.logger.Warn("batch claim failed", "error", err)
}
if err := sleepWithContextOrWakeup(pollerCtx, d.cfg.PollInterval, wakeup); err != nil {
return
}
continue
}
tasks := claimResult.Tasks
// Dispatch each claimed task into a slot. activeTasks is incremented for
// every dispatched task BEFORE exitClaim so the auto-update barrier never
// sees a zero-claims / zero-active window between claim and dispatch.
dispatched := 0
for i := range tasks {
if i >= len(slots) || tasks[i] == nil {
break
}
t := *tasks[i]
slot := slots[i]
taskTarget := t.IssueID
if taskTarget == "" && t.ChatSessionID != "" {
taskTarget = "chat:" + t.ChatSessionID
}
d.logger.Info("task received", "task", t.ID, "target", taskTarget)
taskWG.Add(1)
d.activeTasks.Add(1)
if cache, ok := d.repoCache.(interface{ CancelMaintenance() }); ok {
// A task can reuse an existing worktree and never enter the
// checkout path that normally preempts repository maintenance.
// Cancel all low-priority maintenance before the agent starts so
// direct Git operations cannot overlap it.
cache.CancelMaintenance()
}
go func(t Task, slot int) {
defer taskWG.Done()
defer d.activeTasks.Add(-1)
defer func() {
// Release local capacity before waking the poller. The task's
// terminal callback and local cleanup have both finished at this
// point, so a successor that was previously blocked by agent
// capacity or per-(issue, agent) serialization can be claimed
// immediately instead of waiting for PollInterval.
sem <- slot
signalPollerWakeup(wakeup)
}()
d.handleTask(parentCtx, t, slot)
}(t, slot)
dispatched++
}
d.exitClaim()
if dispatched < len(slots) {
releaseSlots(slots[dispatched:])
}
// If we filled every slot, more work may be queued — loop immediately.
// Otherwise wait for the next wakeup / poll interval. A connection that
// negotiated WS RPC receives task-available pushes, so this poll is only
// a missed-event safety net and can run less often. Claim errors retain
// the configured fallback cadence above.
if dispatched > 0 && dispatched == len(slots) {
continue
}
if err := sleepWithContextOrWakeup(pollerCtx, d.taskClaimPollInterval(claimResult), wakeup); err != nil {
return
}
}
}
// taskClaimPollInterval returns the next missed-event safety poll. The longer
// cadence is only safe when this exact claim response came from a server that
// opted into scheduling hints; a missing hint covers old servers and uncertain
// WS claims, both of which retain the normal fallback cadence. Downward-only
// jitter keeps the default below the server's 3-minute empty-claim cache TTL
// while preventing an idle fleet from polling in lockstep.
func (d *Daemon) taskClaimPollInterval(result claimTasksResult) time.Duration {
if !d.wsRPC.supportsRPCV1() || !result.ClaimedOverWS || !result.ClaimPollHintSupported {
if d.cfg.PollInterval > 0 {
return d.cfg.PollInterval
}
return DefaultPollInterval
}
upperBound := d.cfg.WSClaimPollInterval
if upperBound <= 0 {
upperBound = DefaultWSClaimPollInterval
}
interval := downwardJitterDuration(upperBound)
if result.NextDeferredTaskAfterMillis > 0 {
untilDeferred := time.Duration(result.NextDeferredTaskAfterMillis) * time.Millisecond
if untilDeferred < interval {
interval = untilDeferred
}
}
return interval
}
func downwardJitterDuration(interval time.Duration) time.Duration {
minReduction := interval / 12
maxReduction := interval / 6
if minReduction <= 0 || maxReduction <= minReduction {
return interval
}
reduction := minReduction + time.Duration(rand.Int63n(int64(maxReduction-minReduction)+1))
return interval - reduction
}
func signalPollerWakeup(wakeup chan<- struct{}) {
select {
case wakeup <- struct{}{}:
default:
}
}
// drainAvailableSlots non-blockingly pulls up to max additional slots from the
// semaphore, returning immediately when none are free.
func drainAvailableSlots(sem chan int, max int) []int {
if max <= 0 {
return nil
}
var slots []int
for len(slots) < max {
select {
case s := <-sem:
slots = append(slots, s)
default:
return slots
}
}
return slots
}
func capacityBackoff(pollInterval time.Duration) time.Duration {
if pollInterval <= 0 || pollInterval > taskSlotCapacityBackoff {
return taskSlotCapacityBackoff
}
return pollInterval
}
func waitForTaskSlot(ctx context.Context, sem chan int, wakeup <-chan struct{}, wait time.Duration) (slot int, acquired, woke bool, err error) {
select {
case slot = <-sem:
return slot, true, false, nil
case <-ctx.Done():
return 0, false, false, ctx.Err()
default:
}
if wait <= 0 {
return 0, false, false, nil
}
timer := time.NewTimer(wait)
defer timer.Stop()
select {
case slot = <-sem:
return slot, true, false, nil
case <-wakeup:
return 0, false, true, nil
case <-ctx.Done():
return 0, false, false, ctx.Err()
case <-timer.C:
return 0, false, false, nil
}
}
// newTaskSlotSemaphore returns a buffered channel pre-populated with stable
// slot indices [0, n). Receive to acquire a slot, send the same slot back to
// release. Used by pollLoop to expose MULTICA_TASK_SLOT to spawned tasks.
func newTaskSlotSemaphore(maxConcurrentTasks int) chan int {
sem := make(chan int, maxConcurrentTasks)
for i := 0; i < maxConcurrentTasks; i++ {
sem <- i
}
return sem
}
// shouldInterruptAgent decides whether the running agent should be cancelled
// based on the latest GetTaskStatus call. Pure function so the decision is
// trivially testable; the polling goroutine in watchTaskCancellation is just
// I/O around it.
//
// Two conditions trigger cancellation:
//
// 1. status is a terminal state — "completed", "failed", or "cancelled"
// (isAgentTaskTerminal). The server has already finalized the task: user
// cancel, issue reassignment, the runtime offline sweeper flipping
// running → failed during a disconnect, or a duplicate execution that
// already completed it. Letting the local agent run on is pure waste —
// CompleteAgentTask only accepts status == "running", so its eventual
// CompleteTask/FailTask callback is guaranteed to fail and just adds log
// noise. Reusing isAgentTaskTerminal keeps this set in lockstep with the
// GC's notion of a terminal task.
// 2. err is a 404 with "task not found" — the task row was deleted while
// the agent was running. Without this we'd let the local agent keep
// emitting tool calls against a dead task for its full timeout window.
//
// All other errors (transient network, 5xx, ...) intentionally do NOT
// trigger cancellation — the next tick will retry and we don't want a
// flaky link to kill an in-flight agent.
func shouldInterruptAgent(status string, err error) bool {
if err != nil {
return isTaskNotFoundError(err)
}
return isAgentTaskTerminal(status)
}
// watchTaskCancellation polls the server for the task's status on the given
// interval and returns a channel that is closed when the running agent
// should be interrupted. The polling goroutine stops when ctx is cancelled,
// so callers should pass the runCtx that was set up around the agent run.
func (d *Daemon) watchTaskCancellation(ctx context.Context, taskID string, pollInterval time.Duration, taskLog *slog.Logger) <-chan struct{} {
cancelled := make(chan struct{})
// Subscribe to the reconcile broadcaster before launching the inner
// goroutine. A WS reconnect that fires between the goroutine starting
// and its first notify() call would otherwise be dropped; the ticker
// still bounds the worst case, but the whole point of the broadcast is
// to avoid waiting on that ticker.
var reconcileCh <-chan struct{}
if d.reconcile != nil {
reconcileCh = d.reconcile.notify()
}
go func() {
ticker := time.NewTicker(pollInterval)
defer ticker.Stop()
check := func() bool {
status, err := d.client.GetTaskStatus(ctx, taskID)
if !shouldInterruptAgent(status, err) {
return false
}
if err != nil {
taskLog.Info("task gone server-side, interrupting agent", "error", err)
} else {
taskLog.Info("task reached terminal state server-side, interrupting agent", "status", status)
}
close(cancelled)
return true
}
for {
select {
case <-ctx.Done():
return
case <-reconcileCh:
// Refresh the subscription before issuing the request so a
// second broadcast that overlaps GetTaskStatus is not lost.
if d.reconcile != nil {
reconcileCh = d.reconcile.notify()
}
if check() {
return
}
case <-ticker.C:
if check() {
return
}
}
}
}()
return cancelled
}
func (d *Daemon) handleTask(ctx context.Context, task Task, slot int) {
d.mu.Lock()
rt, tracked := d.runtimeIndex[task.RuntimeID]
d.mu.Unlock()
// The runtime can go offline between the batch claim leaving with its ID and
// the claimed task arriving here — a below-minimum demotion or a drift
// convergence to zero both drop rows while a claim is in flight. Reporting it
// as runtime_offline is what the server already retries on; without this the
// zero-value Runtime carries an empty provider and the task dies several
// hundred lines later as `no agent configured for provider ""`, which reads
// like a misconfigured host and is not retried.
if !tracked {
d.logger.Warn("claimed task targets a runtime this daemon no longer hosts; failing it back for retry",
"task", task.ID, "runtime_id", task.RuntimeID)
if err := d.reportTerminalTask(ctx, terminalTaskReport{
kind: terminalTaskReportFail,
taskID: task.ID,
errorMessage: "runtime went offline before the run started",
failureReason: taskfailure.ReasonRuntimeOffline.String(),
}); err != nil {
d.logger.Error("fail task callback failed", "task", task.ID, "error", err)
}
return
}
provider := rt.Provider
// Task-scoped logger. The task id goes in whole: it is the key every
// other surface prints (task JSON, env-root ownership manifest, server
// logs), so a full id is what lets a log line be joined to them. An
// 8-char prefix cannot — task ids are UUIDv7, whose leading 32 bits are
// a millisecond timestamp that only advances every ~65s, so concurrent
// tasks routinely share one (#7326).
taskLog := d.logger.With("task", task.ID)
phaseRecorder := newTaskPhaseRecorder(taskLog.With("task_id", task.ID, "runtime_id", task.RuntimeID), time.Now)
ctx = withTaskPhaseRecorder(ctx, phaseRecorder)
phaseRecorder.Mark(taskPhaseClaimed)
defer phaseRecorder.Mark(taskPhaseFinished)
agentName := "agent"
if task.Agent != nil {
agentName = task.Agent.Name
}
if task.ChatSessionID != "" {
taskLog.Info("picked chat task", "chat_session", task.ChatSessionID, "agent", agentName, "provider", provider)
} else {
taskLog.Info("picked task", "issue", task.IssueID, "agent", agentName, "provider", provider)
}
taskLog.Debug("task context",
"workspace_id", task.WorkspaceID,
"runtime_id", task.RuntimeID,
"agent_id", task.AgentID,
"repos", len(task.Repos),
"project_id", task.ProjectID,
"autopilot_run_id", task.AutopilotRunID,
"trigger_comment_id", task.TriggerCommentID,
"resume_session", task.PriorSessionID != "",
"reuse_workdir", task.PriorWorkDir != "",
)
// If the task targets a project_resource of type local_directory that
// is pinned to this daemon, acquire the path mutex before runner.run
// so the server-side state machine is dispatched →
// waiting_local_directory → running rather than backwards-transitioning
// from running into the wait state. The release is deferred so a panic
// or early return always frees the lock for the next waiter.
//
// StartTask itself now lives in runTask (see issue #3999 race A) and
// fires only after execenv.Prepare/Reuse has put env.WorkDir on disk,
// so consumers that read status==running can resolve the workdir path
// without racing the daemon's os.MkdirAll.
localRelease, abort := d.acquireLocalDirectoryLockIfNeeded(ctx, task, taskLog)
if abort {
return
}
if localRelease != nil {
defer localRelease()
}
// Hold a process-wide active-root guard for the rest of this task so
// the GC loop never sees a window where the env root has neither the
// in-process guard nor .gc_meta.json (issue #3999 race B). runTask
// installs its own ref-counted mark/unmark internally; without this
// outer guard the inner unmark fires when runTask returns, leaving
// the directory protected only by the 72h orphan TTL through
// reportTaskResult and execenv.WriteGCMeta below. markActiveEnvRoot
// is reference-counted, so the duplicate marks runTask installs are
// correctly nested within these.
resolvedEnvRoot, resolveRootErr := execenv.ResolveRootDir(taskRootDirParams(d.cfg.WorkspacesRoot, task))
if resolveRootErr != nil {
taskLog.Error("resolve stable task env root", "error", resolveRootErr)
}
if resolvedEnvRoot != "" {
d.markActiveEnvRoot(resolvedEnvRoot)
defer d.unmarkActiveEnvRoot(resolvedEnvRoot)
}
if task.PriorWorkDir != "" {
if priorRoot := filepath.Dir(task.PriorWorkDir); priorRoot != "" && priorRoot != resolvedEnvRoot {
d.markActiveEnvRoot(priorRoot)
defer d.unmarkActiveEnvRoot(priorRoot)
}
}
// Create a cancellable context so we can interrupt the running agent
// when the server signals the task should stop — either the task reached
// a terminal state (completed/failed/cancelled) or the task row is
// deleted (404).
runCtx, runCancel := context.WithCancel(ctx)
defer runCancel()
// Poll interval is d.cancelPollInterval (5s in production, reduced in tests
// via direct field override). Guard against zero so a misconfigured daemon
// doesn't panic time.NewTicker.
pollInterval := d.cancelPollInterval
if pollInterval == 0 {
pollInterval = 5 * time.Second
}
cancelledByPoll := d.watchTaskCancellation(runCtx, task.ID, pollInterval, taskLog)
go func() {
select {
case <-cancelledByPoll:
runCancel()
case <-runCtx.Done():
}
}()
result, err := d.runner.run(runCtx, task, provider, slot, taskLog)
if errors.Is(err, errStartClaimRejected) {
// The row belongs to another claim (or is terminal). A task-id-only
// failure callback from this stale delivery could kill its new owner.
taskLog.Info("discarding rejected start claim", "error", err)
return
}
// Report usage before any early return — the agent accumulates tokens
// whether the task completes, errors, or is cancelled mid-run by the poll
// goroutine. Both claude.go and codex.go populate result.Usage even when
// runCtx is cancelled, so dropping this on the cancelled path silently
// under-reports billing.
if len(result.Usage) > 0 {
if usageErr := d.client.ReportTaskUsage(ctx, task.ID, result.Usage); usageErr != nil {
taskLog.Warn("report task usage failed", "error", usageErr)
}
}
// Check if we were cancelled by the polling goroutine.
select {
case <-cancelledByPoll:
taskLog.Info("task cancelled during execution, discarding result",
"branch_name", result.BranchName, "error", err)
// runner.run has returned, so the transcript flush is complete —
// tell the server it can settle its deferred chat finalization
// (#5219). The sweeper grace period covers a lost ack's chat settle,
// but NOT the payload: the branch rides along because the worktree was
// already finalized before this check, and when Finalize instead
// ABORTED, the preserved-worktree error is the only pointer left to
// the agent's work — everything else on this path is discarded. Other
// run errors stay discarded: on a cancelled run they are expected
// noise (context canceled, killed process), and persisting them would
// stamp a bogus reason on every ordinary mid-run cancel.
ack := TaskCancelAck{BranchName: result.BranchName, DurableWorkDir: result.DurableWorkDir}
var preserved *worktreePreservedError
if errors.As(err, &preserved) {
ack.ErrorMessage = preserved.Error()
ack.FailureReason = "local_directory_error"
}
if ackErr := d.client.AckTaskCancelled(ctx, task.ID, ack); ackErr != nil {
taskLog.Warn("cancel ack failed; server sweeper will finalize", "error", ackErr)
}
return
default:
}
if err != nil {
taskLog.Error("task failed", "error", err)
// runTask may have reached worktree finalization before returning the
// error. Preserve any delivery metadata that defer attached to the named
// result, especially the actual/preserved workdir and delivered branch.
// MUL-2946: route the bare error string through the canonical
// classifier so the failure_reason column reflects the actual
// shape of the failure (provider 5xx, network, process crash,
// …) rather than the coarse legacy "agent_error" bucket.
if failErr := d.reportTerminalTask(ctx, terminalTaskReport{
kind: terminalTaskReportFail,
taskID: task.ID,
errorMessage: err.Error(),
branchName: result.BranchName,
workDir: result.WorkDir,
durableWorkDir: result.DurableWorkDir,
failureReason: taskRunFailureReason(err),
}); failErr != nil {
taskLog.Error("fail task callback failed", "error", failErr)
}
return
}
_ = d.client.ReportProgress(ctx, task.ID, "Finishing task", 2, 2)
// Final pre-completion check: if the server already moved the task to a
// terminal state (completed/failed/cancelled) or deleted the row
// outright, skip reporting — the complete/fail callbacks would fail
// anyway. Reuse shouldInterruptAgent so this guard honors the same
// signals as the in-flight watcher.
if status, err := d.client.GetTaskStatus(ctx, task.ID); shouldInterruptAgent(status, err) {
taskLog.Info("task cancelled during execution, discarding result",
"status", status, "error", err, "branch_name", result.BranchName)
// Same contract as the poll-cancelled path above: the transcript is
// flushed, so let the server settle its deferred chat finalization, and
// carry the finalized branch so cancelled work stays discoverable. No
// error to carry here — this branch is only reached with err == nil.
// This fires for ANY observed terminal status; the server applies the
// payload only to a cancelled row (status CAS), because for
// completed/failed rows the complete/fail callback is the
// authoritative channel and a stale run's late ack must not touch
// them.
if ackErr := d.client.AckTaskCancelled(ctx, task.ID, TaskCancelAck{BranchName: result.BranchName, DurableWorkDir: result.DurableWorkDir}); ackErr != nil {
taskLog.Warn("cancel ack failed; server sweeper will finalize", "error", ackErr)
}
return
}
d.reportTaskResult(ctx, task.ID, result, taskLog)
// Write GC metadata after the task finishes so the periodic GC loop
// can look up the parent record (issue / chat session / autopilot run /
// task itself for quick-create) later. Written last so that a mid-task
// crash leaves the directory as an orphan (cleaned up by GCOrphanTTL).
if result.EnvRoot != "" {
if meta, ok := gcMetaForTask(task); ok {
// A local_directory project_resource matched this daemon
// means the agent ran in the user's own tree. Stamp the
// meta so the GC loop never tries to RemoveAll envRoot's
// sibling workdir (which is the user's path) or the envRoot
// itself (we want output/ and logs/ to linger for forensic
// access).
//
// Worktree mode is excluded: its workdir is a disposable
// worktree INSIDE envRoot, already removed by Finalize, and
// the deliverable lives on as a branch in the user's repo.
// Stamping it would hand every worktree task a permanently
// exempt env root, so the directory would accumulate one env
// root per task forever — the exact cost the exemption was
// meant to trade away for a user's own files.
if assignment, _ := localDirectoryAssignmentForTask(task, d.cfg.DaemonID); assignment != nil && !assignment.UsesWorktree() {
meta.LocalDirectory = true
}
if err := execenv.WriteGCMeta(result.EnvRoot, meta, taskLog); err != nil {
taskLog.Warn("write gc meta failed (non-fatal)", "error", err)
}
}
}
}
// worktreePreservedError marks a task error that must survive the cancel path:
// its message names the preserved worktree holding the agent's uncommitted
// work. Every other error on a cancelled run is expected noise (context
// canceled, killed process) and stays discarded; this one is the only pointer
// to real work and rides the cancel ack to the server.
type worktreePreservedError struct{ err error }
func (e *worktreePreservedError) Error() string { return e.err.Error() }
func (e *worktreePreservedError) Unwrap() error { return e.err }
// environmentSetupError marks a failure to build or re-open the task's
// execution environment: everything execenv.Prepare / execenv.Reuse does on
// this host before the agent process exists — the workspace directory and its
// overlay homes, plus the per-provider local config written or validated
// inside it. Its causes are local, and not only filesystem ones: a full
// volume, a read-only or permission-denied workspaces root, a directory
// another process still holds open, an I/O error on the disk underneath, or a
// local runtime config the daemon refuses to boot past.
//
// It carries the wrapped error's message unchanged and exists purely so
// taskRunFailureReason can recognise the phase structurally. Without it the
// OS error text falls through to taskfailure.Classify, a classifier written
// to read agent and provider output, and lands in agent_error.* — today the
// catchall, historically provider_server_error, which pointed one bug report
// at an LLM vendor for hours (#7913). The task never reached an agent, so no
// reason in that namespace can be right.
type environmentSetupError struct{ err error }
func (e *environmentSetupError) Error() string { return e.err.Error() }
func (e *environmentSetupError) Unwrap() error { return e.err }
// asEnvironmentSetupFailure tags err as an execution-environment setup
// failure. The message is untouched, so the wrapper is invisible to everything
// except taskRunFailureReason.
func asEnvironmentSetupFailure(err error) error { return &environmentSetupError{err: err} }
func taskRunFailureReason(err error) string {
if errors.Is(err, errInvalidTaskIdentity) {
return taskfailure.ReasonInvalidTaskIdentity.String()
}
if errors.Is(err, errTaskPrepareTimeout) {
return taskfailure.ReasonTimeout.String()
}
// Checked after the prepare deadline: when the whole prepare budget ran
// out, runTask has already collapsed the error into errTaskPrepareTimeout
// and that classification is the more accurate one. This branch is for the
// per-skill download deadline firing inside a prepare budget that still had
// room (MUL-5370).
if errors.Is(err, errSkillBundleUnavailable) {
return taskfailure.ReasonSkillBundleUnavailable.String()
}
// Structural, not textual: the message ends in "context deadline exceeded",
// which Classify routes to agent_error.provider_network — "the connection to
// the model provider dropped, check your network" for a stall that is purely
// local, plus an auto-retry of a failure that is deterministic on the host.
// The sentinel survives the preparation helper boundary via
// preparationErrorKind (#7112).
if errors.Is(err, execenv.ErrOpenclawCLITimeout) {
return taskfailure.ReasonRuntimeCLITimeout.String()
}
// Everything else that failed while building or re-opening the execution
// environment. Last of the structural branches: the four sentinels above
// each name a cause the daemon already recognises on its own (task
// identity, the prepare budget, skill downloads, the local runtime CLI)
// and stay the better label. What is left is the rest of preparation on
// this host — filesystem faults, and the per-provider local config
// Prepare writes or validates — whose text Classify can only read as
// agent output (#7913).
var envSetupErr *environmentSetupError
if errors.As(err, &envSetupErr) {
return taskfailure.ReasonEnvironmentPrepareFailed.String()
}
return taskfailure.Classify(err.Error()).String()
}
// acquireLocalDirectoryLockIfNeeded inspects the task's project resources for
// a local_directory pinned to this daemon, validates the path, and takes the
// path mutex. Returns a release callback (nil when no local_directory
// resource applies) and abort=true when the caller must bail without
// starting the task (the helper has already reported the failure to the
// server).
//
// The helper covers four distinct failure modes:
//
// 1. The project_resource JSON is structurally broken — fail the task fast.
// 2. The path fails validation (missing, not a directory, no R/W, system
// blacklist) — fail the task fast with a user-facing reason.
// 3. The mutex is held by another task — call MarkTaskWaitingLocalDirectory
// so the row flips to waiting_local_directory while we block on the
// lock, then return the release callback once we win.
// 4. The blocking wait is cancelled (daemon shutdown, server-side cancel)
// — fail the task with the ctx error.
func (d *Daemon) acquireLocalDirectoryLockIfNeeded(ctx context.Context, task Task, taskLog *slog.Logger) (release func(), abort bool) {
if len(task.ProjectResources) == 0 || d.cfg.DaemonID == "" {
return nil, false
}
assignment, err := localDirectoryAssignmentForTask(task, d.cfg.DaemonID)
if err != nil {
taskLog.Error("local_directory: resolve resource failed", "error", err)
if failErr := d.reportTerminalTask(ctx, terminalTaskReport{
kind: terminalTaskReportFail,
taskID: task.ID,
errorMessage: err.Error(),
failureReason: "local_directory_error",
}); failErr != nil {
taskLog.Error("fail task after local_directory resolve error", "error", failErr)
}
return nil, true
}
if assignment == nil {
return nil, false
}
taskLog = taskLog.With("local_directory", assignment.AbsPath)
// Check the mode before the path: a mode this daemon can't honour is a
// version-skew problem the user fixes by upgrading, and reporting a path
// complaint first would send them looking in the wrong place.
if err := assignment.ValidateExecutionMode(); err != nil {
taskLog.Error("local_directory: unsupported execution mode", "error", err)
if failErr := d.reportTerminalTask(ctx, terminalTaskReport{
kind: terminalTaskReportFail,
taskID: task.ID,
errorMessage: err.Error(),
failureReason: "local_directory_error",
}); failErr != nil {
taskLog.Error("fail task after local_directory mode check", "error", failErr)
}
return nil, true
}
if err := validateLocalPath(assignment.AbsPath); err != nil {
taskLog.Error("local_directory: path validation failed", "error", err)
if failErr := d.reportTerminalTask(ctx, terminalTaskReport{
kind: terminalTaskReportFail,
taskID: task.ID,
errorMessage: err.Error(),
failureReason: "local_directory_error",
}); failErr != nil {
taskLog.Error("fail task after local_directory validation error", "error", failErr)
}
return nil, true
}
// Worktree mode is the whole point of not serialising: each task gets its
// own checkout of the repo inside its env root, so there is no shared
// mutable state on the user's path to protect. Skipping the mutex here is
// what lets sibling tasks on one directory run concurrently. Path
// validation above still applies — git needs to write worktree
// registrations into the user's repo.
if assignment.UsesWorktree() {
taskLog.Info("local_directory: worktree mode, skipping path mutex")
return nil, false
}
// A conversation is not a second writer. Everything above still applied —
// the mode was checked, the path was validated, and the assignment stands,
// so this task keeps the user's directory as its working directory. Only
// the queueing is skipped, which is what stops a chat turn from sitting
// behind a 20-minute build with nothing to contribute to it (issue #7344).
// See localDirectoryLockExempt for why the mutex does not owe this task a
// slot.
if localDirectoryLockExempt(task) {
taskLog.Info("local_directory: chat task, skipping path mutex")
return nil, false
}
// While the lock is contended the daemon would otherwise sit blocked on
// the path mutex with no signal back from the server — the main
// per-task watcher only starts after the lock is acquired. If the user
// cancels the issue or it gets reassigned during the wait, we need to
// notice promptly so the daemon slot isn't pinned by a phantom waiter.
// We spin up the cancellation watcher lazily inside onWait so the
// no-contention fast path still costs nothing.
waitCtx, waitCancel := context.WithCancel(ctx)
defer waitCancel()
pollInterval := d.cancelPollInterval
if pollInterval == 0 {
pollInterval = 5 * time.Second
}
var (
watcherOnce sync.Once
prepareLeaseOnce sync.Once
cancelledByPoll <-chan struct{}
stopPrepareLease func()
waitCounted bool
)
defer func() {
if waitCounted {
d.resourceWaitTasks.Add(-1)
}
}()
defer func() {
if stopPrepareLease != nil {
stopPrepareLease()
}
}()
onWait := func(holder string) {
// LocalPathLocker invokes onWait synchronously and at most once for an
// Acquire call. Count the actual mutex wait even if the best-effort
// server status update below fails.
d.resourceWaitTasks.Add(1)
waitCounted = true
// Rendered to the user, so it names the directory rather than its path
// (see localDirectoryAssignment.DisplayName). The absolute path stays in
// the daemon's own logs, which is where an operator debugging a wedged
// lock looks for it.
reason := assignment.DisplayName()
if holder != "" {
// Known rough edge: this clause is English and the client renders it
// inside a localized "Waiting for {reason}" label, so a zh/ja/ko user
// sees mixed script. Fixing it properly means sending the directory
// and the holder as separate fields and localizing the join on the
// client — worth doing if this hint grows, not for one parenthetical.
reason = fmt.Sprintf("%s (held by task %s)", reason, shortID(holder))
}
taskLog.Info("local_directory: waiting on path mutex", "holder", holder)
if waitErr := d.client.MarkTaskWaitingLocalDirectory(ctx, task.ID, reason); waitErr != nil {
// Non-fatal: even if the server-side flag fails to update,
// we still want to block on the lock and proceed when free.
// The UI just won't see the explicit "waiting" badge.
taskLog.Warn("local_directory: mark waiting status failed", "error", waitErr)
}
prepareLeaseOnce.Do(func() {
stopPrepareLease = d.startTaskPrepareLeaseExtender(waitCtx, task, taskLog)
})
// Start polling once we actually park. shouldInterruptAgent inside
// watchTaskCancellation already handles both server-side terminal
// states (completed/failed/cancelled) and the row-deleted
// reassignment case (404), which is the full set of "this task
// shouldn't run anymore" signals we need to react to during the wait.
watcherOnce.Do(func() {
cancelledByPoll = d.watchTaskCancellation(waitCtx, task.ID, pollInterval, taskLog)
go func() {
select {
case <-cancelledByPoll:
waitCancel()
case <-waitCtx.Done():
}
}()
})
}
release, err = d.localPathLocks.Acquire(waitCtx, assignment.RealPath, task.ID, onWait)
if err != nil {
// If the wait was cut short because the server finalized the task
// (terminal state) or deleted the row, the row is already in a
// terminal state — return silently the same way the run-phase poller
// does at lines ~2104. Issuing FailTask here would be a no-op at best
// and a confusing redundant log line at worst.
if cancelledByPoll != nil {
select {
case <-cancelledByPoll:
taskLog.Info("local_directory: wait aborted by server-side terminal state")
return nil, true
default:
}
}
taskLog.Error("local_directory: lock acquire failed", "error", err)
failureReason := "local_directory_error"
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
failureReason = "cancelled"
}
if failErr := d.reportTerminalTask(ctx, terminalTaskReport{
kind: terminalTaskReportFail,
taskID: task.ID,
errorMessage: fmt.Sprintf("local_directory wait cancelled: %s", err.Error()),
failureReason: failureReason,
}); failErr != nil {
taskLog.Error("fail task after local_directory lock cancel", "error", failErr)
}
return nil, true
}
taskLog.Info("local_directory: lock acquired")
return release, false
}
// reportTaskResult writes the final task disposition back to the server.
//
// Fail closed: only an explicit "completed" status is reported as success.
// Anything else — "blocked", "cancelled", or any future status we forget to
// enumerate — must go through FailTask, so a run that never produced a real
// result can never be displayed as "Completed" in the UI (e.g. provider 429 /
// out-of-credit / runtime crash). Forward SessionID/WorkDir on every path:
// the agent may have built a real session before getting stuck, and we want
// the next chat turn to resume there rather than start over and "forget"
// the conversation.
func (d *Daemon) reportTaskResult(ctx context.Context, taskID string, result TaskResult, taskLog *slog.Logger) {
switch result.Status {
case "completed":
taskLog.Info("task completed", "status", result.Status)
err := d.reportTerminalTask(ctx, terminalTaskReport{
kind: terminalTaskReportComplete,
taskID: taskID,
output: result.Comment,
branchName: result.BranchName,
sessionID: result.SessionID,
workDir: result.WorkDir,
durableWorkDir: result.DurableWorkDir,
sessionRolloutMissing: result.SessionRolloutMissing,
retiredSessionID: result.RetiredSessionID,
})
if err == nil {
return
}
// The original completion is already durable. Never overwrite it with a
// synthetic failure: a temporary auth/config skew can make a 4xx recover
// after restart just as a transport outage can make a 5xx recover, and the
// user's successful output must remain authoritative in both cases.
taskLog.Error("complete task callback not acknowledged; durable report remains queued", "error", err)
default:
failureReason := result.FailureReason
if failureReason == "" {
if result.Status == "cancelled" {
// "cancelled" is a deliberate non-failure terminal
// state masquerading as a failure_reason — preserved
// outside the canonical taxonomy so the UI can render
// it differently from a real failure.
failureReason = "cancelled"
} else {
// MUL-2946: classify the agent's comment text so the
// failure_reason lands in the refined taxonomy
// (provider_auth_or_access, context_overflow,
// process_failure, …) instead of the legacy coarse
// "agent_error" bucket. Empty comment lands in
// ReasonAgentUnknown.
failureReason = taskfailure.Classify(result.Comment).String()
}
}
taskLog.Info("task did not complete, reporting failure", "status", result.Status, "failure_reason", failureReason)
if err := d.reportTerminalTask(ctx, terminalTaskReport{
kind: terminalTaskReportFail,
taskID: taskID,
errorMessage: result.Comment,
sessionID: result.SessionID,
workDir: result.WorkDir,
durableWorkDir: result.DurableWorkDir,
// Worktree mode commits the agent's leftovers before tearing the
// worktree down, so a failed run routinely still has a branch. This
// is the case where the user most needs it: the task went wrong and
// they want to see how far it got.
branchName: result.BranchName,
failureReason: failureReason,
sessionRolloutMissing: result.SessionRolloutMissing,
retiredSessionID: result.RetiredSessionID,
}); err != nil {
taskLog.Error("report failed task failed", "error", err)
}
}
}
// reportTerminalTask is the only path that sends complete/fail callbacks. It
// attempts to persist the exact report before the first network request and
// removes a persisted copy only after a successful response. A crash after the
// server commit but before local acknowledgement merely replays the same
// idempotent terminal request. If persistence itself fails, the direct request
// still runs so a healthy server is not held hostage by the local disk.
//
// It deliberately preserves context values while discarding cancellation and
// parent deadlines: daemon shutdown cancels the root context before pollLoop's
// 30-second drain, but terminal callbacks must still use that remaining window.
// The explicit timeout keeps this detached work bounded during normal runs.
func (d *Daemon) reportTerminalTask(parentCtx context.Context, report terminalTaskReport) error {
if _, err := persistedTerminalReport(report, time.Now()); err != nil {
return err
}
release, ok := d.beginTerminalReportDelivery(report.taskID)
if !ok {
return fmt.Errorf("terminal task report for %s is already being delivered", report.taskID)
}
defer release()
persisted := false
if d.terminalReports != nil {
if err := d.terminalReports.enqueue(report); err != nil {
// Durability is an availability improvement, not a prerequisite for
// the online callback. A read-only/full disk must not turn a request
// that the server could accept right now into a stuck task.
d.logger.Error("persist terminal task report; continuing with direct delivery",
"task", report.taskID,
"kind", report.kind,
"error", err,
)
} else {
persisted = true
}
}
ctx, cancel := context.WithTimeout(context.WithoutCancel(parentCtx), terminalTaskReportTimeout)
defer cancel()
err := d.sendTerminalTaskReport(ctx, report, defaultTerminalRetrySchedule)
if err != nil {
quarantined := false
if persisted {
item := pendingTerminalTaskReport{fileName: terminalReportFileName(report.taskID), report: report}
quarantined = d.handleTerminalReportDeliveryError(ctx, item, err)
}
if persisted && !quarantined {
d.signalTerminalReportReplay()
}
return err
}
if !persisted {
return nil
}
item := pendingTerminalTaskReport{fileName: terminalReportFileName(report.taskID), report: report}
if err := d.terminalReports.acknowledge(item); err != nil {
d.signalTerminalReportReplay()
return fmt.Errorf("acknowledge terminal task report: %w", err)
}
return nil
}
func (d *Daemon) beginTerminalReportDelivery(taskID string) (func(), bool) {
d.terminalReportMu.Lock()
if d.terminalReportFlight == nil {
d.terminalReportFlight = make(map[string]struct{})
}
if _, exists := d.terminalReportFlight[taskID]; exists {
d.terminalReportMu.Unlock()
return nil, false
}
d.terminalReportFlight[taskID] = struct{}{}
d.terminalReportMu.Unlock()
return func() {
d.terminalReportMu.Lock()
delete(d.terminalReportFlight, taskID)
d.terminalReportMu.Unlock()
}, true
}
func (d *Daemon) terminalReportClock() time.Time {
if d.terminalReportNow != nil {
return d.terminalReportNow()
}
return time.Now()
}
func (d *Daemon) sendTerminalTaskReport(ctx context.Context, report terminalTaskReport, schedule []time.Duration) error {
if d.terminalReportSend != nil {
return d.terminalReportSend(ctx, report, schedule)
}
switch report.kind {
case terminalTaskReportComplete:
return d.client.completeTaskWithRetrySchedule(ctx, report.taskID, report.output, report.branchName, report.sessionID, report.workDir, report.sessionRolloutMissing, report.retiredSessionID, report.durableWorkDir, schedule)
case terminalTaskReportFail:
return d.client.failTaskWithRetrySchedule(ctx, report.taskID, report.errorMessage, report.sessionID, report.workDir, report.branchName, report.failureReason, report.sessionRolloutMissing, report.retiredSessionID, report.durableWorkDir, schedule)
default:
return fmt.Errorf("unsupported terminal task report kind %d", report.kind)
}
}
// gcMetaForTask classifies a finished task and produces a GCMeta of the right
// kind. The discriminator order matters: a task carrying both an issue_id
// and a chat_session_id (theoretical, not produced today) should be treated
// as a chat task because the chat session is the longer-lived parent record.
//
// Returns ok=false when the task has no recognizable parent (e.g. an
// internal task with no IDs at all). The caller skips writing a meta file
// in that case so the directory falls back to mtime-based orphan cleanup.
func gcMetaForTask(task Task) (execenv.GCMeta, bool) {
meta := execenv.GCMeta{WorkspaceID: task.WorkspaceID, TaskID: task.ID}
switch {
case task.ChatSessionID != "":
meta.Kind = execenv.GCKindChat
meta.ChatSessionID = task.ChatSessionID
case task.AutopilotRunID != "":
meta.Kind = execenv.GCKindAutopilotRun
meta.AutopilotRunID = task.AutopilotRunID
case task.IssueID != "":
meta.Kind = execenv.GCKindIssue
meta.IssueID = task.IssueID
case task.QuickCreatePrompt != "":
// Quick-create tasks reach WriteGCMeta before the server runs
// LinkTaskToIssue, so IssueID is always empty here. Persist the
// task ID instead and let the GC loop ask the server for terminal
// state via the task gc-check endpoint.
meta.Kind = execenv.GCKindQuickCreate
meta.TaskID = task.ID
default:
return execenv.GCMeta{}, false
}
return meta, true
}
func taskRootDirParams(workspacesRoot string, task Task) execenv.RootDirParams {
return execenv.RootDirParams{
WorkspacesRoot: workspacesRoot,
WorkspaceID: task.WorkspaceID,
WorkspaceSlug: task.WorkspaceSlug,
TaskID: task.ID,
IssueIdentifier: task.IssueIdentifier,
}
}
// runtimeDisplayNameOverrides maps a provider key to the human-facing runtime
// name when simple title-casing would read awkwardly. Providers not listed
// here fall back to capitalizing the key (claude → "Claude", codex → "Codex").
// Built-in runtime identities (from agent.BuiltinRuntimes) are seeded into
// this map at init so their display names stay in lockstep with the
// descriptor.
var runtimeDisplayNameOverrides = map[string]string{
"codearts": "CodeArts",
"dsh": "DeepSeek Harness",
"traecli": "Trae",
"grok": "Grok",
"qoderclicn": "Qoder CN",
"qwen": "Qwen Code",
"qwenpaw": "QwenPaw",
"mcode": "MiniMax Code",
"zeroclaw": "ZeroClaw",
}
func init() {
// Seed built-in runtime identity display names from the descriptor so
// adding a new fork doesn't require editing this map by hand.
for _, desc := range agent.BuiltinRuntimes {
runtimeDisplayNameOverrides[desc.ID] = desc.DisplayName
}
}
// providerDisplayName returns the human-facing runtime name for a provider key.
func providerDisplayName(name string) string {
if name == "" {
return name
}
if friendly, ok := runtimeDisplayNameOverrides[name]; ok {
return friendly
}
return strings.ToUpper(name[:1]) + name[1:]
}
// providerNeedsInlineSystemPrompt reports whether the runtime brief must ride
// along in the turn itself (agent.ExecOptions.SystemPrompt) because the CLI
// will not pick up the per-task context file execenv writes into the workdir.
// This is the ONLY place that decides it, and it is the reason every other
// backend sees an empty SystemPrompt.
//
// Adding a provider here is a real fix only when that CLI genuinely ignores its
// context file — traecli was added because it reads .trae/rules/ and not
// AGENTS.md, so its agents were silently missing the workflow section and left
// issues stuck in `todo` with no comment and no error. Adding one that DOES
// read the file just duplicates the brief on every turn.
//
// Confirmed to load their context file, so deliberately absent here. MUL-5392
// probed each one over its real launch path with a canary in the context file
// and no inline delivery: claude 2.1.220 (CLAUDE.md), codex 0.144.6 driving the
// app-server (AGENTS.md), opencode 1.17.7 (AGENTS.md), pi 0.67.2 (AGENTS.md),
// hermes 0.18.2 over ACP (AGENTS.md). MCode 0.1.2 also loads AGENTS.md by its
// native runtime contract. kiro was confirmed earlier by a kiro-cli
// 2.13.0 ACP smoke — see the call site. Still unprobed: grok, qoder, codebuddy.
func providerNeedsInlineSystemPrompt(provider string) bool {
switch provider {
case "openclaw", "kimi", "traecli", "qwenpaw":
return true
default:
return false
}
}
// gateResumeToReachableSession clears the task's prior session unless this run
// can actually reach the store the session lives in, and reports whether that
// held. Most CLI backends key their session stores to the cwd (Claude Code
// looks sessions up under ~/.claude/projects/<encoded-cwd>/), so a session id
// from a different workdir can never resolve: the CLI exits within a second
// and the run fails before doing any work — permanently, because the failed
// run records no session and the next claim serves the same stale pointer
// again. This fires whenever the prior workdir no longer exists (GC'd after
// the issue went done, daemon reinstall, manual cleanup) and execenv.Reuse fell
// back to a fresh Prepare (GitHub #3854).
//
// Pi and OMP are the exception. Their opaque session id is an absolute JSONL
// path under ~/.multica/pi-sessions, and the backend passes that path directly
// to --session. The transcript remains resumable when only the task workdir
// changes, so binding it to workdir reuse discards healthy conversation history
// and forces the model to reconstruct it through `multica chat history`.
//
// Hermes is also independent of cwd: its transcript lives in HERMES_HOME's
// state.db. sessionHomeReachable checks whether the conversation-scoped store
// mounted by execenv holds history, or whether the task-local home was reused.
// Requiring the prior cwd as well would discard reachable history whenever a
// local_directory task gets a new worktree (#9062). An empty or unavailable
// store must still drop the resume, even when the cwd matches (#6806).
// Codex also has a conversation-scoped store for local_directory tasks. A
// continued worktree may use a new cwd while retaining its branch and rollout.
// Managed Codex tasks still require the same existing directory.
// sameExistingDir reports whether two paths name the same existing directory.
// False when either cannot be stat'd, which is the safe answer for cwd-keyed
// providers: an absent prior workdir means there is nothing to resume from.
func sameExistingDir(a, b string) bool {
if a == "" || b == "" {
return false
}
ai, err := os.Stat(a)
if err != nil {
return false
}
bi, err := os.Stat(b)
if err != nil {
return false
}
return os.SameFile(ai, bi)
}
// codexWorktreeResumeEligible permits a changed cwd only when the prepared
// local_directory worktree continues this conversation's branch and Codex has
// a stable conversation-scoped session store. In-place and managed tasks keep
// the existing same-directory rule.
func codexWorktreeResumeEligible(env *execenv.Environment, store string) bool {
return env != nil && env.LocalWorktree != nil && env.LocalWorktree.Continued && env.CodexHome != "" && store != ""
}
func gateResumeToReachableSession(task *Task, taskCtx *execenv.TaskContextForEnv, provider, envWorkDir string, sessionHomeReachable, refusesMissingSessionCwd, codexWorktreeResumeEligible bool, taskLog *slog.Logger) bool {
var reachable bool
if providerUsesPiSessionFile(provider) {
reachable = piSessionResumable(task.PriorSessionID, refusesMissingSessionCwd)
} else if provider == "hermes" {
reachable = sessionHomeReachable
} else if provider == "codex" && codexWorktreeResumeEligible {
reachable = sessionHomeReachable
} else {
// Compare the directories, not the spelling. Reuse runs in the canonical
// path it validated and locked, which need not be character-identical to
// the PriorWorkDir the server sent — a symlinked workspaces root is enough
// to make them differ. A string compare would then silently drop the prior
// session on every follow-up task in that installation.
reachable = task.PriorWorkDir != "" && sameExistingDir(envWorkDir, task.PriorWorkDir) && sessionHomeReachable
}
if !reachable && task.PriorSessionID != "" {
taskLog.Info("dropping prior session: session store not reachable from this run",
"provider", provider,
"session_id", task.PriorSessionID,
"prior_workdir", task.PriorWorkDir,
"workdir", envWorkDir,
"session_home_reachable", sessionHomeReachable,
)
task.PriorSessionID = ""
taskCtx.PriorSessionResumed = false
// The user expected this run to continue the prior conversation; surface
// the loss instead of silently restarting (MUL-4424). Set it on BOTH
// carriers: the notice is rendered from `task` by BuildPrompt (MUL-5377
// moved it out of the brief), while taskCtx still drives execenv.
taskCtx.PriorSessionResumeUnavailable = true
task.PriorSessionResumeUnavailable = true
}
return reachable
}
func providerUsesPiSessionFile(provider string) bool {
if provider == "pi" {
return true
}
desc, ok := agent.BuiltinRuntimeByID(provider)
return ok && desc.ProtocolFamily == "pi"
}
// piSessionResumable reports whether the backend can actually START from this
// session. The transcript file always has to be there; whether its recorded
// working directory also has to be there depends on the runtime, which is what
// refusesMissingCwd carries (providerRefusesMissingSessionCwd).
//
// Checking only the file is what produced GH #8082: after the prior task's
// worktree was reclaimed the file survived, the gate reported the session
// reachable, and Pi exited 1 in under 200ms with no tool call and no output —
// permanently, because the failed run records the same session id again and the
// next claim serves the same stale pointer. That is the exact failure shape the
// gate above exists to prevent; #7760 removed the protection for the Pi family
// without replacing it with the key Pi actually validates.
func piSessionResumable(sessionID string, refusesMissingCwd bool) bool {
if !piSessionFilePresent(sessionID) {
return false
}
return !refusesMissingCwd || piSessionCwdPresent(sessionID)
}
// providerRefusesMissingSessionCwd reports whether a runtime refuses to start
// when the working directory recorded in the transcript no longer exists.
//
// This is deliberately NOT the same question as providerUsesPiSessionFile.
// That one asks where the session lives — an independently addressed JSONL path
// rather than a cwd-keyed store — and it is true for the whole Pi protocol
// family. This one asks what the runtime does with the cwd it finds inside that
// file, and the family does not agree:
//
// - pi re-anchors a resumed run to the recorded cwd and hard-refuses when it
// is gone (GH #8082).
// - omp opens the transcript from an explicit --session path and falls back to
// the launch cwd instead; verified on v17.2.12, where the same transcript
// whose recorded cwd had been deleted resumed with exit 0 and ran in the
// launch directory.
//
// So the check must not be applied family-wide: doing that drops omp sessions
// omp would have resumed cleanly, which is exactly the continuity loss #7760
// set out to fix.
//
// builtinRuntime is why the provider name alone cannot answer this. A custom
// runtime profile keeps its protocol family as the provider, so
// `protocol_family: pi` with `command_name: <anything>` also arrives here as
// "pi" while being an unrelated implementation — the same trap agent.Config's
// BuiltinRuntime field documents. Only the provider's own discovered binary is
// the CLI whose refusal was actually verified, so a custom command answers
// false and keeps its session.
//
// False is the safe default in both directions — unknown Pi-family runtime,
// and custom command. A runtime that refuses in the words the backend matches
// (piResumeRefusedMarker) is still recovered by Result.ResumeRejected: one
// wasted run and a fresh session, not the permanent loop this issue reported.
// That backstop is a single phrase match and does not generalise to a refusal
// worded differently — but it does cover the case this default is most likely
// to be wrong about, a Pi-family runtime behaving like Pi. Guessing the other
// way has no backstop at all: it silently discards history that was never in
// danger, with nothing downstream to notice.
func providerRefusesMissingSessionCwd(provider string, builtinRuntime bool) bool {
return builtinRuntime && provider == "pi"
}
// piSessionFilePresent proves there is persisted history to resume. It does
// not claim the transcript is idle: the Pi backend takes an exclusive lock for
// the complete child-process lifetime and reports a busy resume as rejected so
// runTask falls back to its existing one-shot fresh-session path.
func piSessionFilePresent(sessionID string) bool {
if sessionID == "" {
return false
}
info, err := os.Stat(sessionID)
return err == nil && info.Mode().IsRegular() && info.Size() > 0
}
// piSessionCwdPresent mirrors Pi's own startup check, deliberately condition
// for condition, so the two cannot disagree about what "resumable" means a
// second time. Pi refuses to start if and only if the transcript carries a
// session header whose `cwd` is non-empty and does not exist on disk; with no
// header, or an empty cwd, it falls back to the launch directory and starts
// normally. Anything this predicate cannot read is therefore NOT evidence of a
// refusal, and returns true: a false negative here silently discards healthy
// conversation history, which is the regression #7760 set out to fix.
//
// Existence is the whole test — not "is a directory". Pi uses existsSync, which
// is true for a plain file too, so demanding a directory would drop sessions Pi
// would have accepted and re-open the same divergence from the other side.
//
// Note this says nothing about WHICH directory the resumed run will use. Pi
// adopts the recorded cwd, so a session whose recorded cwd still exists but is
// not this task's workdir resumes into the older directory. That is a separate
// defect with a separate fix; this predicate deliberately keeps the existing
// behaviour there rather than widening a crash fix into a semantic change.
func piSessionCwdPresent(sessionID string) bool {
cwd, ok := piSessionRecordedCwd(sessionID)
if !ok || cwd == "" {
return true
}
_, err := os.Stat(cwd)
return err == nil
}
// piSessionHeaderScanLines bounds how far into a transcript we look for the
// session header. Pi writes it as the first line at session creation, so the
// answer is always line 1 in practice; the bound only stops a pathological or
// hand-edited file from turning a cheap predicate into a full read of a
// multi-megabyte transcript.
const piSessionHeaderScanLines = 64
// piSessionMaxHeaderLine caps a single scanned line. A transcript's later lines
// carry whole assistant turns and can be far larger than bufio's 64KB default;
// without this the scan would stop early with an error on a perfectly healthy
// file.
const piSessionMaxHeaderLine = 1 << 20
// piSessionRecordedCwd returns the cwd recorded in the transcript's session
// header. The bool reports whether a header was found at all — callers must
// distinguish "no header" (Pi starts fine) from "header with an empty cwd"
// (also fine), and neither from a real recorded directory.
func piSessionRecordedCwd(path string) (string, bool) {
f, err := os.Open(path)
if err != nil {
return "", false
}
defer func() { _ = f.Close() }()
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 0, 64*1024), piSessionMaxHeaderLine)
for i := 0; i < piSessionHeaderScanLines && scanner.Scan(); i++ {
var entry struct {
Type string `json:"type"`
Cwd string `json:"cwd"`
}
// A malformed line is skipped rather than failing the scan: Pi itself
// tolerates unparseable entries when loading a transcript.
if err := json.Unmarshal(scanner.Bytes(), &entry); err != nil {
continue
}
if entry.Type == "session" {
return entry.Cwd, true
}
}
return "", false
}
// sessionHomeReachable reports whether a session recorded by a prior task on
// this conversation can still be found from THIS run's environment, for
// providers that key their sessions somewhere other than the cwd.
//
// Only Hermes does today: its transcripts live in `<HERMES_HOME>/state.db`,
// which is the per-task overlay under envRoot. Forwarding a session id into a
// database that does not hold it is what produced a conversation restarting
// from zero every turn (GH #6806), so the question has to be about the
// database, not about the plumbing:
//
// - With the conversation's session store mounted, the answer is whether that
// store actually holds a transcript. A mount onto an empty store is the
// normal shape of a first turn — and also of a store the GC reclaimed
// between turns, a switched Hermes profile, or a dangling link left by an
// older overlay. Reading "mounted" as "resumable" would forward a dead id
// into every one of those.
// - With no store, the transcript is the overlay's own task-local file, which
// survives exactly when this run reused the prior task's env root.
//
// Every other provider is keyed by cwd or handles its independently addressed
// store in gateResumeToReachableSession, so this predicate returns true.
func sessionHomeReachable(provider string, env *execenv.Environment, envReused bool) bool {
if provider != "hermes" {
return true
}
if env.HermesSessionStore != "" {
return env.HermesSessionHistoryPresent
}
return envReused
}
// shouldReusePriorWorkdir keeps the local_directory lock and cross-agent
// isolation invariants without forcing managed follow-ups onto a fresh
// provider session. Every managed issue or chat task may reuse only directories
// that resolve to the two-segment managed root shape, carry Prepare-time
// managed-env provenance for the same workspace/scope/agent, and carry a
// matching daemon task-context marker. Other task kinds have no durable scope
// with which to prove ownership and therefore start fresh.
//
// Reuse eligibility is deliberately keyed off .managed_env.json (written by
// execenv.Prepare) and NOT .gc_meta.json (written only after the task reaches
// terminal state). The server's task-complete handler reconciles a follow-up
// and wakes the runtime before the prior task's daemon handler writes the GC
// file, so a successor can be claimed inside that window; keying off the
// terminal file raced and dropped the session (MUL-4886). Both proofs this
// function reads — the env-root provenance and the workdir task-context marker
// — are written at Prepare time, so neither depends on completion ordering.
func shouldReusePriorWorkdir(task Task, localAssignment *localDirectoryAssignment, workspacesRoot string) (string, bool) {
if task.PriorWorkDir == "" || localAssignment != nil {
return "", false
}
// util.ResolveSymlinks, not filepath.EvalSymlinks: on Windows the latter
// cannot pass through a directory junction, so a junctioned workspaces
// root silently declined every reuse and each follow-up lost its session
// (#8946).
root, err := util.ResolveSymlinks(workspacesRoot)
if err != nil {
return "", false
}
workdir, err := util.ResolveSymlinks(task.PriorWorkDir)
if err != nil {
return "", false
}
info, err := os.Stat(workdir)
if err != nil || !info.IsDir() {
return "", false
}
rel, err := filepath.Rel(root, workdir)
if err != nil || !filepath.IsLocal(rel) {
return "", false
}
parts := strings.Split(rel, string(filepath.Separator))
if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] != "workdir" {
return "", false
}
if task.AgentID == "" || (task.IssueID == "" && task.ChatSessionID == "") {
return "", false
}
// Managed-env provenance is written only for non-local resumable envs, so
// its presence (plus the workspace/scope/agent match) proves this is a
// safe daemon-managed reuse target and not a residual local_directory path.
prov, err := execenv.ReadManagedEnvProvenance(filepath.Dir(workdir))
if err != nil || prov.ManagedBy != execenv.ManagedEnvProvenanceManagedBy ||
prov.WorkspaceID != task.WorkspaceID ||
prov.AgentID != task.AgentID {
return "", false
}
data, err := os.ReadFile(filepath.Join(workdir, execenv.TaskContextMarkerRelPath))
if err != nil {
return "", false
}
var marker struct {
ManagedBy string `json:"managed_by"`
AgentID string `json:"agent_id"`
IssueID string `json:"issue_id"`
ChatSessionID string `json:"chat_session_id"`
}
if json.Unmarshal(data, &marker) != nil {
return "", false
}
if marker.ManagedBy != execenv.TaskContextMarkerManagedBy || marker.AgentID != task.AgentID {
return "", false
}
if task.IssueID != "" {
if prov.IssueID != task.IssueID || marker.IssueID != task.IssueID {
return "", false
}
return workdir, true
}
if prov.ChatSessionID != task.ChatSessionID || marker.ChatSessionID != task.ChatSessionID {
return "", false
}
return workdir, true
}
// gateCodexResumeToRolloutPresence drops the prior Codex session when its
// rollout is not actually present in the task's CODEX_HOME sessions. A reused
// workdir keeps PriorSessionID (gateResumeToReachableSession), but Codex session
// isolation (MUL-4424) means the rollout may be missing: a migrated legacy home
// that could not locate it, or a local_directory task whose shared history was
// pruned. Codex would then silently thread/start from scratch, so we clear the
// resume claim from both the backend (PriorSessionID) and the brief
// (PriorSessionResumed) instead of pretending the conversation continues.
// No-op for non-Codex providers or when there is nothing to resume.
func gateCodexResumeToRolloutPresence(task *Task, taskCtx *execenv.TaskContextForEnv, provider, codexHome string, taskLog *slog.Logger) {
if provider != "codex" || task.PriorSessionID == "" || codexHome == "" {
return
}
if execenv.CodexResumeRolloutPresent(codexHome, task.PriorSessionID) {
return
}
taskLog.Warn("dropping prior codex session: rollout not present in task CODEX_HOME; starting a fresh thread",
"session_id", task.PriorSessionID, "codex_home", codexHome)
task.PriorSessionID = ""
taskCtx.PriorSessionResumed = false
// The user expected this run to continue the prior conversation; surface the
// loss instead of silently restarting (MUL-4424). Set it on BOTH carriers:
// the notice is rendered from `task` by BuildPrompt (MUL-5377 moved it out
// of the brief), while taskCtx still drives execenv.
taskCtx.PriorSessionResumeUnavailable = true
task.PriorSessionResumeUnavailable = true
}
const (
// codexRolloutFlushWait bounds how long the daemon waits for Codex to finish
// writing a session's rollout into the per-task store before treating that
// session as unrecoverable. Codex streams the rollout as the thread runs, so
// in the common case the file already exists and the check returns at once;
// the wait only adds latency on the rare path where the rollout never lands
// (an early crash/error) — which is exactly the pointer we must not persist.
codexRolloutFlushWait = 2 * time.Second
codexRolloutPollInterval = 50 * time.Millisecond
)
// codexSessionResumable reports whether a Codex session's rollout is present in
// the task's per-issue session store, so the daemon never records a session
// pointer the next follow-up would only discover is unresumable — and then drop
// via gateCodexResumeToRolloutPresence, losing the conversation (MUL-5305). It
// mirrors, at write time, the presence gate the daemon already applies at resume
// time: only a session whose rollout is on disk is worth persisting as the
// resumable pointer.
//
// Non-Codex providers have no rollout store to check (codexHome == "") and are
// always treated as resumable, preserving their existing behavior. Codex is
// given a brief bounded window to finish flushing before we give up, so ordinary
// flush lag is not mistaken for a lost rollout.
func codexSessionResumable(codexHome, sessionID string, wait time.Duration) bool {
if codexHome == "" || sessionID == "" {
return true
}
deadline := time.Now().Add(wait)
for {
if execenv.CodexResumeRolloutPresent(codexHome, sessionID) {
return true
}
if !time.Now().Before(deadline) {
return false
}
time.Sleep(codexRolloutPollInterval)
}
}
// waitCodexRolloutPresent blocks until sessionID's rollout appears in codexHome's
// per-issue store or ctx is done, returning whether it became present. It backs
// the mid-flight pin: Codex reveals the session id on a single task_started
// status, so a fixed one-shot check would miss a rollout that flushes a beat
// later; polling for the life of the run (bounded by ctx) catches it while never
// outliving the task. Non-Codex providers (codexHome == "") have no rollout to
// verify and return immediately (MUL-5305).
func waitCodexRolloutPresent(ctx context.Context, codexHome, sessionID string) bool {
if codexHome == "" || sessionID == "" {
return true
}
if execenv.CodexResumeRolloutPresent(codexHome, sessionID) {
return true
}
ticker := time.NewTicker(codexRolloutPollInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
// The rollout may have landed just as the run ended — check once more.
return execenv.CodexResumeRolloutPresent(codexHome, sessionID)
case <-ticker.C:
if execenv.CodexResumeRolloutPresent(codexHome, sessionID) {
return true
}
}
}
}
func (d *Daemon) ensureTaskSkillBundles(ctx context.Context, task *Task) error {
if task == nil || task.Agent == nil || len(task.Agent.SkillRefs) == 0 {
return nil
}
resolved := make(map[string]SkillData, len(task.Agent.SkillRefs))
misses := make([]SkillRefData, 0)
for _, ref := range task.Agent.SkillRefs {
ref := ref
var bundle SkillData
if err := d.skillCache.WithRefLock(task.WorkspaceID, ref, func() error {
if cached, ok := d.skillCache.Load(task.WorkspaceID, ref); ok {
bundle = cached
return nil
}
misses = append(misses, ref)
return nil
}); err != nil {
return fmt.Errorf("load skill bundle cache: %w", err)
}
if bundle.ID != "" {
resolved[skillRefKey(ref.Source, ref.ID)] = bundle
}
}
// Resolve each missing bundle in its own request, caching it the moment it
// arrives. The download is the slow part on jittery links, so fetching the
// whole set in one atomic body read meant a single timeout discarded all
// progress and the cache never converged — every dispatch re-downloaded
// everything and timed out again. Per-skill, each download fits its own
// size-scaled deadline and is persisted independently, so even a dispatch
// that ultimately fails leaves the skills it did fetch cached for the next
// one. (GitHub #4505 / MUL-3650)
for _, ref := range misses {
started := time.Now()
bundle, stats, err := d.resolveSkillBundle(ctx, task, ref)
if err != nil {
if isSkillBundleTransferFailure(err) {
// Only transport failures and incomplete 2xx bodies get the
// network diagnosis. HTTP error responses and invalid complete
// payloads retain their server semantics instead of being
// relabelled as connectivity problems (GitHub #7386).
return fmt.Errorf("%w: %s: %w",
errSkillBundleUnavailable,
describeSkillBundleFailure(ref, stats, time.Since(started)),
err)
}
return fmt.Errorf("%w: %w", errSkillBundleUnavailable, err)
}
resolved[skillRefKey(bundle.Source, bundle.ID)] = bundle
}
skills := make([]SkillData, 0, len(task.Agent.SkillRefs))
for _, ref := range task.Agent.SkillRefs {
bundle, ok := resolved[skillRefKey(ref.Source, ref.ID)]
if !ok {
return fmt.Errorf("skill bundle missing after resolve: skill_id=%s source=%s hash=%s", ref.ID, ref.Source, ref.Hash)
}
skills = append(skills, bundle)
}
task.Agent.Skills = skills
return nil
}
// resolveSkillBundle downloads one skill bundle and writes it to the on-disk
// cache before returning. The request runs under its own deadline, scaled to
// the bundle's declared size rather than the daemon's fixed 30s control-plane
// timeout, so a large bundle on a slow link is given room to finish instead of
// being cut off mid-body. Caching on success is what lets the resolve converge
// across dispatches. (GitHub #4505 / MUL-3650)
func (d *Daemon) resolveSkillBundle(ctx context.Context, task *Task, ref SkillRefData) (SkillData, TransferStats, error) {
reqCtx, cancel := context.WithTimeout(ctx, skillBundleResolveTimeout(ref.SizeBytes))
defer cancel()
bundle, stats, err := d.client.ResolveSkillBundle(reqCtx, task.RuntimeID, task.ID, ref)
if err != nil {
return SkillData{}, stats, err
}
// The resolve endpoint serves the agent's *current* bundle and hash, which
// may differ from the claim-time ref when the skill was edited between
// claim and prepare (see ResolveTaskSkillBundles). So confirm only that the
// server returned the skill we asked for (source/id), then validate the
// bundle for self-consistency against a ref derived from itself — pinning
// it to the possibly-stale requested hash would reject a legitimate update.
if bundle.Source != ref.Source || bundle.ID != ref.ID {
return SkillData{}, stats, fmt.Errorf("resolve skill bundle returned wrong skill: requested source=%s id=%s, got source=%s id=%s", ref.Source, ref.ID, bundle.Source, bundle.ID)
}
bundleRef := skillRefFromBundle(bundle)
validationRef := bundleRef
if ref.Source == skillbundle.SourcePlugin {
validationRef = ref
}
if !validateSkillBundle(validationRef, bundle) {
return SkillData{}, stats, fmt.Errorf("resolve skill bundle returned invalid bundle: skill_id=%s source=%s hash=%s", bundle.ID, bundle.Source, bundle.Hash)
}
if err := d.skillCache.WithRefLock(task.WorkspaceID, validationRef, func() error {
return d.skillCache.Store(task.WorkspaceID, bundle)
}); err != nil {
if d.logger != nil {
d.logger.Warn("skill bundle cache store failed; continuing with downloaded bundle",
"workspace_id", task.WorkspaceID,
"skill_id", bundle.ID,
"source", bundle.Source,
"hash", bundle.Hash,
"error", err,
)
}
}
return bundle, stats, nil
}
// isSkillBundleTransferFailure identifies errors for which byte-level network
// diagnostics are meaningful. A requestError is an explicit server response;
// malformed but complete JSON and bundle-validation errors are server payload
// problems. Neither should be presented as a slow or dead network link. In
// particular, json.Decoder can synthesize io.ErrUnexpectedEOF after its source
// ended with a clean EOF, so that decoder error alone is not transport proof.
func isSkillBundleTransferFailure(err error) bool {
var reqErr *requestError
if errors.As(err, &reqErr) {
return false
}
if errors.Is(err, context.DeadlineExceeded) {
return true
}
var netErr net.Error
return errors.As(err, &netErr)
}
// describeSkillBundleFailure renders the diagnostic half of a failed bundle
// download: what was being fetched, how many HTTP response-body bytes arrived,
// the separately declared decoded content size, and whether this host has a
// proxy at all.
//
// The three facts answer the three wrong turns the old text invited. "network
// error downloading skill X" instead of "skill X unavailable" stops the reader
// blaming the skill. The response byte count distinguishes a dead link from a
// partial response without pretending JSON wire bytes and decoded skill-content
// bytes form one progress ratio. The proxy note catches the case behind GitHub
// #7386, where a daemon that inherited no proxy on a cross-border link never got
// a byte through while the same host succeeded through a local proxy.
func describeSkillBundleFailure(ref SkillRefData, stats TransferStats, elapsed time.Duration) string {
elapsed = elapsed.Round(time.Millisecond)
var transfer string
switch {
case !stats.ResponseStarted:
transfer = fmt.Sprintf("no successful response from server after %s overall", elapsed)
case stats.BytesRead == 0:
transfer = fmt.Sprintf("a successful response started but delivered no body bytes before failing after %s overall", elapsed)
default:
// BytesRead is a single-attempt high-water mark, while elapsed covers
// the logical call including retries and backoff. State both scopes
// rather than deriving a rate from mismatched measurements.
transfer = fmt.Sprintf("received up to %s of response body data in one attempt; failed after %s overall",
formatBytes(stats.BytesRead), elapsed)
}
return fmt.Sprintf("network error downloading skill %q (id=%s): %s; declared skill content size %s; %s; the skill content is not at fault",
ref.Name, ref.ID, transfer, formatBytes(ref.SizeBytes), proxyEnvSummary())
}
// proxyEnvSummary reports whether the daemon inherited any proxy setting. It
// names the variable but never its value: proxy URLs routinely embed
// credentials, and this string lands in task failure text the whole workspace
// can read.
//
// Go's transport only consults these variables — it does not read the Windows
// system proxy — and it reads them at process start, so a daemon that was
// already running when they were set still shows none (GitHub #7386).
func proxyEnvSummary() string {
for _, key := range []string{"HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"} {
if strings.TrimSpace(os.Getenv(key)) != "" {
return fmt.Sprintf("proxy configured (%s)", key)
}
}
return "no proxy configured (HTTPS_PROXY unset)"
}
// formatBytes renders a byte count for humans reading a failure message.
func formatBytes(n int64) string {
switch {
case n < 0:
return "unknown size"
case n < 1024:
return fmt.Sprintf("%d B", n)
case n < 1024*1024:
return fmt.Sprintf("%.0f KB", float64(n)/1024)
default:
return fmt.Sprintf("%.2f MB", float64(n)/(1024*1024))
}
}
const (
// skillBundleResolveMinTimeout floors the per-skill resolve deadline so a
// tiny bundle still tolerates connection setup and round-trip latency.
skillBundleResolveMinTimeout = 30 * time.Second
// skillBundleResolveMaxTimeout caps it so a wedged download cannot pin a
// task in prepare indefinitely.
skillBundleResolveMaxTimeout = 5 * time.Minute
// skillBundleResolveMinThroughput is the pessimistic floor throughput
// (bytes/sec) used to scale the deadline to bundle size — deliberately low
// to cover slow, jittery links rather than ideal bandwidth.
skillBundleResolveMinThroughput = 50 * 1024
)
// skillBundleResolveTimeout returns the deadline budget for downloading a
// bundle of the given size: at least skillBundleResolveMinTimeout, scaled up at
// skillBundleResolveMinThroughput, and capped at skillBundleResolveMaxTimeout.
func skillBundleResolveTimeout(sizeBytes int64) time.Duration {
if sizeBytes <= 0 {
return skillBundleResolveMinTimeout
}
scaled := time.Duration(sizeBytes/skillBundleResolveMinThroughput) * time.Second
if scaled < skillBundleResolveMinTimeout {
return skillBundleResolveMinTimeout
}
if scaled > skillBundleResolveMaxTimeout {
return skillBundleResolveMaxTimeout
}
return scaled
}
func (d *Daemon) startTaskPrepareLeaseExtender(ctx context.Context, task Task, taskLog *slog.Logger) func() {
refresh := d.prepareLeaseRefresh
if refresh <= 0 {
refresh = taskPrepareLeaseRefresh
}
leaseCtx, cancel := context.WithCancel(ctx)
done := make(chan struct{})
go func() {
defer close(done)
ticker := time.NewTicker(refresh)
defer ticker.Stop()
for {
select {
case <-leaseCtx.Done():
return
case <-ticker.C:
reqCtx, reqCancel := context.WithTimeout(leaseCtx, taskPrepareLeaseTimeout)
err := d.client.ExtendTaskPrepareLease(reqCtx, task.RuntimeID, task.ID)
reqCancel()
if err != nil {
taskLog.Warn("extend task prepare lease failed", "error", err)
}
}
}
}()
var once sync.Once
return func() {
once.Do(func() {
cancel()
<-done
})
}
}
// lockReusablePriorEnvRoot decides whether this task may continue in a prior
// task's env root and, if so, takes the exclusion lock on it.
//
// Order matters and is the point of this function. The lock WRITES a file into
// the directory, so eligibility has to be proven first:
// shouldReusePriorWorkdir resolves symlinks, checks the
// {workspace}/{task}/workdir shape and verifies managed provenance, and only
// the canonical path it returns is ever opened. Locking on
// filepath.Dir(task.PriorWorkDir) before that would drop .task_lock into
// whatever the path happened to point at — a symlink target outside the
// workspaces root, or a user's own local_directory.
//
// The re-check under the lock closes the gap between proving eligibility and
// acting on it. Declining is always safe: the caller falls back to a fresh
// Prepare, which costs session continuity, not correctness.
//
// The error return is the one outcome that is NOT "decline and carry on": it is
// non-nil only when the run was cancelled while waiting for the prior env root,
// and it carries the context's cause so the caller can end the task instead of
// preparing an environment for work that no longer exists.
func (d *Daemon) lockReusablePriorEnvRoot(ctx context.Context, task Task, localAssignment *localDirectoryAssignment, heldRoot string) (*execenv.EnvRootClaim, string, os.FileInfo, bool, error) {
// Pin the workspaces root BEFORE validating anything. Opening it after,
// from a name validation just approved, would re-resolve that name: rename
// the root aside, leave a symlink to a look-alike tree, and os.Root
// faithfully pins the replacement. Root promises you cannot escape the tree
// it opened — not that it opened the tree you meant. Holding the handle
// first makes that ordering impossible.
wsRoot, err := os.OpenRoot(d.cfg.WorkspacesRoot)
if err != nil {
return nil, "", nil, false, nil
}
defer wsRoot.Close()
workDir, ok := shouldReusePriorWorkdir(task, localAssignment, d.cfg.WorkspacesRoot)
if !ok {
return nil, "", nil, false, nil
}
priorRoot := filepath.Dir(workDir)
// workDir came back through util.ResolveSymlinks, so the root it is
// measured against has to be resolved the same way — otherwise a symlinked
// workspaces root (macOS /tmp -> /private/tmp, a home on a linked volume,
// a Windows junction to another drive) makes the two look unrelated and
// every reuse is refused.
canonicalWorkspacesRoot, err := util.ResolveSymlinks(d.cfg.WorkspacesRoot)
if err != nil {
return nil, "", nil, false, nil
}
rel, err := filepath.Rel(canonicalWorkspacesRoot, priorRoot)
if err != nil || !filepath.IsLocal(rel) {
return nil, "", nil, false, nil
}
// Already covered by this run's own claim (a task re-dispatched onto its
// own directory); taking a second lock on it would only deadlock against
// ourselves.
if priorRoot == heldRoot {
return nil, workDir, nil, true, nil
}
// Pin the identity of the directory that just passed validation. Every
// later step is checked against THIS, not against whatever the name
// resolves to next: a path string cannot tell "the directory I validated"
// apart from "a different directory now answering to that name".
validatedInfo, err := os.Stat(priorRoot)
if err != nil {
return nil, "", nil, false, nil
}
// Deterministic seam for the TOCTOU regressions: tests swap the validated
// directory here, between proving eligibility and taking the lock.
if reuseLockTestHook != nil {
reuseLockTestHook()
}
lockStartedAt := time.Now()
claim, lockedInfo, err := d.lockEnvRootForReuseWaitingOutTheBusyWindow(ctx, wsRoot, rel, priorRoot, task)
switch {
case errors.Is(err, errPriorEnvRootWaitAborted):
// The run is over. Declining reuse would hand the caller on to a fresh
// Prepare, which is the one thing that must not happen here.
return nil, "", nil, false, context.Cause(ctx)
case errors.Is(err, execenv.ErrEnvRootBusy):
d.logger.Info("prior workdir is still in use after waiting for it; starting a fresh environment",
"task", task.ID, "prior_root", filepath.Base(priorRoot),
"waited", time.Since(lockStartedAt).Round(time.Millisecond), "budget", d.envRootBusyWait)
return nil, "", nil, false, nil
case err != nil:
d.logger.Warn("could not lock prior workdir; starting a fresh environment",
"task", task.ID, "error", err)
return nil, "", nil, false, nil
case claim == nil:
return nil, "", nil, false, nil
}
// The lock has to have landed on the directory validation approved.
if !os.SameFile(validatedInfo, lockedInfo) {
d.logger.Info("prior workdir changed identity before it could be claimed; starting a fresh environment",
"task", task.ID)
claim.Release()
return nil, "", nil, false, nil
}
// Re-validate while holding the lock, and require the answer to be the SAME
// directory. Equality of the canonical path is not enough on its own: the
// tree can be rearranged so a different-but-equally-valid env root now
// answers to that name, which would leave us holding a lock on one
// directory while handing another to Reuse. os.SameFile compares identity,
// not spelling.
recheckedWorkDir, stillOK := shouldReusePriorWorkdir(task, localAssignment, d.cfg.WorkspacesRoot)
if !stillOK || recheckedWorkDir != workDir {
claim.Release()
return nil, "", nil, false, nil
}
// ...and the directory we are about to hand to Reuse has to be that same
// one, so the object locked and the object used cannot diverge.
currentInfo, err := os.Stat(filepath.Dir(recheckedWorkDir))
if err != nil || !os.SameFile(lockedInfo, currentInfo) {
d.logger.Info("prior workdir changed identity while being claimed; starting a fresh environment",
"task", task.ID)
claim.Release()
return nil, "", nil, false, nil
}
return claim, workDir, lockedInfo, true, nil
}
// envRootBusyRetryInterval is how often the wait below re-tries the lock. The
// window it is covering is seconds long, so this only has to be small relative
// to that, not tight.
const envRootBusyRetryInterval = 250 * time.Millisecond
// errPriorEnvRootWaitAborted marks the wait as ended by the run itself rather
// than by the lock. It wraps the context's cause so the task can be failed with
// the real reason.
var errPriorEnvRootWaitAborted = errors.New("waiting for the prior env root was aborted")
// lockEnvRootForReuseWaitingOutTheBusyWindow takes the reuse lock, waiting out
// a lock the PREVIOUS run of this (issue, agent) has not let go of yet.
//
// A busy lock here has one cause in a healthy system: the server hands a task
// to a daemon only when no other task for the same (issue, agent) is dispatched
// or running (ClaimAgentTask's serialization), so by the time this task exists
// its predecessor is already finished as far as the server is concerned. The
// process is not: it learns of its cancellation from its own poll tick and
// takes a few seconds to exit, and it holds .task_lock until it does. That is
// the whole of the gap — a machine-local fact, visible right here.
//
// Giving up immediately costs the workdir, and with it the provider session
// living in it: the agent restarts with no memory of the conversation the user
// was in the middle of editing (MUL-6880). Waiting costs a few seconds of one
// task slot, which is far less than the fresh Prepare — including a repo
// checkout — that declining forces instead.
//
// The wait is bounded and gives up into exactly the old behaviour, so a
// predecessor that is genuinely wedged, or a lock held by something else on a
// shared workspaces root, still ends in a fresh environment rather than a
// stuck task.
func (d *Daemon) lockEnvRootForReuseWaitingOutTheBusyWindow(
ctx context.Context,
wsRoot *os.Root,
rel, priorRoot string,
task Task,
) (*execenv.EnvRootClaim, os.FileInfo, error) {
start := time.Now()
deadline := start.Add(d.envRootBusyWait)
waited := false
for {
claim, info, err := execenv.LockEnvRootForReuse(wsRoot, rel, priorRoot)
if !errors.Is(err, execenv.ErrEnvRootBusy) || !time.Now().Before(deadline) {
// The wait's real duration is logged, not the budget: 15s is a
// reasoned guess (one cancel-poll interval plus the agent's exit),
// and these lines are how it gets checked against production
// instead of staying a guess.
if waited && err == nil {
d.logger.Info("prior workdir freed while waiting for the previous run to exit",
"task", task.ID, "prior_root", filepath.Base(priorRoot),
"waited", time.Since(start).Round(time.Millisecond))
}
return claim, info, err
}
if !waited {
waited = true
d.logger.Info("prior workdir is still held by the previous run; waiting for it",
"task", task.ID, "prior_root", filepath.Base(priorRoot),
"budget", d.envRootBusyWait)
}
select {
case <-ctx.Done():
d.logger.Info("stopped waiting for the prior workdir: the run was cancelled",
"task", task.ID, "prior_root", filepath.Base(priorRoot),
"waited", time.Since(start).Round(time.Millisecond))
// NOT the busy error the loop was carrying. A cancelled run is a
// third outcome, and collapsing it into "still busy" would send the
// caller on to a fresh Prepare for work nobody is waiting for, and
// would file one cancellation under both "cancelled" and "budget
// exhausted" in the very logs the budget is meant to be judged by.
return nil, nil, fmt.Errorf("%w: %w", errPriorEnvRootWaitAborted, context.Cause(ctx))
case <-time.After(envRootBusyRetryInterval):
}
}
}
// reuseLockTestHook runs between reuse eligibility validation and taking the
// prior-root lock. Nil outside tests; the TOCTOU regressions use it to make the
// swap deterministic instead of racing it.
var reuseLockTestHook func()
// reuseBeforeUseTestHook runs after the prior-root claim is settled and before
// Reuse resolves that path by name. Nil outside tests.
var reuseBeforeUseTestHook func()
func (d *Daemon) prepareExecutionEnvironment(ctx context.Context, params execenv.PrepareParams) (*execenv.Environment, error) {
if d.executionEnvironmentCommand == nil {
// Focused runTask tests construct a zero-valued Daemon and keep setup
// in-process. Production Daemons created by New always use isolation.
return execenv.Prepare(params, d.logger)
}
command, err := d.executionEnvironmentCommand()
if err != nil {
return nil, err
}
return execenv.PrepareIsolated(ctx, command, params, d.logger)
}
func (d *Daemon) reuseExecutionEnvironment(ctx context.Context, params execenv.ReuseParams) (*execenv.Environment, error) {
if d.executionEnvironmentCommand == nil {
return execenv.Reuse(params, d.logger), nil
}
command, err := d.executionEnvironmentCommand()
if err != nil {
return nil, err
}
return execenv.ReuseIsolated(ctx, command, params, d.logger)
}
func (d *Daemon) effectiveTaskPrepareTimeout() time.Duration {
if d.taskPrepareTimeout > 0 {
return d.taskPrepareTimeout
}
return defaultTaskPrepareTimeout
}
func skillRefKey(source, id string) string {
return source + "\x00" + id
}
func skillRefFromBundle(bundle SkillData) SkillRefData {
files := make([]skillbundle.File, 0, len(bundle.Files))
for _, file := range bundle.Files {
files = append(files, skillbundle.File{Path: file.Path, Content: file.Content})
}
manifest := skillbundle.BuildManifest(skillbundle.Skill{
ID: bundle.ID,
Source: bundle.Source,
Name: bundle.Name,
Description: bundle.Description,
Content: bundle.Content,
Files: files,
})
fileRefs := make([]SkillFileRefData, 0, len(manifest.Files))
for _, file := range manifest.Files {
fileRefs = append(fileRefs, SkillFileRefData{Path: file.Path, SHA256: file.SHA256, SizeBytes: file.SizeBytes})
}
return SkillRefData{
ID: bundle.ID,
Source: bundle.Source,
Hash: manifest.Hash,
SizeBytes: manifest.SizeBytes,
FileCount: manifest.FileCount,
Files: fileRefs,
}
}
// taskModelSelection is what a task actually launches with: the model
// selector plus the capability overrides that survived validation.
type taskModelSelection struct {
Model string
ThinkingLevel string
ServiceTier string
}
// resolveTaskModelSelection settles the model selector and its capability
// overrides against the runtime's own model catalog, reading that catalog at
// most once per task — and not at all when nothing needs it.
//
// The single read is the point. Discovery is a CLI subprocess with a 15-30s
// ceiling, and cachedDiscovery deliberately does not memoize a result that
// came back empty or as a fallback (#3729, MUL-5549) so a transient failure
// can retry immediately. A logged-out or timing-out runtime therefore pays
// that ceiling in full on every read, and a task that read the catalog once to
// qualify its model and again to validate thinking_level would pay it twice
// before the agent even starts (MUL-6471 review).
//
// Who asks for the catalog:
// - opencode and its DevEco fork cannot execute an unqualified selector, so
// a pinned model has to be resolved against the catalog before launch.
// - thinking_level / service_tier are catalog-owned and keyed on the
// catalog's own model id, so they need it whenever they are set.
//
// A pi task with no capability override asks for neither: pi's own resolver
// accepts the persisted id in every shape it can take, so the daemon has
// nothing to add and skips discovery entirely. Same for claude, codex, and any
// task that pins no model.
//
// Qualification runs first because both checks below match on the catalog's
// canonical id: an unqualified id silently fails every lookup and drops a
// perfectly valid level (GH #7300).
func resolveTaskModelSelection(
ctx context.Context,
provider string,
runtimeCmd agent.Command,
sel taskModelSelection,
taskLog *slog.Logger,
) taskModelSelection {
capabilityChecksPending := sel.ThinkingLevel != "" || sel.ServiceTier != ""
read := false
var (
catalog agent.Catalog
catalogErr error
)
loadCatalog := func() (agent.Catalog, error) {
if !read {
read = true
catalog, catalogErr = listModels(ctx, provider, runtimeCmd)
}
return catalog, catalogErr
}
sel.Model = qualifyTaskModel(provider, sel.Model, capabilityChecksPending, loadCatalog, taskLog)
// service_tier is catalog-owned and currently Codex-only. As with
// thinking_level, stale or incompatible persisted values degrade to the
// runtime default instead of failing the task. A catalog that cannot
// validate — a lookup error, or a fallback catalog standing in for a
// failed discovery — passes the value through, so a discovery failure does
// not silently disable a previously valid user choice (MUL-7691).
if sel.ServiceTier != "" {
ok, err := agent.ValidateServiceTierWith(loadCatalog, provider, sel.Model, sel.ServiceTier)
if err != nil {
taskLog.Warn("service_tier: catalog cannot validate; passing through",
"provider", provider,
"model", sel.Model,
"service_tier", sel.ServiceTier,
"error", err,
)
} else if !ok {
taskLog.Warn("service_tier: not valid for this (provider, model); skipping injection",
"provider", provider,
"model", sel.Model,
"service_tier", sel.ServiceTier,
)
sel.ServiceTier = ""
}
}
// Per-model guard: the server validates the literal token against the
// provider's enum, but per-model gaps (Codex's per-model
// `supported_reasoning_levels`, Claude's per-model `supportedEffortLevels`)
// only resolve here, against the daemon's local CLI catalog. Invalid
// combinations log a warning and drop the level rather than failing the
// task, so a stale persisted value never blocks execution. An empty model
// is resolved by ValidateThinkingLevelWith to the provider's default model
// so default-model tasks aren't misjudged — except for codex, whose empty
// model follows config.toml (any model) and so fails closed, dropping the
// level here without a catalog read at all. Only a verified catalog can
// drop a level: on a lookup error or a fallback/empty catalog we keep the
// persisted level and let the CLI object, unless the binary itself has no
// such effort flag (MUL-7691).
if sel.ThinkingLevel != "" {
ok, err := agent.ValidateThinkingLevelWith(loadCatalog, provider, sel.Model, sel.ThinkingLevel)
if err != nil {
taskLog.Warn("thinking_level: catalog cannot validate; passing through",
"provider", provider,
"model", sel.Model,
"thinking_level", sel.ThinkingLevel,
"error", err,
)
} else if !ok {
taskLog.Warn("thinking_level: not valid for this (provider, model); skipping injection",
"provider", provider,
"model", sel.Model,
"thinking_level", sel.ThinkingLevel,
)
sel.ThinkingLevel = ""
}
}
return sel
}
// qualifyTaskModel promotes a persisted model id to the canonical
// `<provider>/<id>` selector its runtime catalog advertises, and returns the
// model to actually launch with.
//
// agent.model holds whatever was persisted, and for gateway-style providers a
// bare model id is itself slash-shaped (`claude/claude-opus-5` under provider
// `multica-anthropic`), so the delimiter cannot tell a missing provider from a
// present one — only the catalog knows (GH #7300).
//
// It reads the catalog for two distinct reasons, and neither is "because we
// can": either the runtime refuses to launch without a qualified selector, or
// a capability check is about to read the catalog anyway and will match
// nothing unless the id is canonical first. When neither holds, the persisted
// value goes to the CLI untouched and no subprocess is spawned.
func qualifyTaskModel(
provider, model string,
capabilityChecksPending bool,
loadCatalog func() (agent.Catalog, error),
taskLog *slog.Logger,
) string {
if model == "" {
return model
}
if !agent.ModelSelectorMustBeProviderQualified(provider) && !capabilityChecksPending {
return model
}
catalog, err := loadCatalog()
if err != nil {
// Same fail-open posture as the capability checks: an unreachable
// catalog must not stop a task whose model may well be exactly what
// the CLI expects.
taskLog.Warn("model: catalog lookup failed; using the configured model as-is",
"provider", provider,
"model", model,
"error", err,
)
return model
}
qualified, rewritten := agent.QualifyModelID(catalog, model)
if !rewritten {
return model
}
taskLog.Info("model: qualified against the runtime catalog",
"provider", provider,
"configured_model", model,
"model", qualified,
)
return qualified
}
func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot int, taskLog *slog.Logger) (taskResult TaskResult, returnErr error) {
phaseRecorder := taskPhaseRecorderFromContext(ctx)
phaseRecorder.Mark(taskPhasePrepareStarted)
// A claim carries the task-row agent id both at the top level and inside
// the expanded agent configuration. The top-level id is authoritative
// because it is also bound into the task-scoped token. Never prepare or
// reuse a workdir when the two identities disagree.
if err := validateTaskIdentity(task); err != nil {
return TaskResult{}, err
}
// Refuse to spawn an agent without a workspace. An empty workspace_id
// here would make MULTICA_WORKSPACE_ID empty in the agent env, and the
// CLI would otherwise silently fall back to the user-global config — a
// path that can leak operations into an unrelated workspace when
// multiple workspaces share a host.
if task.WorkspaceID == "" {
return TaskResult{}, fmt.Errorf("refusing to spawn agent: task has no workspace_id (task_id=%s)", task.ID)
}
prepareTimeout := d.effectiveTaskPrepareTimeout()
prepareCtx, cancelPrepare := context.WithTimeoutCause(ctx, prepareTimeout, errTaskPrepareTimeout)
prepareComplete := false
defer func() {
cancelPrepare()
if prepareComplete || returnErr == nil || errors.Is(returnErr, errStartClaimRejected) || !errors.Is(context.Cause(prepareCtx), errTaskPrepareTimeout) {
return
}
// Collapse every deadline shape (context deadline, HTTP cancellation,
// or the explicit waitForExecutionEnvironment cause) into one sentinel
// that handleTask can classify as a retryable platform timeout.
taskResult = TaskResult{}
returnErr = fmt.Errorf("%w after %s", errTaskPrepareTimeout, prepareTimeout)
}()
// task.Repos is the authoritative repo list for this task — when the
// claimed task belongs to a project with github_repo resources the server
// has already narrowed it to project repos only. Make sure those URLs are
// in the per-workspace allowlist and the local cache, otherwise
// `multica repo checkout` would reject project-only URLs that aren't also
// bound at the workspace level.
d.registerTaskRepos(task.WorkspaceID, task.ID, task.Repos)
defer d.clearTaskRepoRefs(task.WorkspaceID, task.ID)
entry, ok := d.agents()[provider]
// A custom runtime profile (MUL-3284) overrides the executable path: the
// runtime identity is the provider (so ResolveBackend applies its descriptor),
// but the actual binary on PATH is the profile's
// command_name, resolved at registration time and keyed by RuntimeID here.
// Critically, a custom runtime can live on a host that has NO built-in
// agent of the same provider installed, so when the runtime is custom we
// synthesize an AgentEntry instead of hard-failing on the !ok lookup.
var profileFixedArgs []string
// resolvedVersion is the CLI version of the built-in binary entry.Path
// resolves to, paired with the path by resolveAgentEntry so a just-upgraded
// codex is never launched under the previous version's policy (MUL-4486).
var resolvedVersion string
// usesCustomProfileCommand distinguishes "this provider's own binary" from
// "some other binary speaking this provider's protocol". Backends need it
// for compatibility exceptions verified against a specific vendor's CLI,
// which must not extend to arbitrary commands sharing a protocol family.
var usesCustomProfileCommand bool
if customSpec, isCustom := d.customProfileLaunchForRuntime(task.RuntimeID); isCustom {
usesCustomProfileCommand = true
entry.Path = customSpec.path
resolvedVersion = customSpec.version
// Filter here rather than relying on agent.New doing it, so that the
// launch and the catalog lookups below agree on one prefix. They share
// a discovery memo keyed on the command, and two spellings of the same
// runtime would key two entries.
profileFixedArgs = agent.FilterLaunchPrefix(provider, customSpec.fixedArgs, d.logger)
ok = true
d.logger.Info("task uses custom runtime profile command",
"task_id", task.ID, "runtime_id", task.RuntimeID,
"provider", provider, "command_path", customSpec.path,
"fixed_args", len(profileFixedArgs))
} else if ok {
// Built-in provider: self-heal a pinned executable path that an in-place
// upgrade deleted (MUL-4486). Only reached when no custom profile owns
// the launch, so a custom runtime's path is never second-guessed and a
// custom-only host pays no wasted re-resolution.
var resolveErr error
entry, resolvedVersion, resolveErr = d.resolveAgentEntryForLaunch(prepareCtx, provider, entry)
if resolveErr != nil {
return TaskResult{}, resolveErr
}
}
if !ok {
return TaskResult{}, fmt.Errorf("no agent configured for provider %q", provider)
}
stopPrepareLease := d.startTaskPrepareLeaseExtender(prepareCtx, task, taskLog)
defer stopPrepareLease()
if err := d.ensureTaskSkillBundles(prepareCtx, &task); err != nil {
return TaskResult{}, err
}
phaseRecorder.Mark(taskPhaseSkillsReady)
agentName := "agent"
var skills []SkillData
var instructions string
agentName = task.Agent.Name
skills = task.Agent.Skills
instructions = task.Agent.Instructions
// Prepare isolated execution environment.
// Repos are passed as metadata only — the agent checks them out on demand
// via `multica repo checkout <url>`.
taskCtx := execenv.TaskContextForEnv{
IssueID: task.IssueID,
TriggerCommentID: task.TriggerCommentID,
TriggerThreadID: task.TriggerThreadID,
CommentReplyTargets: commentReplyThreads(task),
NewCommentCount: task.NewCommentCount,
NewCommentsSince: task.NewCommentsSince,
PriorSessionResumed: task.PriorSessionID != "",
// MUL-5305: the server sets this when a more recent Codex session was
// withheld (rollout missing) and PriorSessionID is an older fallback (or
// absent). Seed the brief's continuity disclosure from it; the local
// resume gates below only ever OR it to true, so the signal is monotonic.
PriorSessionResumeUnavailable: task.PriorSessionResumeUnavailable,
AgentID: task.AgentID,
AgentName: agentName,
AgentInstructions: instructions,
AgentSkills: convertSkillsForEnv(skills),
DisabledRuntimeSkills: convertDisabledRuntimeSkillsForEnv(task.Agent, task.RuntimeID, provider),
Repos: convertReposForEnv(task.Repos),
ProjectID: task.ProjectID,
ProjectTitle: task.ProjectTitle,
ProjectDescription: task.ProjectDescription,
ProjectResources: convertProjectResourcesForEnv(task.ProjectResources),
ChatSessionID: task.ChatSessionID,
ChatChannelType: task.ChatChannelType,
ChatChannelDeliversFiles: task.ChatChannelDeliversFiles,
AutopilotRunID: task.AutopilotRunID,
AutopilotID: task.AutopilotID,
AutopilotTitle: task.AutopilotTitle,
AutopilotDescription: task.AutopilotDescription,
AutopilotSource: task.AutopilotSource,
AutopilotTriggerPayload: strings.TrimSpace(string(task.AutopilotTriggerPayload)),
QuickCreatePrompt: task.QuickCreatePrompt,
IsSquadLeader: taskIsSquadLeader(task),
RequestingUserName: task.RequestingUserName,
RequestingUserProfileDescription: task.RequestingUserProfileDescription,
InitiatorType: task.InitiatorType,
InitiatorID: task.InitiatorID,
InitiatorName: task.InitiatorName,
InitiatorEmail: task.InitiatorEmail,
WorkspaceContext: task.WorkspaceContext,
IssueStatuses: convertIssueStatusesForEnv(task.IssueStatuses),
IssueStatusesOmitted: task.IssueStatusesOmitted,
ConnectedApps: task.ConnectedApps,
}
// A local_directory task gets a fresh CODEX_HOME but mounts the same
// per-conversation sessions store as the task it replaces. Codex scopes its
// own thread writer lock to CODEX_HOME, so those two processes otherwise use
// different locks while appending to the same rollout. Hold a store-scoped,
// cross-process lock for the complete task lifetime. Take it before the env
// root claim so shutdown releases that claim first; the next task can then
// safely reuse the prior environment after it acquires this lock.
if provider == "codex" {
if store := execenv.CodexSessionStorePath(d.cfg.Profile, taskCtx); store != "" {
// Mark before waiting so the session-store GC cannot remove the store
// between this task being queued behind its predecessor and mounting it.
d.markActiveStore(store)
defer d.unmarkActiveStore(store)
waitCtx, waitCancel := context.WithCancel(prepareCtx)
var cancelledByPoll <-chan struct{}
writerLock, lockErr := execenv.AcquireCodexSessionWriterLock(waitCtx, store, func() {
taskLog.Info("codex: waiting for prior task to release conversation session writer")
pollInterval := d.cancelPollInterval
if pollInterval == 0 {
pollInterval = 5 * time.Second
}
cancelledByPoll = d.watchTaskCancellation(waitCtx, task.ID, pollInterval, taskLog)
go func() {
select {
case <-cancelledByPoll:
waitCancel()
case <-waitCtx.Done():
}
}()
})
waitCancel()
if lockErr != nil {
return TaskResult{}, fmt.Errorf("acquire Codex conversation session writer: %w", lockErr)
}
defer writerLock.Release()
}
}
// Mark candidate env roots as active before any env work so the GC loop
// can't reclaim artifacts inside them mid-execution. We mark both the
// stable root for a fresh Prepare and the prior root for Reuse — they
// usually differ (Reuse keeps the original task's directory).
resolvedRoot, err := execenv.ResolveRootDir(taskRootDirParams(d.cfg.WorkspacesRoot, task))
if err != nil {
return TaskResult{}, fmt.Errorf("resolve stable task env root: %w", err)
}
d.markActiveEnvRoot(resolvedRoot)
defer d.unmarkActiveEnvRoot(resolvedRoot)
if task.PriorWorkDir != "" {
priorRoot := filepath.Dir(task.PriorWorkDir)
if priorRoot != resolvedRoot {
d.markActiveEnvRoot(priorRoot)
defer d.unmarkActiveEnvRoot(priorRoot)
}
}
// Claim the env root HERE, in the daemon parent, and hold it for the whole
// task run — the same lifetime as unmarkActiveEnvRoot above.
//
// It cannot be claimed inside preparation: production preparation runs in a
// short-lived helper process (prepareExecutionEnvironment ->
// PrepareIsolated), so a lock taken there dies with the helper and the
// *os.File cannot cross its JSON response back to us. Claiming there would
// leave the agent running with no protection at all — which is exactly the
// re-dispatch window this guards.
envClaim, err := execenv.ClaimEnvRoot(taskRootDirParams(d.cfg.WorkspacesRoot, task))
if err != nil {
return TaskResult{}, fmt.Errorf("claim execution environment: %w", err)
}
defer envClaim.Release()
// Try to reuse the workdir from a previous task on the same (agent, issue) pair.
var env *execenv.Environment
// For a built-in codex task, use the version paired with the resolved path
// so an in-place upgrade can't leave the sandbox policy on the old version
// (MUL-4486). A custom codex runtime skips the self-heal, so resolvedVersion
// is empty and it keeps the existing cached-version fallback — its binary is
// the profile's own command, which the daemon never pins or version-detects.
// Non-codex providers carry the value through without consuming it.
codexVersion := d.agentVersion("codex")
if provider == "codex" && resolvedVersion != "" {
codexVersion = resolvedVersion
}
openclawBin := ""
if provider == "openclaw" {
openclawBin = entry.Path
}
// Resolve any local_directory assignment again here so runTask can plumb
// LocalWorkDir into execenv. handleTask already validated + locked the
// path for worker tasks; leader tasks intentionally skip the assignment.
localAssignment, _ := localDirectoryAssignmentForTask(task, d.cfg.DaemonID)
// Reuse intentionally skipped for local_directory tasks: the prior
// WorkDir is the user's own path (always present) but the reuse path
// loses the envRoot association the GC loop needs, and re-running
// Prepare against a stable user path is cheap (no clone, no copy).
// Leader tasks have no localAssignment; shouldReusePriorWorkdir separately
// requires Prepare-time managed-env provenance and a daemon-owned marker
// before allowing reuse, so a pre-fix leader session recorded against
// local_directory still fails closed.
var agentMcpConfig json.RawMessage
var effectiveMcpConfig json.RawMessage
var cursorMcpAuthSource string
remoteMCPConfig, remoteMCPDiagnostics, remoteMCPBrokers, remoteMCPErr := startTaskRemoteMCPBrokers(
prepareCtx, ctx, task.ID, provider, task.RemoteMCPConnections,
func(resolveCtx context.Context, contributionID string) (http.Header, error) {
return d.client.ResolveRemoteMCPCredential(resolveCtx, task.RemoteMCPDaemonToken, task.ID, contributionID)
},
taskLog,
)
if remoteMCPErr != nil {
return TaskResult{}, fmt.Errorf("prepare Remote MCP broker: %w", remoteMCPErr)
}
if remoteMCPBrokers != nil {
defer remoteMCPBrokers.Close()
}
for _, diagnostic := range remoteMCPDiagnostics {
taskLog.Warn("Remote MCP degraded", "reason", diagnostic)
}
// Agent-trigger plugin hooks, as a second local MCP server beside the
// broker. A failure to start it degrades to no plugin tools rather than
// failing the task: an agent that cannot reach a plugin should still work
// on the issue, which is the same rule that makes a failing tool call a
// tool error rather than a task error.
pluginHookConfig, pluginHookServer, pluginHookErr := startTaskPluginHookMCP(
ctx, task.ID, task.PluginHookTools,
func(callCtx context.Context, taskID, installationID, hookKey string, input json.RawMessage) (json.RawMessage, error) {
return d.client.InvokeAgentPluginHook(callCtx, task.RemoteMCPDaemonToken, taskID, installationID, hookKey, input)
},
taskLog,
)
if pluginHookErr != nil {
taskLog.Warn("plugin hook tools unavailable", "error", pluginHookErr)
}
if pluginHookServer != nil {
defer pluginHookServer.Close()
}
if len(pluginHookConfig) > 0 {
merged, mergeErr := mergeTaskRemoteMCPConfig(remoteMCPConfig, pluginHookConfig)
if mergeErr != nil {
taskLog.Warn("could not merge plugin hook MCP config", "error", mergeErr)
} else {
remoteMCPConfig = merged
}
}
if task.Agent != nil {
agentMcpConfig = task.Agent.McpConfig
effectiveMcpConfig = agentMcpConfig
if merged, mergeErr := mergeRuntimeAndAgentMcpConfig(provider, agentMcpConfig); mergeErr != nil {
taskLog.Warn("mcp_config: runtime merge failed; using agent configuration only",
"provider", provider,
"error", mergeErr,
)
} else {
effectiveMcpConfig = merged
}
if len(remoteMCPConfig) > 0 {
merged, mergeErr := mergeTaskRemoteMCPConfig(effectiveMcpConfig, remoteMCPConfig)
if mergeErr != nil {
return TaskResult{}, fmt.Errorf("merge Remote MCP broker configuration: %w", mergeErr)
}
effectiveMcpConfig = merged
}
if provider == "cursor" {
cursorMcpAuthSource = strings.TrimSpace(task.Agent.CustomEnv[execenv.CursorMcpAuthSourceEnv])
}
}
// Decode openclaw-specific runtime_config knobs once so reuse / prepare /
// ExecOptions all see the same mode + gateway pin (issue #3260). Parse
// failures fail soft to local mode — a broken JSON blob must never block
// task dispatch.
var openclawMode string
var openclawGateway execenv.OpenclawGatewayPin
if task.Agent != nil && provider == "openclaw" {
openclawMode, openclawGateway = decodeOpenclawRuntimeConfig(task.Agent.RuntimeConfig, d.logger)
}
var agentEnvOverrides map[string]string
var agentCustomArgs []string
if task.Agent != nil {
agentEnvOverrides = task.Agent.CustomEnv
agentCustomArgs = task.Agent.CustomArgs
}
// Effective Codex CLI args the task will launch with, normalized through the
// same agent.NormalizeCodexLaunchArgs pipeline buildCodexArgs uses (shell
// unquoting + blocked-flag filtering), preserving its ExtraArgs
// (profile-fixed + daemon defaults) vs CustomArgs (per-agent custom_args)
// split so the filtering matches launch exactly. Threaded into execenv so
// the Windows sandbox decision can honor a `-c windows.sandbox=...` override
// that never lands in config.toml — even when it arrives shell-quoted —
// instead of silently downgrading a user's isolation opt-in (MUL-4957).
var codexSandboxArgs []string
if provider == "codex" {
// profileFixedArgs still belongs in this reconstruction even though it
// no longer travels via ExtraArgs: it is a launch prefix now, so it is
// still on codex's argv, and a `-c windows.sandbox=...` written there
// must still be visible to the sandbox decision.
extraArgs := append(append([]string{}, profileFixedArgs...), defaultArgsForProvider(d.cfg, provider)...)
codexSandboxArgs = agent.NormalizeCodexLaunchArgs(extraArgs, agentCustomArgs, effectiveMcpConfig, d.logger)
}
// Hermes: resolve the overlay source home through one resolver contract —
// the selection parsed from custom_args (agent.ParseHermesProfileArgs) plus
// the agent's custom_env HERMES_HOME feed execenv.ResolveHermesProfile, which
// reproduces Hermes' own profile semantics (root derivation, explicit vs.
// sticky selection, reserved/invalid failure). A reserved/invalid selection
// fails the task closed, matching Hermes' sys.exit(1). The selected source
// home is exported to hermesEnv["HERMES_HOME"] so ${HERMES_HOME} in a
// profile's skills.external_dirs expands against the selected profile home,
// as native Hermes does before loading config.yaml. The parsed occurrence is
// stripped from the acp argv at launch (only when the overlay is built) so
// the flag can't re-point HERMES_HOME past the overlay.
var hermesSourceHome string
var hermesSourceMustExist bool
var hermesEnv map[string]string
var hermesMemoryStore string
var hermesSessionStore string
if provider == "hermes" {
// Resolve from the argv hermes will actually parse — launch prefix,
// the filtered custom args, then `acp` — which agent.HermesLaunchArgv
// assembles the same way the backend does. A custom runtime profile's
// fixed_args are the launch prefix now, so they are scanned before
// custom_args, and the backend's own `acp` token closes the argv and
// participates in the scan. Approximating that argv reads a different
// profile than the process does, and the overlay ends up seeded from
// the wrong home (GH #7046).
sel := agent.ParseHermesProfileArgs(agent.HermesLaunchArgv(profileFixedArgs, agentCustomArgs, d.logger))
res := execenv.ResolveHermesProfile(agentEnvOverrides["HERMES_HOME"], sel.Name, sel.Found, sel.Inline)
if res.Err != nil {
return TaskResult{}, fmt.Errorf("resolve hermes profile: %w", res.Err)
}
hermesSourceHome = res.SourceHome
hermesSourceMustExist = res.MustExist
// Which home the overlay is seeded from decides whether the task sees
// the user's provider config at all, and it is derived from the daemon
// PROCESS environment — invisible from the shell the user tests
// `hermes acp` in, which is why a mismatch reads as "works by hand,
// fails under Multica" (GH #6872). One line, at Info, so the answer is
// in the daemon log before anything fails rather than reconstructed
// afterwards.
taskLog.Info("hermes home resolved",
"source_home", hermesSourceHome,
"from_custom_env", strings.TrimSpace(agentEnvOverrides["HERMES_HOME"]) != "",
"must_exist", hermesSourceMustExist,
)
hermesEnv = sanitizeAgentEnv(agentEnvOverrides)
if hermesEnv == nil {
hermesEnv = map[string]string{}
}
hermesEnv["HERMES_HOME"] = res.SourceHome
// The overlay links memories/ here so the agent's long-term memory
// survives the task instead of being reset by every run (#6638). Keyed on
// the resolved source home so switching an agent's profile switches its
// memory line, matching Hermes' own "a profile is an isolated instance"
// model. Guarded from the GC for the whole task, as the Codex store below.
if store := execenv.HermesMemoryStorePath(d.cfg.Profile, task.AgentID, res.SourceHome); store != "" {
hermesMemoryStore = store
d.markActiveStore(store)
defer d.unmarkActiveStore(store)
}
// The overlay links state.db here so the conversation transcript
// survives the task and a follow-up turn can actually resume it
// (GH #6806). Keyed on (agent, resolved source home, conversation):
// tasks of one conversation are serial, so the shard has a single
// writer, while two issues never share a database. Guarded from the GC
// for the whole task, as the stores above and below.
if store := execenv.HermesSessionStorePath(d.cfg.Profile, task.AgentID, res.SourceHome, taskCtx); store != "" {
hermesSessionStore = store
d.markActiveStore(store)
defer d.unmarkActiveStore(store)
}
}
// Reasonix locates its user config from the environment (REASONIX_HOME, and
// the platform config dirs behind it), which an agent's custom_env may
// re-point or clear. The per-task reasonix.toml has to restate the
// permissions from whichever config the child ends up loading, so the deny
// rules the runtime owner set there survive the task-scoped config that
// overrides them — hence the same sanitized env the child is launched with.
var reasonixEnv map[string]string
if provider == "reasonix" {
reasonixEnv = sanitizeAgentEnv(agentEnvOverrides)
}
envReused := false
priorClaim, priorWorkDir, lockedPriorInfo, reusable, reuseErr := d.lockReusablePriorEnvRoot(ctx, task, localAssignment, envClaim.RootDir())
if reuseErr != nil {
// Cancelled while waiting for the previous run to let go of its
// directory. Ending here IS the behaviour: falling through would
// prepare a whole environment — repo checkout included — for a task
// nobody is waiting for any more.
return TaskResult{}, reuseErr
}
if reusable {
defer priorClaim.Release()
// Deterministic seam for the last-window regression: tests swap the
// directory here, after the claim is settled and before Reuse resolves
// the path by name.
if reuseBeforeUseTestHook != nil {
reuseBeforeUseTestHook()
}
var err error
env, err = d.reuseExecutionEnvironment(prepareCtx, execenv.ReuseParams{
WorkspacesRoot: d.cfg.WorkspacesRoot,
Profile: d.cfg.Profile,
// The canonical path the lock was taken on. Handing Reuse the raw
// PriorWorkDir instead would re-resolve it, so the directory we
// locked and the directory we use could differ.
WorkDir: priorWorkDir,
Provider: provider,
CodexVersion: codexVersion,
ResumeSessionID: task.PriorSessionID,
OpenclawBin: openclawBin,
McpConfig: effectiveMcpConfig,
CursorMcpAuthSource: cursorMcpAuthSource,
OpenclawGateway: openclawGateway,
HermesSourceHome: hermesSourceHome,
HermesSourceMustExist: hermesSourceMustExist,
HermesEnv: hermesEnv,
HermesMemoryStore: hermesMemoryStore,
HermesSessionStore: hermesSessionStore,
ReasonixEnv: reasonixEnv,
CodexCustomArgs: codexSandboxArgs,
Task: taskCtx,
})
if err != nil {
return TaskResult{}, asEnvironmentSetupFailure(fmt.Errorf("reuse execution environment: %w", err))
}
// Reuse resolves priorWorkDir by name, so confirm what it actually
// opened is still the directory we hold the lock on. An fd cannot cross
// into the preparation helper process, so the name is the only thing
// that can be handed over; this turns "silently ran somewhere else"
// into "declined and started clean". See lockReusablePriorEnvRoot for
// what remains uncovered.
if env != nil && lockedPriorInfo != nil {
usedInfo, statErr := os.Stat(filepath.Dir(env.WorkDir))
if statErr != nil || !os.SameFile(lockedPriorInfo, usedInfo) {
// No "task" field here: taskLog already carries the full id.
taskLog.Info("reused workdir is not the directory that was claimed; starting a fresh environment")
env = nil
}
}
// Reuse can decline (nil) and fall through to a fresh Prepare below.
// Whether it did decides whether an env-root-scoped session store — the
// Hermes overlay's task-local state.db — carried over from the prior task.
envReused = env != nil
}
if env == nil {
var err error
prepParams := execenv.PrepareParams{
WorkspacesRoot: d.cfg.WorkspacesRoot,
Profile: d.cfg.Profile,
WorkspaceID: task.WorkspaceID,
WorkspaceSlug: task.WorkspaceSlug,
TaskID: task.ID,
IssueIdentifier: task.IssueIdentifier,
AgentName: agentName,
// This run already holds the claim (envClaim above) and the reset
// it implies; preparation must not try to take it again.
EnvRootPreclaimed: true,
Provider: provider,
CodexVersion: codexVersion,
OpenclawBin: openclawBin,
McpConfig: effectiveMcpConfig,
CursorMcpAuthSource: cursorMcpAuthSource,
OpenclawGateway: openclawGateway,
HermesSourceHome: hermesSourceHome,
HermesSourceMustExist: hermesSourceMustExist,
HermesEnv: hermesEnv,
HermesMemoryStore: hermesMemoryStore,
HermesSessionStore: hermesSessionStore,
ReasonixEnv: reasonixEnv,
CodexCustomArgs: codexSandboxArgs,
Task: taskCtx,
}
if localAssignment.UsesWorktree() {
prepParams.LocalWorktree = &execenv.LocalWorktreeParams{LocalPath: localAssignment.AbsPath}
// Take the per-path mutex for the snapshot alone, then hand it
// straight back — long enough to read a consistent tree, short
// enough that worktree tasks still overlap for the run itself.
//
// A worktree task skips this lock for its execution, but the
// snapshot is the one moment it READS the user's directory, and the
// same real path can be attached to another project as an in_place
// resource (each project may attach it once, so several can).
// Snapshotting underneath a running in_place task would capture a
// half-written tree plus that task's in-flight sidecars.
//
// The wait gets the same visibility plumbing as the in-place
// acquire in acquireLocalDirectoryLockIfNeeded, because the holder
// can be an in-place task that runs for hours: without the status
// update the user sees a bare "preparing" with no hint the task is
// queued behind the directory, and without the poller a task the
// user cancels keeps its daemon slot pinned until the prepare
// timeout — the run-phase cancellation watcher only starts after
// launch. The prepare-lease extender is already running for this
// whole phase, so only status, accounting, and cancellation are
// mirrored here.
waitCtx, waitCancel := context.WithCancel(prepareCtx)
defer waitCancel()
pollInterval := d.cancelPollInterval
if pollInterval == 0 {
pollInterval = 5 * time.Second
}
// LocalPathLocker invokes onWait synchronously, in this goroutine,
// at most once per Acquire — see the in-place call site.
waitCounted := false
release, lockErr := d.localPathLocks.Acquire(waitCtx, localAssignment.RealPath, task.ID, func(holder string) {
d.resourceWaitTasks.Add(1)
waitCounted = true
reason := fmt.Sprintf("local_directory %s", localAssignment.AbsPath)
if holder != "" {
reason = fmt.Sprintf("%s (held by task %s)", reason, shortID(holder))
}
taskLog.Info("local_directory: worktree snapshot waiting for holder",
"holder", holder)
if waitErr := d.client.MarkTaskWaitingLocalDirectory(waitCtx, task.ID, reason); waitErr != nil {
// Non-fatal: the wait still happens, the UI just won't
// show the explicit "waiting" badge.
taskLog.Warn("local_directory: mark waiting status failed", "error", waitErr)
}
cancelled := d.watchTaskCancellation(waitCtx, task.ID, pollInterval, taskLog)
go func() {
select {
case <-cancelled:
waitCancel()
case <-waitCtx.Done():
}
}()
})
if waitCounted {
d.resourceWaitTasks.Add(-1)
}
if lockErr != nil {
return TaskResult{}, fmt.Errorf("local_directory worktree: wait for a consistent snapshot of %s: %w",
localAssignment.AbsPath, lockErr)
}
env, err = d.prepareExecutionEnvironment(prepareCtx, prepParams)
release()
if err != nil {
return TaskResult{}, asEnvironmentSetupFailure(fmt.Errorf("prepare execution environment: %w", err))
}
} else {
if localAssignment != nil {
prepParams.LocalWorkDir = localAssignment.AbsPath
}
env, err = d.prepareExecutionEnvironment(prepareCtx, prepParams)
if err != nil {
return TaskResult{}, asEnvironmentSetupFailure(fmt.Errorf("prepare execution environment: %w", err))
}
}
}
phaseRecorder.Mark(taskPhaseEnvironmentReady)
// Belt-and-suspenders: also mark whatever root we ended up with, in case
// future changes diverge from ResolveRootDir.
if env.RootDir != resolvedRoot && env.RootDir != "" {
d.markActiveEnvRoot(env.RootDir)
defer d.unmarkActiveEnvRoot(env.RootDir)
}
// Finalize the worktree on EVERY exit path, success or failure: commit
// whatever the agent left uncommitted, then unregister the worktree from
// the user's repo. Deferred against the named return so a task that fails
// mid-run still hands back the branch holding its partial work instead of
// letting `git worktree remove --force` delete it. A failing task is
// exactly when the user most wants to see how far the agent got.
//
// In-place local_directory runs never enter this block: their WorkDir is
// already durable, so DurableWorkDir deliberately stays absent instead of
// duplicating the same path under two lifecycle meanings.
if env.LocalWorktree != nil {
defer func() {
if taskResult.WorkDir == "" {
taskResult.WorkDir = env.WorkDir
}
if taskResult.EnvRoot == "" {
taskResult.EnvRoot = env.RootDir
}
outcome, finalizeErr := env.LocalWorktree.Finalize(taskLog)
if outcome.Branch != "" {
taskResult.BranchName = outcome.Branch
}
if finalizeErr == nil {
// The configured local_directory becomes authoritative only after
// Finalize confirms the disposable task worktree is actually gone.
if localAssignment != nil {
taskResult.DurableWorkDir = localAssignment.AbsPath
}
return
}
// Finalize could not complete its delivery contract, so the task
// worktree remains authoritative. This covers both an uncommitted
// change set and a committed branch whose worktree removal could not
// be confirmed. Fail the task: reporting success or a durable project
// directory here would hide the path that still needs attention.
//
// Wrapped in worktreePreservedError so the cancel path can
// recognise it: a cancelled task discards its result and error, but
// THIS error names the preserved worktree holding the agent's work
// and must ride the cancel ack instead of vanishing into a log.
// Joined rather than replacing an earlier failure — that one is
// usually the more useful primary cause, but the preserved path
// must not be displaced by it.
taskLog.Error("local_directory: worktree finalize incomplete; keeping the task worktree authoritative",
"error", finalizeErr, "preserved_path", outcome.PreservedPath)
wrapped := &worktreePreservedError{err: fmt.Errorf("local_directory worktree: %w", finalizeErr)}
if returnErr == nil {
returnErr = wrapped
} else {
returnErr = errors.Join(returnErr, wrapped)
}
}()
}
// Workdir is preserved for reuse by future tasks on the same (agent,
// issue) pair in cloud mode; the work_dir path is stored in DB on task
// completion and passed back via PriorWorkDir on the next claim, so
// rewriting the marker block in place is the right behavior.
//
// In local_directory mode the workdir is the user's own repo, reuse is
// already disabled above (see localAssignment == nil), and the brief
// would otherwise live on inside the user's repository — a subsequent
// manual `claude` / `codex` run in that directory would pick
// up stale Multica instructions (issue id, trigger comment id, reply
// rules) and start acting on the previous task's context. Excise the
// marker block on the way out instead.
//
// Worktree mode runs the same pass for a different reason: the worktree is
// disposable, but its branch is the deliverable, and Finalize commits
// whatever is still on disk. Without this the sidecars would land in every
// task's diff. The .git/info/exclude trick repocache uses for github_repo
// worktrees is not available here — a linked worktree resolves info/exclude
// to the user's own common git dir, so using it would silently change what
// `git status` hides in the user's checkout. Removing the files we wrote is
// both narrower and exact; it also leaves a genuine agent edit to a tracked
// CLAUDE.md intact, since CleanupRuntimeConfig only excises our marker block.
//
// Ordering: registered immediately after the Finalize defer above, so LIFO
// runs cleanup first and Finalize commits an already-clean worktree. It must
// also precede every early return between here and provider launch
// (temp-dir setup, StartTask): those paths still run Finalize, and without
// this pass Finalize would auto-commit the sidecars Prepare just wrote and
// deliver a branch whose only content is Multica's own runtime files — or,
// in place, leave them behind in the user's tree.
if env.LocalDirectory || env.LocalWorktree != nil {
defer func() {
var cleanupErr error
if cerr := execenv.CleanupRuntimeConfig(env.WorkDir, provider); cerr != nil {
cleanupErr = cerr
d.logger.Warn("execenv: cleanup runtime config failed", "error", cerr)
}
// Excise the sidecar tree (.agent_context/, .multica/,
// provider-specific .claude/skills/ etc.) that Prepare wrote
// into the user's repo. Without this pass the user's tree
// accumulates one directory layer per task — see MUL-2784.
// CleanupRuntimeConfig handles the runtime brief inside
// CLAUDE.md / AGENTS.md; CleanupSidecars handles
// every other file Prepare placed under WorkDir. Together
// they round-trip the workdir to its exact pre-task bytes.
if cerr := execenv.CleanupSidecars(env.RootDir); cerr != nil {
if cleanupErr == nil {
cleanupErr = cerr
}
d.logger.Warn("execenv: cleanup sidecars failed", "error", cerr)
}
// In worktree mode a failed cleanup is NOT survivable: Finalize is
// about to `git add -A`, so whatever the cleanup could not remove
// gets committed and delivered as the task's branch — a diff whose
// content is Multica's own runtime files, which is precisely what
// this mode promises never to produce. Tell Finalize to abort
// instead, so nothing is committed and the worktree is kept for
// inspection. (In place there is no commit and no branch, so a
// cleanup failure stays a warning: the leftover files are visible
// in the user's own tree and removable by hand.)
if cleanupErr != nil && env.LocalWorktree != nil {
env.LocalWorktree.AbortWithReason(fmt.Errorf(
"could not remove the runtime's own files from the worktree before committing: %w", cleanupErr))
}
}()
}
taskTempDir, taskTempLock, err := ensureTaskTempDir(env.RootDir, task.WorkspaceID, task.ID)
if err != nil {
return TaskResult{}, fmt.Errorf("prepare task temp dir: %w", err)
}
defer func() {
// Drop the execution lock before removing the directory: while it is
// held the GC sweep correctly refuses to touch this directory, so a
// removal that fails here (a file inside still open — the Windows case
// in #7364) would otherwise leave the directory pinned until the daemon
// exits. Released first, the next GC cycle acquires the lock, sees the
// owner is gone and reclaims it. Nothing waits on the task path.
//
// RemoveTaskTempDir rather than os.RemoveAll so that a cleanup which
// fails here leaves the .task_lock marker intact — without it the next
// sweep could not tell this directory from a pre-lock leftover.
execenv.ReleaseTaskTempLock(taskTempLock)
if cerr := execenv.RemoveTaskTempDir(taskTempDir); cerr != nil {
taskLog.Warn("task temp dir cleanup failed", "path", taskTempDir, "error", cerr)
}
}()
// Issue #3999 race A: now that env.WorkDir is on disk, transition the
// server-side state machine dispatched (or waiting_local_directory) →
// running. Calling StartTask before Prepare/Reuse let any consumer
// that read status==running and resolved
// /multica_workspaces/{ws}/{short-id}/workdir hit FileNotFoundError in
// the microsecond window before os.MkdirAll ran.
//
// On error we return early. A rejected claim is discarded by handleTask;
// other errors use its existing FailTask + taskfailure.Classify path with the same
// "start task failed: <…>" string and the same failure_reason
// taxonomy as before — see MUL-2946 for the classifier contract.
var taskCapabilities []string
if agent.SupportsTaskSupplement(provider, resolvedVersion) && task.IssueID != "" {
taskCapabilities = append(taskCapabilities, protocol.DaemonCapabilityTaskSupplementV1)
}
taskSupplementNegotiated, err := d.client.StartTask(prepareCtx, task, taskCapabilities...)
if err != nil {
stopPrepareLease()
return TaskResult{}, fmt.Errorf("start task failed: %w", err)
}
if taskSupplementNegotiated {
// Register before provider launch so a hint cannot arrive in the gap
// between the committed server transition and turn/started. The row is
// durable, so a coalesced hint is sufficient; the five-second fallback
// covers a notification sent before this start response arrived.
_, unsubscribeSupplements := d.taskSupplementSignals.subscribe(task.ID)
defer unsubscribeSupplements()
}
stopPrepareLease()
prepareComplete = true
cancelPrepare()
_ = d.client.ReportProgress(ctx, task.ID, fmt.Sprintf("Launching %s", provider), 1, 2)
// usesCustomProfileCommand is the same provenance the backend receives as
// agent.Config.BuiltinRuntime: it separates the provider's own discovered
// binary from an arbitrary command speaking its protocol. Reused here so
// the gate and the backend cannot disagree about which one is running.
resumeReachable := gateResumeToReachableSession(
&task, &taskCtx, provider, env.WorkDir,
sessionHomeReachable(provider, env, envReused),
providerRefusesMissingSessionCwd(provider, !usesCustomProfileCommand),
provider == "codex" && codexWorktreeResumeEligible(env, execenv.CodexSessionStorePath(d.cfg.Profile, taskCtx)),
taskLog,
)
// A reachable workdir or continued local worktree is not sufficient for a
// Codex resume: the prior thread's rollout must be in this task's CODEX_HOME
// sessions (MUL-4424 isolates them). Drop the resume before the brief is
// generated below if it isn't, so we never tell the agent it is continuing a
// conversation Codex will silently restart from scratch.
if resumeReachable {
gateCodexResumeToRolloutPresence(&task, &taskCtx, provider, env.CodexHome, taskLog)
}
// Inject runtime-specific config (meta skill) so the agent discovers .agent_context/.
runtimeBrief, err := execenv.InjectRuntimeConfig(env.WorkDir, provider, taskCtx)
if err != nil {
d.logger.Warn("execenv: inject runtime config failed (non-fatal)", "error", err)
}
// An exempt turn runs in the user's directory without having queued for it,
// so a sibling coding task may be writing to the same tree right now. That
// is the one thing it cannot work out from its own context — tell it.
// Worktree mode is excluded: there the tree is this task's private checkout.
var promptOptions []PromptOption
if localAssignment != nil && !localAssignment.UsesWorktree() && localDirectoryLockExempt(task) {
promptOptions = append(promptOptions, WithSharedLocalDirectory())
}
// Worktree mode hands this turn a tree that is mid-merge when the user's
// edits since the previous turn collided with the branch's own work. The
// conflict is deliberately left in place for the agent to resolve, so the
// prompt has to be the thing that tells it (MUL-6881).
if env.LocalWorktree != nil && len(env.LocalWorktree.ReplayConflicts) > 0 {
promptOptions = append(promptOptions, WithWorktreeReplayConflicts(env.LocalWorktree.ReplayConflicts))
}
prompt := BuildPrompt(task, provider, promptOptions...)
// Pass task-scoped auth credentials and context so the spawned agent CLI
// can call the Multica API and the local daemon (e.g. `multica repo checkout`).
// MULTICA_TASK_SLOT is allocated from the daemon-wide concurrency pool, not
// per-agent. When one daemon hosts multiple agents, slots index shared
// daemon-level resources such as GPUs.
// MULTICA_TOKEN is bound to (agent, task) by the server. Never fall back
// to the daemon's own credential here: doing so lets agent CLI writes land
// as the runtime owner's member actor and can retrigger the same agent.
agentToken, err := taskScopedAuthToken(task)
if err != nil {
taskLog.Error("task auth token invalid; refusing to start agent", "error", err)
return TaskResult{}, err
}
agentEnv := taskMulticaEnvironment(task, provider, agentName, agentToken, env.MulticaConfigRoot, d.cfg.WorkspacesRoot, d.cfg.ServerBaseURL, d.cfg.HealthPort, slot, taskTempDir)
if checkoutMode := repoCheckoutModeFor(provider, runtime.GOOS); checkoutMode != "" {
agentEnv[repoCheckoutModeEnv] = checkoutMode
}
if task.AutopilotRunID != "" {
agentEnv["MULTICA_AUTOPILOT_RUN_ID"] = task.AutopilotRunID
}
if task.AutopilotID != "" {
agentEnv["MULTICA_AUTOPILOT_ID"] = task.AutopilotID
}
// Quick-create marker — when set, the multica CLI's `issue create`
// command stamps the new issue with origin_type=quick_create +
// origin_id=<task_id> so the completion handler can find it
// deterministically (see GetIssueByOrigin).
if task.QuickCreatePrompt != "" {
agentEnv["MULTICA_QUICK_CREATE_TASK_ID"] = task.ID
if len(task.QuickCreateAttachmentIDs) > 0 {
if raw, err := json.Marshal(task.QuickCreateAttachmentIDs); err == nil {
agentEnv["MULTICA_QUICK_CREATE_ATTACHMENT_IDS"] = string(raw)
} else {
taskLog.Warn("quick-create attachment ids: marshal failed; skipping env injection", "error", err)
}
}
}
// Ensure the multica CLI is on PATH inside the agent's environment.
// Some runtimes (e.g. Codex) run in an isolated sandbox that may not
// inherit the daemon's PATH. Prepend the directory of the running
// multica binary so that `multica` commands in the agent always resolve.
if selfBin, err := resolveSelfExecutable(); err == nil {
binDir := filepath.Dir(selfBin)
agentEnv["PATH"] = binDir + string(os.PathListSeparator) + os.Getenv("PATH")
}
// Point Codex to the per-task CODEX_HOME so it discovers skills natively
// without polluting the system ~/.codex/skills/.
if env.CodexHome != "" {
agentEnv["CODEX_HOME"] = env.CodexHome
}
// HOME and the XDG base dirs are deliberately not touched here: provider
// tools such as gh, aws, kubectl, and npm continue resolving the daemon
// user's existing state (MUL-5578). The Multica CLI is the exception:
// MULTICA_TASK_CONFIG_ROOT above redirects its implicit profile lookup to
// private task-local state and prevents Owner-profile fallback.
// (Hermes HERMES_HOME is applied after custom_env below so the per-task
// overlay can win over a user-set HERMES_HOME; see
// layerCustomEnvAndHermesHome.)
// Point Cursor at per-task project state when managed MCP is present.
// The workdir .cursor/mcp.json carries the managed server list, while
// CURSOR_DATA_DIR isolates the matching project approvals from the user's
// persistent ~/.cursor/projects state.
if env.CursorDataDir != "" {
agentEnv["CURSOR_DATA_DIR"] = env.CursorDataDir
}
// Point OpenClaw at the per-task synthesized config. The config pins
// agents.defaults.workspace (and any agents.list[].workspace) to the
// task workdir, so the CLI's native skill scanner picks up the per-task
// skills written under {workDir}/skills/. Falls back silently when the
// preparer didn't run (non-openclaw provider, or write failure).
if env.OpenclawConfigPath != "" {
agentEnv["OPENCLAW_CONFIG_PATH"] = env.OpenclawConfigPath
}
// Grant the wrapper config permission to $include the user's active
// config across directories. OpenClaw's $include defaults to confining
// resolution to the wrapper's own directory; without this, the
// wrapper-out-of-envRoot $include into ~/.openclaw/openclaw.json is
// rejected and the run boots with no user-registered agents.
if rootsValue, ok := composeOpenclawIncludeRoots(env.OpenclawIncludeRoot, os.Getenv("OPENCLAW_INCLUDE_ROOTS")); ok {
agentEnv["OPENCLAW_INCLUDE_ROOTS"] = rootsValue
}
// Inject user-configured custom environment variables (e.g. ANTHROPIC_API_KEY,
// ANTHROPIC_BASE_URL for router/proxy mode, or CLAUDE_CODE_USE_BEDROCK for
// Bedrock). These are set per-agent via the agent settings UI.
// Critical internal variables are blocklisted to prevent accidental or
// malicious override of daemon-set values.
var agentCustomEnv map[string]string
if task.Agent != nil {
agentCustomEnv = task.Agent.CustomEnv
}
layerCustomEnvAndHermesHome(agentEnv, agentCustomEnv, env.HermesHome, d.logger)
if provider == "reasonix" {
reasonixStateHome, err := prepareReasonixTaskStateHome(d.cfg.Profile, task.RuntimeID, task.AgentID)
if err != nil {
return TaskResult{}, fmt.Errorf("prepare reasonix state home: %w", err)
}
agentEnv["REASONIX_STATE_HOME"] = reasonixStateHome
}
if provider == "dsh" {
dshSessionRoot, err := prepareDshTaskSessionRoot(d.cfg.Profile, task.RuntimeID, task.AgentID)
if err != nil {
return TaskResult{}, fmt.Errorf("prepare dsh session root: %w", err)
}
agentEnv["MULTICA_DSH_SESSION_ROOT"] = dshSessionRoot
agentEnv["DSH_TELEMETRY_DISABLED"] = "1"
}
if err := configureCodexTaskShellEnvironment(provider, env.CodexHome, os.Environ(), agentEnv, agentCustomEnv, d.logger); err != nil {
return TaskResult{}, err
}
// The overlay is authoritative once built, so nothing on the command line
// may re-point HERMES_HOME out of it. Both argv regions are stripped
// together, against the same assembled argv the resolver read: a selection
// can straddle them (a prefix ending in a bare `-p` captures the first
// custom arg), which per-region stripping cannot see.
var hermesOverlayCustomArgs []string
hermesOverlayActive := provider == "hermes" && env != nil && env.HermesHome != ""
if hermesOverlayActive {
var rawCustomArgs []string
if task.Agent != nil {
rawCustomArgs = task.Agent.CustomArgs
}
profileFixedArgs, hermesOverlayCustomArgs = agent.StripHermesProfileSelectors(
profileFixedArgs, rawCustomArgs, d.logger)
}
// Resolve the backend through the unified runtime resolver: built-in
// runtime identities (e.g. "omp") dispatch through NewRuntime, protocol
// families go through New. This is the single production boundary — the
// daemon never calls agent.New or agent.NewRuntime directly, so the two
// factories stay meaning exactly one thing each.
backend, err := agent.ResolveBackend(provider, agent.Config{
ExecutablePath: entry.Path,
LaunchPrefix: profileFixedArgs,
CLIVersion: resolvedVersion,
Env: agentEnv,
Logger: d.logger,
TaskID: task.ID,
RuntimeID: task.RuntimeID,
DaemonVersion: d.cfg.CLIVersion,
CodexVersion: codexVersion,
BuiltinRuntime: !usesCustomProfileCommand,
})
if err != nil {
return TaskResult{}, fmt.Errorf("create agent backend: %w", err)
}
// Two-tier model resolution: an explicit agent.model wins,
// then the daemon-wide MULTICA_<PROVIDER>_MODEL env var. If
// both are empty we deliberately pass "" through — each
// backend omits `--model` from the CLI invocation, so the
// provider picks its own default (Claude Code's shipped
// default, codex app-server's account-scoped default, etc.).
// Baking a Go-side "recommended default" here is how the
// cursor regression happened — static guesses drift from
// whatever the upstream CLI actually accepts.
//
// Resolved before the start log rather than at first use: logging
// entry.Model there reported the env-var tier alone, so every task whose
// model came from agent.model — the common case — announced itself with an
// empty model and looked like the selection had been dropped (GH #7300).
model := ""
if task.Agent != nil && task.Agent.Model != "" {
model = task.Agent.Model
}
if model == "" {
model = entry.Model
}
taskLog.Info("starting agent",
"provider", provider,
"workdir", env.WorkDir,
"model", model,
"resume_reachable", resumeReachable,
)
if task.PriorSessionID != "" {
taskLog.Info("resuming session", "session_id", task.PriorSessionID)
}
taskStart := time.Now()
var customArgs []string
// profileFixedArgs deliberately does NOT go here. It travels as
// agent.Config.LaunchPrefix instead, because ExtraArgs is honoured by only
// six of the twenty-one backends and lands *after* the protocol flags in
// the ones that do — so a wrapper's subcommand was either dropped on the
// floor or spliced in behind `-p` (GH #7046).
extraArgs := defaultArgsForProvider(d.cfg, provider)
var mcpConfig json.RawMessage
if task.Agent != nil {
customArgs = task.Agent.CustomArgs
mcpConfig = effectiveMcpConfig
}
if hermesOverlayActive {
// Stripped above, alongside the launch prefix. A skill-less hermes task
// has no overlay to protect and keeps its flags untouched.
customArgs = hermesOverlayCustomArgs
}
thinkingLevel := ""
serviceTier := ""
if task.Agent != nil {
thinkingLevel = task.Agent.ThinkingLevel
serviceTier = task.Agent.ServiceTier
}
selection := resolveTaskModelSelection(ctx, provider, agent.NewCommand(entry.Path, profileFixedArgs),
taskModelSelection{Model: model, ThinkingLevel: thinkingLevel, ServiceTier: serviceTier}, taskLog)
model, thinkingLevel, serviceTier = selection.Model, selection.ThinkingLevel, selection.ServiceTier
var idleWatchdogTimeout time.Duration
if provider == "opencode" || provider == "codearts" {
idleWatchdogTimeout = d.cfg.OpenCodeIdleWatchdog
}
execOpts := agent.ExecOptions{
EnableTaskSupplement: taskSupplementNegotiated,
Cwd: env.WorkDir,
Model: model,
ThreadName: deriveTaskThreadName(task),
Timeout: d.cfg.AgentTimeout,
SemanticInactivityTimeout: d.cfg.CodexSemanticInactivityTimeout,
FirstTurnNoProgressTimeout: d.cfg.CodexFirstTurnNoProgressTimeout,
IdleWatchdogTimeout: idleWatchdogTimeout,
HandshakeTimeout: d.cfg.CodexHandshakeTimeout,
TurnInterruptTimeout: d.cfg.CodexTurnInterruptTimeout,
ThreadHandshakeTimeout: d.cfg.CodexThreadHandshakeTimeout,
ResumeSessionID: task.PriorSessionID,
// Post-gate intent: PriorSessionID here already reflects the pre-flight
// resume gates (a dropped resume is surfaced via the prompt instead). If it
// survived to here, the backend must disclose the loss when the live
// resume still fails — even across the fresh-session retry below, which
// clears ResumeSessionID but not this (MUL-4424).
//
// What that disclosure SAYS, and whether it addresses the user at all,
// depends on whether this surface's conversation is still readable, which
// only the daemon knows — hence handing the backend finished text rather
// than a flag. Empty when the prompt already carries the notice, so a turn
// can never pay for it twice (MUL-5722).
ResumeExpected: task.PriorSessionID != "",
ResumeContinuityNotice: backendResumeContinuityNotice(task),
ExtraArgs: extraArgs,
CustomArgs: customArgs,
McpConfig: mcpConfig,
ThinkingLevel: thinkingLevel,
ServiceTier: serviceTier,
OpenclawMode: openclawMode,
ClaudeSettingsPath: env.ClaudeSettingsPath,
QwenpawWorkspace: env.QwenpawWorkspace,
}
// Some providers do not reliably load the per-task runtime config files we
// write into the task workdir:
// - openclaw is pinned to the task workdir via the per-task config we
// synthesize (see prepareOpenclawConfig), so AGENTS.md / .agent_context/
// in the workdir ARE picked up by the CLI. Inline injection is retained
// as a belt-and-suspenders for older openclaw releases until that load
// path stabilises in production; remove this once a release tracks the
// workdir bootstrap reliably end-to-end.
// - kimi is wrapped through its own CLI whose cwd handling is opaque
// enough that we can't trust the file-based path either.
// Pass the full runtime brief inline (CLI catalog + workflow steps + agent
// identity/persona + skills + project context) so the backend prepends the
// same payload that file-based runtimes pick up from disk. Without this,
// these providers silently miss the workflow section and never call
// `multica issue status` / `multica issue comment add`, leaving issues
// stuck in `todo`.
//
// Hermes and Kiro are intentionally excluded: their ACP sessions start in
// the task cwd and load AGENTS.md themselves. Kiro documents root AGENTS.md
// as always included, and a real kiro-cli 2.13.0 ACP smoke confirms it.
// Prepending the full runtime brief into the ACP user prompt duplicates that
// context and bloats every turn.
if providerNeedsInlineSystemPrompt(provider) {
execOpts.SystemPrompt = runtimeBrief
}
// A quick-actions refresh task from a server that predates server-side
// generation (MUL-5573). This daemon no longer has a suggestion pass to run
// it with, and it must NOT fall through to the ordinary chat path below:
// the task carries no user message, so the agent would answer a prompt
// nobody wrote and that server would persist the result as a real assistant
// reply. Complete it empty instead — the same shape the retired pass
// produced on this task, which that server writes no row for. The user's
// refresh spinner resolves via the client's own timeout.
if task.RegenerateQuickActionsFor != "" {
taskLog.Warn("refusing quick-actions refresh task from an older server; complete the daemon upgrade by updating the server",
"target_task", task.RegenerateQuickActionsFor,
)
return TaskResult{Status: "completed", Comment: "", WorkDir: env.WorkDir, EnvRoot: env.RootDir}, nil
}
// Authenticate the localhost repo-checkout endpoint with the same
// task-scoped token the child receives. The endpoint derives identity and
// branch ownership from this in-memory record instead of trusting request
// fields or ambient process environment. Register only for the provider
// execution window and always remove the credential afterwards.
d.registerActiveRepoCheckoutTask(agentToken, activeRepoCheckoutTask{
WorkspaceID: task.WorkspaceID,
TaskID: task.ID,
AgentID: task.AgentID,
AgentName: task.Agent.Name,
WorkDir: env.WorkDir,
})
defer d.clearActiveRepoCheckoutTask(agentToken)
taskLog.Debug("invoking backend",
"provider", provider,
"model", model,
"prompt_bytes", len(prompt),
"custom_args", len(customArgs),
"extra_args", len(extraArgs),
"mcp_config", len(mcpConfig) > 0,
"inline_system_prompt", execOpts.SystemPrompt != "",
"resume_session", execOpts.ResumeSessionID != "",
"timeout", execOpts.Timeout,
"idle_watchdog", execOpts.IdleWatchdogTimeout,
)
// Shared across the resume-retry below so the retry's transcript rows
// keep ascending seq values for the same task.
var msgSeq atomic.Int32
result, tools, err := d.executeAndDrain(ctx, backend, prompt, execOpts, taskLog, task.ID, env.CodexHome, &msgSeq)
if err != nil {
return TaskResult{}, err
}
// retiredSessionID is the session this run was told to resume and then
// abandoned. Captured before the retry clears task.PriorSessionID, and
// reported on EVERY terminal path — the retry succeeding is exactly when
// the abandoned id would otherwise survive, unreferenced by this task's
// row but still reachable through an older completed row on the issue or
// through the chat_session pointer (GH #6066).
var retiredSessionID string
defer func() { taskResult.RetiredSessionID = retiredSessionID }()
if shouldRetryWithFreshSession(result, task.PriorSessionID, tools, provider) {
firstResult := result
firstUsage := result.Usage
firstTools := tools
if !result.ResumeRejectedTransient {
retiredSessionID = task.PriorSessionID
}
taskLog.Warn("session resume failed, retrying with fresh session", "error", result.Error)
// Rebuild cold-session context before the single retry. The prior
// provider transcript is gone (missing, account-mismatched, or —
// GH #5975 — carrying history the provider now refuses), so the
// fresh process must NOT be told it is resuming a conversation:
// - taskCtx.PriorSessionResumed=false + re-injecting the runtime
// brief rewrites the on-disk AGENTS.md so it no longer claims
// "You're resuming the prior session" (which file-based backends
// like Kiro load themselves).
// - clearing task.PriorSessionID rebuilds the prompt on the cold
// comment-reading path instead of the warm resumed one.
// - PriorSessionResumeUnavailable=true makes BuildPrompt append the
// continuity notice for this surface, so the agent knows not to
// assume continuity it no longer has. This is now the ONLY injector
// on the retry path: the backend's own copy is suppressed below,
// because before MUL-5722 both fired and the turn carried the same
// paragraph twice.
// task and taskCtx are local (runTask takes task by value), so these
// mutations only affect the retry.
execOpts.ResumeSessionID = ""
task.PriorSessionID = ""
task.PriorSessionResumeUnavailable = true
execOpts.ResumeContinuityNotice = ""
taskCtx.PriorSessionResumed = false
if freshBrief, briefErr := execenv.InjectRuntimeConfig(env.WorkDir, provider, taskCtx); briefErr != nil {
taskLog.Warn("execenv: re-inject cold runtime config for fresh retry failed (non-fatal)", "error", briefErr)
} else {
runtimeBrief = freshBrief
if providerNeedsInlineSystemPrompt(provider) {
execOpts.SystemPrompt = runtimeBrief
}
}
freshPrompt := BuildPrompt(task, provider, promptOptions...)
retryResult, retryTools, retryErr := d.executeAndDrain(ctx, backend, freshPrompt, execOpts, taskLog, task.ID, env.CodexHome, &msgSeq)
if retryErr != nil {
taskLog.Error("fresh session also failed to start; keeping the original poisoned result", "error", retryErr)
} else if retryResult.Status != "completed" && retryResult.SessionID == "" {
taskLog.Warn("fresh session retry also failed without establishing a new session; keeping the original poisoned result",
"retry_status", retryResult.Status,
"retry_error", retryResult.Error,
)
}
// The poisoned prior session id lives ONLY on firstResult (classified
// unrecoverable, so GetLastTaskSession excludes it). reconcile never
// grafts it onto the retry result: a retry that establishes a new
// session wins with its own id; a retry that fails without a new
// session keeps firstResult so the bad session stays excluded rather
// than being relabeled resumable by a benign-looking second error.
result, tools = reconcileFreshRetryResult(firstResult, firstUsage, firstTools, retryResult, retryTools, retryErr)
}
phaseRecorder.Mark(taskPhaseTurnCompleted)
elapsed := time.Since(taskStart).Round(time.Second)
taskLog.Info("agent finished",
"status", result.Status,
"duration", elapsed.String(),
"tools", tools,
)
taskLog.Debug("agent result detail",
"status", result.Status,
"output_bytes", len(result.Output),
"session_id", result.SessionID,
"models_with_usage", len(result.Usage),
"agent_error", result.Error,
)
// Convert agent usage map to task usage entries.
var usageEntries []TaskUsageEntry
for model, u := range result.Usage {
if u.InputTokens == 0 && u.OutputTokens == 0 && u.CacheReadTokens == 0 && u.CacheWriteTokens == 0 && u.CostUSDTicks <= 0 {
continue
}
usageEntries = append(usageEntries, TaskUsageEntry{
Provider: provider,
Model: model,
InputTokens: u.InputTokens,
OutputTokens: u.OutputTokens,
CacheReadTokens: u.CacheReadTokens,
CacheWriteTokens: u.CacheWriteTokens,
CostUSDTicks: u.CostUSDTicks,
})
}
// MUL-5305: withhold a Codex session whose rollout never reached the per-issue
// store, for ANY terminal state — including `completed`, since a completed
// turn whose rollout is missing is exactly the #5934 case and must not be
// recorded as a resume pointer the next follow-up would only drop. Blanking
// the id keeps GetLastTaskSession falling back to the last session whose
// rollout is real; SessionRolloutMissing tells the server to clear the row's
// session and record a continuity gap, so the next claim still discloses the
// loss (PriorSessionResumeUnavailable, MUL-4424 transparency) even while
// resuming that older good session. No-op for non-Codex providers
// (env.CodexHome == "") and when there is no session.
var sessionRolloutMissing bool
if result.SessionID != "" && !codexSessionResumable(env.CodexHome, result.SessionID, codexRolloutFlushWait) {
taskLog.Warn("codex session rollout not present in task CODEX_HOME; withholding resume pointer and flagging continuity gap",
"session_id", result.SessionID, "codex_home", env.CodexHome, "status", result.Status)
result.SessionID = ""
sessionRolloutMissing = true
}
// Stamp the withhold flag onto whichever TaskResult the status switch below
// returns (SessionID is already blanked above); reportTaskResult forwards it
// as session_rollout_missing on the terminal callback (MUL-5305).
defer func() { taskResult.SessionRolloutMissing = sessionRolloutMissing }()
switch result.Status {
case "completed":
if result.Output == "" {
// The agent completed successfully but produced no text output.
// This is valid — the agent may have done all its work via tool
// calls (e.g. posting comments via CLI, pushing code). Treat as
// a normal completion so the task is not incorrectly marked as
// blocked.
return TaskResult{
Status: "completed",
Comment: "",
SessionID: result.SessionID,
WorkDir: env.WorkDir,
EnvRoot: env.RootDir,
Usage: usageEntries,
}, nil
}
// Detect "poisoned" terminal output: the agent didn't reach a real
// conclusion but emitted a known fallback marker (iteration limit,
// fallback meta message). Route through the blocked path with a
// specific failure_reason so the server can exclude this session
// from the (agent_id, issue_id) resume lookup — otherwise a manual
// rerun would inherit the same poisoned session and reproduce the
// same bad output.
if reason, ok := classifyPoisonedOutput(result.Output); ok {
taskLog.Warn("agent finished with poisoned fallback output, classifying as blocked",
"failure_reason", reason,
)
return TaskResult{
Status: "blocked",
Comment: result.Output,
SessionID: result.SessionID,
WorkDir: env.WorkDir,
EnvRoot: env.RootDir,
Usage: usageEntries,
FailureReason: reason,
}, nil
}
taskResult = TaskResult{
Status: "completed",
Comment: result.Output,
SessionID: result.SessionID,
WorkDir: env.WorkDir,
EnvRoot: env.RootDir,
Usage: usageEntries,
}
return taskResult, nil
case "timeout":
// Surface session_id/work_dir so the chat resume pointer is kept
// in sync even when the agent times out after building a session.
// We mark as "blocked" (not a hard error return) so handleTask
// goes through the FailTask path that forwards session info.
comment := result.Error
if comment == "" {
comment = fmt.Sprintf("%s timed out after %s", provider, d.cfg.AgentTimeout)
}
failureReason := "timeout"
if reason, ok := classifyResumeUnsafeTimeout(provider, comment); ok {
taskLog.Warn("agent timed out with resume-unsafe session, classifying as blocked",
"failure_reason", reason,
)
failureReason = reason
}
return TaskResult{
Status: "blocked",
Comment: comment,
SessionID: result.SessionID,
WorkDir: env.WorkDir,
EnvRoot: env.RootDir,
FailureReason: failureReason,
Usage: usageEntries,
}, nil
case "idle_watchdog":
// The idle watchdog force-stopped the run because the backend
// went silent (e.g. claude blocked on a tool call against a
// frozen child process). Route through the blocked path with a
// dedicated failure_reason so the run leaves "running" state and
// operators can tell idle-stop apart from a real timeout.
comment := result.Error
if comment == "" {
comment = idleWatchdogReason(d.cfg.AgentIdleWatchdog)
}
return TaskResult{
Status: "blocked",
Comment: comment,
SessionID: result.SessionID,
WorkDir: env.WorkDir,
EnvRoot: env.RootDir,
FailureReason: "idle_watchdog",
Usage: usageEntries,
}, nil
case "cancelled":
// Server cancelled the task (e.g. issue reassignment, user cancel).
// handleTask's cancelledByPoll branch already discards this result,
// so this case is mainly defensive — and preserves the "cancelled"
// status string for the "agent finished" log line so operators can
// distinguish "task cancelled by server" from a real timeout.
return TaskResult{
Status: "cancelled",
Comment: "task cancelled by server",
SessionID: result.SessionID,
WorkDir: env.WorkDir,
EnvRoot: env.RootDir,
Usage: usageEntries,
}, nil
default:
errMsg := result.Error
if errMsg == "" {
errMsg = fmt.Sprintf("%s execution %s", provider, result.Status)
}
// Forward SessionID/WorkDir on the blocked path: backends commonly
// emit a real session_id before failing (rate-limit, tool error,
// model reject, …). Without this the chat_session resume pointer
// would either be left stale or overwritten with NULL on the
// server, causing the next chat turn to lose context.
//
// Classify upstream API 400 invalid_request_error failures with a
// dedicated failure_reason so GetLastTaskSession excludes the
// task from the (agent_id, issue_id) resume lookup. Without this
// classifier a corrupt image or oversized payload baked into the
// conversation permanently blocks the issue: every follow-up
// task resumes the same poisoned session and hits the same 400.
failureReason, _ := classifyPoisonedError(errMsg)
if failureReason == "" {
// A resume we could not read back leaves the same oversized thread
// recorded as this issue's resume pointer. Reaching here means the
// in-turn fresh-session retry did not save the run (it is gated on
// tools == 0, and can fail on its own), so classify it to keep the
// NEXT task off that thread rather than replaying the overflow
// forever (MUL-5722).
failureReason, _ = classifyResumeUnsafeTransport(provider, errMsg)
if failureReason != "" && retiredSessionID == "" && task.PriorSessionID != "" {
// Name the thread explicitly. The failure happens before the
// turn starts, so the backend has no session id to report and
// this row lands with session_id NULL — which means neither
// the reason above nor any error-text filter on this row can
// identify WHICH session to avoid. retired_session_id is the
// one channel that does not depend on the failed row carrying
// the session, and it is what the resume lookups and the chat
// pointer cleanup both key off.
//
// Belt-and-braces, not the live path: an overflowed resume
// fails before any tool runs, so shouldRetryWithFreshSession's
// tools == 0 gate is always satisfied and the retry above has
// already recorded the same id. This covers the case where a
// future condition stops the retry from firing, so the session
// is still retired rather than silently kept.
retiredSessionID = task.PriorSessionID
}
}
if failureReason != "" {
taskLog.Warn("agent failed with a resume-unsafe error, retiring the session",
"failure_reason", failureReason,
)
} else {
// MUL-2946: classifyPoisonedError only matches the
// session-poisoning Anthropic 400 shape. Everything else
// falls through to taskfailure.Classify, which maps the
// raw error string to one of the 14 agent_error.*
// sub-reasons (provider auth, capacity, context overflow,
// runner crash, …) or to ReasonAgentUnknown. This keeps
// the failure_reason column in the canonical refined
// taxonomy at write time instead of waiting on the
// MUL-1949 offline backfill to re-classify after the
// fact.
failureReason = taskfailure.Classify(errMsg).String()
}
// After the classifiers above have read errMsg. Each hint is fixed
// prose chosen to match none of the resume guards (see its const), so
// ordering is not what makes it safe — but it keeps the machine
// decisions reading exactly what the runtime reported, and leaves the
// annotations on the outside where a future edit is visibly a change
// to human-facing text rather than to classifier input. They are
// mutually exclusive by provider, so they cannot stack.
errMsg = annotateHermesProviderUnconfigured(errMsg, provider, env.HermesHome != "")
errMsg = annotateCodexRetiredCompaction(errMsg, provider)
return TaskResult{
Status: "blocked",
Comment: errMsg,
SessionID: result.SessionID,
WorkDir: env.WorkDir,
EnvRoot: env.RootDir,
Usage: usageEntries,
FailureReason: failureReason,
}, nil
}
}
// shouldRetryWithFreshSession reports whether a failed run that requested
// --resume should be retried once from a fresh session.
//
// Two independent questions have to both answer yes, and conflating them is
// how this went wrong before:
//
// 1. Would a fresh session even fix this? Only if the resume itself was
// refused — permanently because the transcript is gone or belongs to
// another provider account, or transiently because another live run owns
// it. result.ResumeRejected and result.ResumeRejectedTransient are the
// backend's positive evidence of those two cases. Both allow a fresh retry,
// but only the permanent signal retires the prior session from later
// lookups.
// Answering by exclusion alone would invert the burden of proof: the
// failures a new session cures are a small enumerable set, while the
// ones it cannot are open-ended. A network drop, a 429, a quota trip or
// a provider 5xx has nothing to do with the session, so resetting it
// discards the one recoverable thing — the conversation pointer — and
// re-runs the task for nothing. provider_network in particular is
// documented resume-safe in internal/service/task.go (retryableReasons,
// MUL-4910): the platform's own retry is supposed to inherit the session
// and continue the truncated conversation.
//
// Not every backend can answer, though, and a false ResumeRejected means
// different things depending on who produced it: "checked, not a
// rejection" from a capable backend, "could not tell" from one of the
// backends in agent.ResumeRejectionUndetectable. That is why provider is a
// parameter — without it the compatibility branch below would silently
// apply to every backend, second-guessing capable ones by exclusion.
//
// 2. Is re-running safe? Only if the agent executed no tool. tools == 0 does
// not prove the run mutated nothing — it proves we observed no tool use,
// which is the strongest signal available here — but it is what stands
// between a retry and a duplicated side effect. Comment creation has no
// idempotency key and a duplicate re-fires its @mention triggers; the
// retry also reuses the same workdir, which is never reset between
// attempts, so a retry after real work re-plans on top of its own
// half-finished commits.
func shouldRetryWithFreshSession(result agent.Result, priorSessionID string, tools int32, provider string) bool {
if result.Status != "failed" || priorSessionID == "" || tools > 0 {
return false
}
// Positive evidence: the backend proved the resume was refused.
if result.ResumeRejected || result.ResumeRejectedTransient {
return true
}
// Positive evidence of a different kind: the resume was NOT refused —
// the transcript loaded fine — and the provider then refused to replay
// it because a message in it is empty. ResumeRejected is false for every
// backend here precisely because nothing rejected the resume, which is
// why this needs its own branch rather than a phrase added to the
// rejection list.
//
// It applies to all 18 backends, not the ResumeRejectionUndetectable
// subset below, and that is deliberate: this is the one failure class
// where dropping the session is provably the fix without the backend
// having to detect anything. The evidence is in the provider's own error
// text, which the common layer already has. Leaving it to each adapter
// is how the same bug got fixed three times for Kiro, Kimi and Anthropic
// while every other backend stayed broken.
//
// The tools == 0 gate above still applies unchanged — a run that already
// used a tool is never re-run, poisoned history or not. Such a task still
// gets its session retired, just by classifyPoisonedError at report time
// rather than by an in-turn retry.
if taskfailure.UnresumableHistory(result.Error) {
return true
}
// Third form of positive evidence, and the same shape of argument: the
// resume was not refused — the runtime happily rebuilt the session — but
// the provider identity it rebuilt can no longer resolve its own
// credentials, so the turn dies with "Could not resolve authentication
// method" (GH #6777). The credentials are fine; only the session's copy of
// the provider is broken, which is precisely what a fresh session
// re-resolves from current config.
//
// This is the exception the Result.ResumeRejected doc calls out: adapters
// must NOT flag auth errors, because a genuine credential failure keeps the
// session so the platform's own retry can continue the conversation. The
// distinction is resume-vs-fresh, not the error text — and priorSessionID
// above already establishes that this run WAS a resume. On a cold run the
// same error means the config really is wrong and this gate never sees it.
//
// Deciding here rather than in each ACP adapter is what makes it correct
// for every step of the ACP lifecycle: the failure surfaces at
// session/resume, at session/set_model (a resumed session whose persisted
// provider was normalised gets a redundant set_model that re-routes to the
// wrong provider — MUL-5029) or at session/prompt, and only two of those
// three carry any resume-failure signal today. The final error text carries
// the phrase on all three.
//
// Worst case, the config genuinely is broken: the fresh attempt fails the
// same way, the user sees the same error once, and the single-retry budget
// bounds the cost. That is the same trade the branch above already makes.
if taskfailure.AuthMethodUnresolved(result.Error) {
return true
}
// Everything below is a bounded compatibility path for the backends that
// cannot answer question 1 at all. For every other backend a false
// ResumeRejected is a real answer — it checked and this was not a
// rejection — so the gate stops here rather than second-guessing it by
// exclusion.
if !agent.ResumeRejectionUndetectable(provider) {
return false
}
// antigravity, codearts, copilot, cursor, deveco and opencode scrape SessionID out
// of stream output and have no rejection string captured anywhere, so an
// empty SessionID is the only thing they can offer. It proves no session
// was established this run, which is exactly what the gate relied on for
// every backend before ResumeRejected existed; keeping it preserves their
// recovery instead of silently removing it.
//
// Inventing rejection phrases for these backends would be the alternative,
// and it is worse: no real output has been captured for any of them, and
// a false positive discards a recoverable session pointer.
return result.SessionID == "" && freshSessionMayHelp(result.Error)
}
// reconcileFreshRetryResult picks the authoritative result after the single
// fresh-session retry (see the retry block in runTask). Its one hard invariant:
// the poisoned prior session id — carried only on `first`, whose failure is
// classified unrecoverable so GetLastTaskSession excludes it — must NEVER be
// grafted onto the retry's result, or a later task would resume the bad
// session again and re-form the loop this fix exists to break (GH #5975 review).
//
// - retryErr != nil: the fresh attempt never produced a result. Keep `first`
// so the poisoned session stays recorded as unrecoverable.
// - retry established a new session id: it fully wins, carrying its OWN id.
// A benign retry failure on a real new session is fine to record — it is
// not the poisoned one.
// - retry completed without a session id (e.g. all work via tools): take it,
// but keep the id EMPTY. Never resurrect the poisoned id as a resumable
// success.
// - retry failed AND established no new session: keep `first`. Adopting the
// second result here is exactly the bug — a second error lacking the
// oversized-image markers would be classified resume-safe and the poisoned
// id (were it attached) would be re-selected. We keep the unrecoverable
// first result and only merge usage.
//
// Usage is merged across both attempts in every branch so billing is complete.
func reconcileFreshRetryResult(first agent.Result, firstUsage map[string]agent.TokenUsage, firstTools int32, retry agent.Result, retryTools int32, retryErr error) (agent.Result, int32) {
switch {
case retryErr != nil:
first.Usage = firstUsage
return first, firstTools
case retry.SessionID != "":
retry.Usage = mergeUsage(firstUsage, retry.Usage)
return retry, retryTools
case retry.Status == "completed":
retry.Usage = mergeUsage(firstUsage, retry.Usage)
return retry, retryTools
default:
first.Usage = mergeUsage(firstUsage, retry.Usage)
return first, firstTools
}
}
// freshSessionMayHelp reports whether restarting the conversation could
// plausibly fix errText. It answers "is this failure about the session at
// all?", so every reason with a defined non-session remedy — wait, back off,
// top up, re-auth, fix the config, install the binary — is excluded. Those
// keep the session pointer so the platform's own retry can resume the
// truncated conversation.
//
// What is left through is deliberately narrow: unknown, process failure and
// unparseable output. A real resume rejection from one of these backends most
// likely surfaces as exactly that — a non-zero exit or output we cannot
// parse — since none of them reports one explicitly. Context overflow is also
// allowed through, as starting over genuinely can clear it.
func freshSessionMayHelp(errText string) bool {
switch taskfailure.Classify(errText) {
case taskfailure.ReasonAgentProviderNetwork,
taskfailure.ReasonAgentProviderCapacityOrRateLimit,
taskfailure.ReasonAgentProviderQuotaLimit,
taskfailure.ReasonAgentProviderServerError,
taskfailure.ReasonAgentProviderAuthOrAccess,
taskfailure.ReasonAgentMissingConfig,
taskfailure.ReasonAgentModelNotFoundOrUnavailable,
taskfailure.ReasonAgentRuntimeMissingExecutable,
taskfailure.ReasonAgentRuntimeVersionUnsupported,
// Defensive: a timeout normally carries its own terminal status and
// never reaches this gate, but if one is ever classified out of a
// "failed" result, re-running the whole task is not the answer.
taskfailure.ReasonAgentTimeout:
return false
default:
return true
}
}
// executeAndDrain runs a backend, drains its message stream (forwarding to the
// server), and waits for the final result. msgSeq numbers the reported task
// messages and is owned by the caller so a same-task retry continues the
// sequence instead of restarting at 1 — the server orders the transcript by
// seq alone, and duplicate seqs would interleave the two attempts' rows.
func (d *Daemon) executeAndDrain(ctx context.Context, backend agent.Backend, prompt string, opts agent.ExecOptions, taskLog *slog.Logger, taskID, codexHome string, msgSeq *atomic.Int32) (agent.Result, int32, error) {
phaseRecorder := taskPhaseRecorderFromContext(ctx)
// Wrap the caller's ctx so the idle watchdog (below) can interrupt both
// the agent subprocess (via the ctx passed to backend.Execute) AND the
// drain loop with a single cancel. Without this layer the backend would
// stay tied to the parent ctx and our cancellation could only abort
// drain, leaving the subprocess running.
agentCtx, agentCancel := context.WithCancel(ctx)
defer agentCancel()
session, err := backend.Execute(agentCtx, prompt, opts)
if err != nil {
// One provider-agnostic boundary for launches: every backend's
// cmd.Start() failure arrives here, so diagnosing ENOEXEC at this point
// covers claude, opencode and any CLI added later without a wrap in
// each backend (MUL-6164).
err = agent.ExplainExecError(err)
taskLog.Debug("backend execute returned error", "error", err)
return agent.Result{}, 0, err
}
// This counter intentionally starts at the narrower provider-session
// boundary, not at the earlier server-side StartTask transition.
d.runningTasks.Add(1)
defer d.runningTasks.Add(-1)
phaseRecorder.Mark(taskPhaseRuntimeStarted)
taskLog.Debug("backend started, draining messages")
// Only negotiated sessions may claim additions. Stop and join delivery
// before the caller reports the task's terminal state to the server.
if opts.EnableTaskSupplement && session.Supplement != nil && session.SupplementReady != nil {
supplementCtx, cancelSupplements := context.WithCancel(agentCtx)
supplementsDone := make(chan struct{})
wakeup, unsubscribe := d.taskSupplementSignals.subscribe(taskID)
go func() {
defer unsubscribe()
defer close(supplementsDone)
d.runTaskSupplementLoop(supplementCtx, session, taskID, wakeup, taskLog)
}()
defer func() {
cancelSupplements()
<-supplementsDone
}()
}
// Bound the drain loop only when there is a wall-clock cap. With a positive
// opts.Timeout, give the drain a slightly longer deadline than the backend
// so it can still collect the backend's own timeout Result if the scanner
// is stuck on a hung stdout pipe (the extra 30 s covers cleanup after the
// backend's own deadline fires). With no cap (opts.Timeout <= 0) the
// inactivity watchdog is the only liveness net, so the drain must NOT
// impose its own deadline either — otherwise an actively streaming long run
// would be cut off here regardless of progress (MUL-3064).
var drainCtx context.Context
var drainCancel context.CancelFunc
if opts.Timeout > 0 {
drainCtx, drainCancel = context.WithTimeout(agentCtx, opts.Timeout+30*time.Second)
} else {
drainCtx, drainCancel = context.WithCancel(agentCtx)
}
defer drainCancel()
var toolCount atomic.Int32
// lastActivityAt records (as awakeclock nanos) when the drain loop most
// recently received a message from the backend. The idle watchdog
// reads this to decide whether the agent has gone silent for too long.
// It is an awake-time reading, not wall time, so a host suspend is never
// charged to the agent as silence (GitHub #9024).
// Initialise to the start so a backend that never emits a single
// message also trips the watchdog.
var lastActivityAt atomic.Int64
lastActivityAt.Store(int64(awakeclock.Now()))
// inFlightTools counts tool_use messages that haven't yet been paired
// with a matching tool_result. A non-zero count means the agent is
// legitimately waiting on a tool (e.g. `npm install`, `docker build`)
// that may run far longer than the idle window without emitting any
// message — so while a tool is in flight the watchdog applies the larger
// AgentToolWatchdog budget instead of treating that silence as a hang.
var inFlightTools atomic.Int32
var idleWatchdogFired atomic.Bool
// idleWatchdogThreshold records (as nanos) which silence budget actually
// tripped the watchdog — the idle window or the larger in-flight-tool
// window — so the failure message reports the real duration.
idleWindow := d.cfg.AgentIdleWatchdog
// A provider may opt into a shorter per-run no-message budget. The global
// zero remains authoritative so MULTICA_AGENT_IDLE_WATCHDOG=0 still disables
// the entire watchdog suite. Tool calls continue to use AgentToolWatchdog.
if idleWindow > 0 && opts.IdleWatchdogTimeout > 0 && opts.IdleWatchdogTimeout < idleWindow {
idleWindow = opts.IdleWatchdogTimeout
}
var idleWatchdogThreshold atomic.Int64
idleWatchdogThreshold.Store(int64(idleWindow))
watchdogToolCount := inFlightTools.Load
if session.ToolActivity != nil {
watchdogToolCount = func() int32 {
count, at := session.ToolActivity()
for {
previous := lastActivityAt.Load()
if int64(at) <= previous || lastActivityAt.CompareAndSwap(previous, int64(at)) {
break
}
}
return count
}
}
// A backend that can prove its outcome is already decided outranks every
// liveness policy below: a run whose terminal result has been read is not a
// hang, no matter how long its cleanup then takes.
// Nil is meaningful and kept distinguishable: a backend that offers no
// terminal boundary is one whose result cannot outrank a force stop, so it
// must not be given a hand-off window it can never use. Every such backend
// keeps the previous behaviour, including a wedged one, which is force
// stopped and classified without waiting for anything.
handsOverTerminal := session.TerminalObserved != nil
terminalObserved := session.TerminalObserved
if terminalObserved == nil {
terminalObserved = func() bool { return false }
}
watchdogCtx, stopWatchdog := context.WithCancel(agentCtx)
defer stopWatchdog()
if idleWindow > 0 {
go d.runIdleWatchdog(watchdogCtx, idleWindow, d.cfg.AgentToolWatchdog, &lastActivityAt, watchdogToolCount, &idleWatchdogFired, &idleWatchdogThreshold, agentCancel, session.Messages, session.InterruptBackgroundTools, terminalObserved, taskLog)
}
// drainFinished closes after the drain goroutine has flushed the last
// message batch, so the result hand-off below can wait for the transcript
// tail to be persisted.
drainFinished := make(chan struct{})
go func() {
defer close(drainFinished)
var mu sync.Mutex
var pendingContent strings.Builder
var pendingType string
var pendingAt time.Time
var batch []TaskMessageData
callIDToTool := map[string]string{}
// Provider IDs can restart on a same-task retry (for example item_0).
// Allocate opaque transcript IDs per execution, including orphan results,
// so neither a retry nor a missing call can steal another call's result.
transcriptCallIDs := map[string]string{}
transcriptCallID := func(providerID string) string {
if providerID == "" {
return ""
}
if id, ok := transcriptCallIDs[providerID]; ok {
return id
}
id := uuid.NewString()
transcriptCallIDs[providerID] = id
return id
}
// sealPendingLocked turns the current contiguous text/thinking frame
// into a sequenced row. Callers hold mu so a ticker flush cannot assign
// a later seq between sealing the frame and appending the event that
// followed it.
sealPendingLocked := func() {
if pendingContent.Len() == 0 {
return
}
s := msgSeq.Add(1)
batch = append(batch, TaskMessageData{
Seq: int(s),
Type: pendingType,
Content: pendingContent.String(),
CreatedAt: pendingAt,
})
pendingContent.Reset()
pendingType = ""
pendingAt = time.Time{}
}
appendPending := func(messageType, content string, observedAt time.Time) {
if content == "" {
return
}
mu.Lock()
defer mu.Unlock()
if pendingType != "" && pendingType != messageType {
sealPendingLocked()
}
if pendingContent.Len() == 0 {
pendingType = messageType
pendingAt = observedAt
}
pendingContent.WriteString(content)
}
flush := func() {
mu.Lock()
sealPendingLocked()
toSend := batch
batch = nil
mu.Unlock()
if len(toSend) > 0 {
sendCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
if err := d.client.ReportTaskMessages(sendCtx, taskID, toSend); err != nil {
taskLog.Debug("failed to report task messages", "error", err)
} else {
taskLog.Debug("reported task messages", "count", len(toSend), "last_seq", toSend[len(toSend)-1].Seq)
}
cancel()
}
}
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
done := make(chan struct{})
tickerDone := make(chan struct{})
firstVisible := make(chan struct{}, 1)
go func() {
defer close(tickerDone)
for {
select {
case <-ticker.C:
flush()
case <-firstVisible:
flush()
case <-done:
return
}
}
}()
// The periodic flush bounds request rate for the rest of the transcript,
// but making the first visible event wait for its next 500 ms edge adds
// pure presentation latency. Signal at most once per execution; a buffered
// channel keeps the drain loop non-blocking while the reporter is busy.
var firstVisibleOnce sync.Once
flushFirstVisible := func() {
firstVisibleOnce.Do(func() {
firstVisible <- struct{}{}
})
}
var sessionPinned atomic.Bool
for {
select {
case msg, ok := <-session.Messages:
if !ok {
goto drainDone
}
if isTaskOutputReceived(msg) {
phaseRecorder.Mark(taskPhaseFirstOutputReceived)
}
if isTaskToolUse(msg) {
phaseRecorder.Mark(taskPhaseFirstToolUse)
}
// Stamp activity as soon as a message lands. The idle
// watchdog reads this to decide whether the backend has
// gone silent — stamping before processing makes sure a
// slow downstream call (mu.Lock contention, batch resize)
// can't be misattributed to backend silence.
observedAt := time.Now().UTC()
lastActivityAt.Store(int64(awakeclock.Now()))
switch msg.Type {
case agent.MessageStatus:
// Persist the session/work_dir as soon as the backend
// reveals them. Without this, a daemon crash mid-run
// loses the resume pointer and the auto-retry fires
// without context.
// MUL-5305: pin the resume pointer only once the session's
// rollout is actually in the store, so a crash-recovery pointer
// the daemon cannot resume never poisons the next follow-up
// (FailAgentTask keeps the pinned session_id via COALESCE, so a
// bad mid-flight pin survives a later terminal failure). Codex
// reveals the session id on a single task_started status, so a
// background waiter polls for the rollout for the life of the
// run and pins the moment it lands — a rollout that flushes
// after this status is still pinned in-flight (crash recovery
// preserved), while a session whose rollout never lands is never
// pinned. The terminal report is the authoritative writer.
// Non-Codex providers (codexHome == "") pin immediately.
if msg.SessionID != "" && !sessionPinned.Swap(true) {
sid := msg.SessionID
wd := opts.Cwd
go func() {
if !waitCodexRolloutPresent(drainCtx, codexHome, sid) {
taskLog.Debug("skip pinning codex session: rollout not present before run ended",
"session_id", sid, "codex_home", codexHome)
return
}
pinCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := d.client.PinTaskSession(pinCtx, taskID, sid, wd); err != nil {
taskLog.Debug("pin session failed", "error", err)
}
}()
}
case agent.MessageToolUse:
n := toolCount.Add(1)
inFlightTools.Add(1)
taskLog.Info(fmt.Sprintf("tool #%d: %s", n, msg.Tool))
mu.Lock()
sealPendingLocked()
if msg.CallID != "" {
callIDToTool[msg.CallID] = msg.Tool
}
s := msgSeq.Add(1)
batch = append(batch, TaskMessageData{
Seq: int(s),
Type: "tool_use",
CallID: transcriptCallID(msg.CallID),
Tool: msg.Tool,
CreatedAt: observedAt,
// Redact before the payload leaves this process, not
// only on arrival. The server redacts again in its
// ingest handler, but that is the *remote* side: a
// daemon that self-updated ahead of the server — or one
// talking to a server mid-rollout — would otherwise ship
// whole-file edit contents (a deleted .env, a patched
// credential) to a peer that does not scrub nested
// values yet. Deployment order is not a control we
// have, so this side has to be safe on its own.
Input: redact.InputMap(msg.Input),
})
mu.Unlock()
flushFirstVisible()
case agent.MessageToolResult:
// Decrement only when the count would stay >= 0. A stray
// tool_result with no matching tool_use (backend bug or
// reconnect mid-stream) shouldn't push the counter
// negative — that would re-arm the watchdog one tool_use
// too early on the next call.
for {
cur := inFlightTools.Load()
if cur <= 0 {
break
}
if inFlightTools.CompareAndSwap(cur, cur-1) {
break
}
}
output, outputTruncated := toolOutputPreview(msg.Output)
mu.Lock()
sealPendingLocked()
toolName := msg.Tool
if toolName == "" && msg.CallID != "" {
toolName = callIDToTool[msg.CallID]
}
s := msgSeq.Add(1)
taskLog.Info("tool_result observed", "seq", s, "tool", toolName, "call_id", msg.CallID)
batch = append(batch, TaskMessageData{
Seq: int(s),
Type: "tool_result",
CallID: transcriptCallID(msg.CallID),
Tool: toolName,
Output: output,
CreatedAt: observedAt,
// Always sent, including false: the reader has to be
// able to tell "this record is complete" from "this
// record predates the flag", and only a daemon that
// measured the output can say the former.
OutputTruncated: &outputTruncated,
})
mu.Unlock()
flushFirstVisible()
case agent.MessageThinking:
appendPending("thinking", msg.Content, observedAt)
if msg.Content != "" {
flushFirstVisible()
}
case agent.MessageText:
if msg.Content != "" {
taskLog.Debug("agent", "text", truncateLog(msg.Content, 200))
}
appendPending("text", msg.Content, observedAt)
if msg.Content != "" {
flushFirstVisible()
}
case agent.MessageError:
taskLog.Error("agent error", "content", msg.Content)
mu.Lock()
sealPendingLocked()
s := msgSeq.Add(1)
batch = append(batch, TaskMessageData{
Seq: int(s),
Type: "error",
Content: msg.Content,
CreatedAt: observedAt,
})
mu.Unlock()
flushFirstVisible()
}
case <-drainCtx.Done():
goto drainDone
}
}
drainDone:
close(done)
// Let any tick-driven flush finish before the final one: a flush still
// in flight would otherwise keep posting batches after this goroutine
// signalled that the transcript tail was persisted.
<-tickerDone
flush()
}()
// waitForDrain blocks until the drain goroutine has flushed the transcript
// tail, so every terminal return below hands control back only after the
// task's reported messages are persisted — a consumer reading them at the
// terminal transition would otherwise see a transcript that is non-empty
// but truncated, indistinguishable from a complete one. Bounded so a
// backend that never closes its message channel cannot stall the terminal
// transition: after 10s the drain loop is cancelled and given a window
// wide enough for its worst-case exit — an in-flight tick flush plus the
// final one, each capped by the 5s ReportTaskMessages timeout and neither
// interruptible by the cancel (they post on context.Background()).
waitForDrain := func() {
select {
case <-drainFinished:
case <-time.After(10 * time.Second):
drainCancel()
select {
case <-drainFinished:
case <-time.After(12 * time.Second):
taskLog.Warn("transcript drain did not stop after cancel; completing anyway")
}
}
}
// awaitTerminalResult gives a backend that advertises an authoritative
// terminal boundary one bounded chance to hand over its result after a
// cancellation won the outer select. Result delivery is the linearization
// point: TerminalObserved must be published before that send, so checking it
// afterwards preserves a provider outcome without racing a flag read. A
// delivered non-authoritative result is still returned to the idle-watchdog
// caller for re-tagging; ordinary upstream cancellation deliberately ignores
// it and keeps the existing generic cancelled disposition.
awaitTerminalResult := func(trigger string) (result agent.Result, delivered, authoritative bool) {
if !handsOverTerminal {
return agent.Result{}, false, false
}
if trigger == "idle_watchdog" {
// Keep this event stable: besides operator diagnostics, the terminal
// race regression uses it as the hand-off linearization probe.
taskLog.Info("idle watchdog fired; waiting for the backend to hand over its result",
"budget", terminalResultHandoffBudget.String())
} else {
taskLog.Info("waiting for the backend to hand over its result after cancellation",
"trigger", trigger,
"budget", terminalResultHandoffBudget.String())
}
timer := time.NewTimer(terminalResultHandoffBudget)
defer timer.Stop()
select {
case result, ok := <-session.Result:
if !ok {
return agent.Result{}, false, false
}
return result, true, terminalObserved()
case <-timer.C:
if trigger == "idle_watchdog" {
taskLog.Warn("backend did not hand over a result within the budget; classifying by liveness",
"budget", terminalResultHandoffBudget.String())
} else {
taskLog.Warn("backend did not hand over a result within the budget; classifying by cancellation trigger",
"trigger", trigger,
"budget", terminalResultHandoffBudget.String())
}
return agent.Result{}, false, false
}
}
select {
case result := <-session.Result:
stopWatchdog()
waitForDrain()
// terminalObserved outranks a watchdog that fired anyway: if the backend
// had already read its authoritative result, this is the real outcome and
// re-tagging it would report a completed run as a hang.
if idleWatchdogFired.Load() && !terminalObserved() {
// The backend's wait goroutine (e.g. claude.go) translates the
// SIGKILL we delivered via agentCancel into Status="aborted".
// Re-tag it as "idle_watchdog" so runTask routes the
// disposition through a dedicated failure_reason, not the
// generic "agent_error" bucket the aborted path falls into.
result.Status = "idle_watchdog"
if result.Error == "" {
result.Error = idleWatchdogReason(time.Duration(idleWatchdogThreshold.Load()))
}
}
return result, toolCount.Load(), nil
case <-drainCtx.Done():
// The drain loop is exiting on this same Done signal; wait for its
// final flush so the timeout/watchdog/cancel terminals below cannot
// hand back (and let runTask fail-and-broadcast) a still-flushing
// transcript either.
waitForDrain()
// Idle watchdog cancels via agentCancel(), which propagates here as
// context.Canceled. Check this BEFORE the generic cancelled/timeout
// classifiers so a watchdog-induced stop isn't misreported as
// "task cancelled by server".
if idleWatchdogFired.Load() {
// For a backend that publishes a terminal boundary, enter the
// hand-off without asking terminalObserved first. Reading a flag and
// then acting on it is exactly the window this branch kept losing:
// the backend can publish between the read and the classifier below.
// Waiting for the result instead makes its delivery the
// linearization point, and the backend contract — publish the
// observation before sending Result — is what makes the check after
// delivery reliable rather than lucky.
//
// Such a backend always closes Result, so a wedged one still ends
// this wait promptly through the closed channel rather than the
// budget.
if result, delivered, authoritative := awaitTerminalResult("idle_watchdog"); authoritative {
// The backend had already read its authoritative result, so
// this is the real outcome, not a hang.
return result, toolCount.Load(), nil
} else if delivered {
// The backend's wait goroutine (e.g. claude.go) translates the
// SIGKILL we delivered via agentCancel into Status="aborted".
// Re-tag it so runTask routes the disposition through the
// dedicated liveness failure_reason.
result.Status = "idle_watchdog"
if result.Error == "" {
result.Error = idleWatchdogReason(time.Duration(idleWatchdogThreshold.Load()))
}
return result, toolCount.Load(), nil
}
return agent.Result{
Status: "idle_watchdog",
Error: idleWatchdogReason(time.Duration(idleWatchdogThreshold.Load())),
}, toolCount.Load(), nil
}
// Distinguish external cancellation (e.g. server-initiated cancel
// because the issue was reassigned, or the user invoked CancelTask)
// from genuine drain-deadline timeouts. context.Canceled means the
// upstream runCtx fired runCancel(); context.DeadlineExceeded is the
// drain deadline expiring on its own.
if errors.Is(drainCtx.Err(), context.Canceled) {
if result, _, authoritative := awaitTerminalResult("upstream_context"); authoritative {
return result, toolCount.Load(), nil
}
return agent.Result{
Status: "cancelled",
Error: "task cancelled by upstream context (server cancel or daemon shutdown)",
}, toolCount.Load(), nil
}
return agent.Result{
Status: "timeout",
Error: "agent did not produce result within drain timeout",
}, toolCount.Load(), nil
}
}
// terminalResultHandoffBudget is how long executeAndDrain waits, after force-
// stopping a run, for the backend to hand over whatever result it has. It only
// decides how long we believe a backend before falling back to the liveness
// verdict; the backend caps its own finalization, so in practice the wait ends
// far sooner.
//
// The value is derived from the slowest finalization this daemon drives today,
// Cursor's, rather than picked: a concurrent background-cleanup pass we may have
// to wait behind (cursorCloseBudget, 10s), the closing pass itself (another
// 10s), one already-started process termination per pass overshooting its
// budget by that termination's own bound (~1s each), and the process WaitDelay
// after cancellation (0.5s) — about 22.5s. 30s leaves margin without letting a
// wedged backend hold a runtime slot indefinitely.
//
// Deliberately not a term: the background reaper's tick. Closing its stop
// channel wakes it immediately rather than at the next tick, so it adds
// nothing to this ceiling.
const terminalResultHandoffBudget = 30 * time.Second
// idleWatchdogReason formats the human-facing explanation surfaced on
// idle_watchdog dispositions. Centralised so the result-arrival branch and the
// drain-timeout branch in executeAndDrain emit identical wording.
func idleWatchdogReason(window time.Duration) string {
return fmt.Sprintf("agent produced no new messages for %s and message queue was empty; force-stopped by idle watchdog", window)
}
// idleWatchdogTickInterval picks how often the idle watchdog re-checks the
// silence budget. Half the window is the base rate, capped at
// idleWatchdogMaxTick so a run is force-stopped within window + tick rather
// than window * 1.5. Sub-nanosecond halves fall back to the window itself so
// tests can pass tiny budgets and still get a valid ticker.
//
// There used to be a `window >= time.Minute && interval < 30*time.Second` floor
// here, meant to keep production polling no faster than 30 s. It was
// unreachable — window >= 1 min implies window/2 >= 30 s — and its only effect
// was to make the tests around it read as if a floor were being exercised.
// Production windows are minutes or hours, so window/2 already clears 30 s.
func idleWatchdogTickInterval(window time.Duration) time.Duration {
interval := window / 2
if interval > idleWatchdogMaxTick {
interval = idleWatchdogMaxTick
}
if interval <= 0 {
interval = window
}
return interval
}
// runIdleWatchdog ticks until either agentCtx is cancelled or the backend has
// been silent past the applicable budget. On firing, it records the tripped
// threshold, sets fired, and calls cancel, which propagates to the agent
// subprocess (via the ctx passed to backend.Execute) and to drainCtx. The
// silence budget depends on whether a tool call is in flight:
//
// 1. No tool in flight — a silent backend is a hang after `window`.
// 2. A tool in flight (tool_use with no matching tool_result yet) —
// `toolWindow` applies instead. It defaults to `window`, so the two are
// normally identical and this branch only changes which duration the
// failure message reports; an operator who deliberately sets
// MULTICA_AGENT_TOOL_WATCHDOG higher buys long tools extra room, and
// toolWindow <= 0 keeps the historical behavior of never force-stopping
// while a tool is in flight. Without this in-flight budget a backend that
// emits tool_use and never the matching tool_result would run forever now
// that there is no wall-clock cap (MUL-3064).
//
// In both cases the watchdog also requires the session.Messages buffer to be
// empty — a buffered-but-undrained message means the drain loop is behind, not
// the backend.
//
// Polling rate comes from idleWatchdogTickInterval, so a run is force-stopped
// somewhere between its budget and budget + tick, never earlier.
//
// Budgets are measured on awakeclock, so they count only time the host was
// awake. lastActivityAt and every native tool timestamp are awakeclock
// readings. A laptop that sleeps through a run resumes with its silence
// exactly where it was before the lid closed (GitHub #9024).
func (d *Daemon) runIdleWatchdog(agentCtx context.Context, window, toolWindow time.Duration, lastActivityAt *atomic.Int64, inFlightTools func() int32, fired *atomic.Bool, firedThreshold *atomic.Int64, cancel context.CancelFunc, messages <-chan agent.Message, interruptBackground func() bool, terminalObserved func() bool, taskLog *slog.Logger) {
d.runIdleWatchdogWithClock(agentCtx, window, toolWindow, lastActivityAt, inFlightTools, fired, firedThreshold, cancel, messages, interruptBackground, terminalObserved, taskLog, awakeclock.Now)
}
// runIdleWatchdogWithClock is runIdleWatchdog with the awake clock injected,
// so tests can simulate a host suspend: wall time that passes while the
// awake reading stands still.
func (d *Daemon) runIdleWatchdogWithClock(agentCtx context.Context, window, toolWindow time.Duration, lastActivityAt *atomic.Int64, inFlightTools func() int32, fired *atomic.Bool, firedThreshold *atomic.Int64, cancel context.CancelFunc, messages <-chan agent.Message, interruptBackground func() bool, terminalObserved func() bool, taskLog *slog.Logger, awakeNow func() time.Duration) {
tickWindow := window
if toolWindow > 0 && toolWindow < tickWindow {
tickWindow = toolWindow
}
ticker := time.NewTicker(idleWatchdogTickInterval(tickWindow))
defer ticker.Stop()
for {
select {
case <-agentCtx.Done():
return
case <-ticker.C:
// Pick the silence budget. A tool in flight is expected to be
// silent (a long build/install/test emits nothing between
// tool_use and tool_result), so it gets the larger toolWindow;
// toolWindow <= 0 disables the in-flight bound entirely.
threshold := window
toolInFlight := inFlightTools() > 0
if toolInFlight {
if toolWindow <= 0 {
continue
}
threshold = toolWindow
}
last := lastActivityAt.Load()
idleFor := awakeNow() - time.Duration(last)
if idleFor < threshold {
continue
}
// A buffered-but-undrained message means the drain loop is
// behind, not the backend. Wait one more tick rather than
// killing a backend that is still producing output.
if len(messages) > 0 {
continue
}
// Cursor can stop its owned background tools without aborting the
// agent. The same watchdog owns both the budget and this recovery,
// so no competing timer can cancel Cursor while it reports a result.
if agentCtx.Err() != nil {
return
}
if terminalObserved != nil && terminalObserved() {
return
}
if toolInFlight && interruptBackground != nil && interruptBackground() {
lastActivityAt.Store(int64(awakeNow()))
taskLog.Info("tool watchdog stopped background tools; waiting for agent result")
continue
}
// A natural tool completion may race the callback or the tick.
if agentCtx.Err() != nil {
return
}
// Refresh native tool activity BEFORE reading its timestamp. The
// callback may publish newer activity without changing the count.
currentToolInFlight := inFlightTools() > 0
currentActivity := lastActivityAt.Load()
if currentActivity != last ||
currentToolInFlight != toolInFlight || len(messages) > 0 {
continue
}
if agentCtx.Err() != nil {
return
}
// Last gate before force-stopping. The terminal result can land while
// this tick is deciding — including while it is blocked inside the
// interrupt callback above — and a decided outcome is never a hang.
if terminalObserved != nil && terminalObserved() {
return
}
// No "task" field here: taskLog already carries the full id.
taskLog.Warn("idle watchdog firing: no agent activity, force-stopping run",
"idle_for", idleFor.Round(time.Second).String(),
"threshold", threshold.String(),
"tool_in_flight", toolInFlight,
)
firedThreshold.Store(int64(threshold))
fired.Store(true)
cancel()
return
}
}
}
func mergeUsage(a, b map[string]agent.TokenUsage) map[string]agent.TokenUsage {
if len(a) == 0 {
return b
}
if len(b) == 0 {
return a
}
merged := make(map[string]agent.TokenUsage, len(a)+len(b))
for model, u := range a {
merged[model] = u
}
for model, u := range b {
existing := merged[model]
existing.InputTokens += u.InputTokens
existing.OutputTokens += u.OutputTokens
existing.CacheReadTokens += u.CacheReadTokens
existing.CacheWriteTokens += u.CacheWriteTokens
existing.CostUSDTicks += u.CostUSDTicks
merged[model] = existing
}
return merged
}
// repoDataToInfo converts daemon RepoData to repocache RepoInfo.
func repoDataToInfo(repos []RepoData) []repocache.RepoInfo {
info := make([]repocache.RepoInfo, len(repos))
for i, r := range repos {
info[i] = repocache.RepoInfo{URL: r.URL}
}
return info
}
func convertIssueStatusesForEnv(statuses []IssueStatusData) []execenv.IssueStatusForEnv {
if len(statuses) == 0 {
return nil
}
result := make([]execenv.IssueStatusForEnv, len(statuses))
for i, s := range statuses {
result[i] = execenv.IssueStatusForEnv{Key: s.Key, Name: s.Name, Category: s.Category, Description: s.Description}
}
return result
}
func convertReposForEnv(repos []RepoData) []execenv.RepoContextForEnv {
if len(repos) == 0 {
return nil
}
result := make([]execenv.RepoContextForEnv, len(repos))
for i, r := range repos {
result[i] = execenv.RepoContextForEnv{URL: r.URL, Description: r.Description, Ref: r.Ref}
}
return result
}
func convertProjectResourcesForEnv(resources []ProjectResourceData) []execenv.ProjectResourceForEnv {
if len(resources) == 0 {
return nil
}
result := make([]execenv.ProjectResourceForEnv, len(resources))
for i, r := range resources {
result[i] = execenv.ProjectResourceForEnv{
ID: r.ID,
ResourceType: r.ResourceType,
ResourceRef: r.ResourceRef,
Label: r.Label,
}
}
return result
}
// markActiveEnvRoot records that a task is currently using the given env root,
// so the GC loop won't reclaim its artifacts mid-execution. Calls are
// reference-counted so a reuse path marked twice (predicted + prior) only
// becomes inactive after both unmark calls.
func (d *Daemon) markActiveEnvRoot(envRoot string) {
if envRoot == "" {
return
}
d.activeEnvRootsMu.Lock()
defer d.activeEnvRootsMu.Unlock()
d.ensureActiveEnvRootStateLocked()
for d.deletingEnvRoots[envRoot] {
d.activeEnvRootsCond.Wait()
}
d.activeEnvRoots[envRoot]++
}
func (d *Daemon) unmarkActiveEnvRoot(envRoot string) {
if envRoot == "" {
return
}
d.activeEnvRootsMu.Lock()
defer d.activeEnvRootsMu.Unlock()
d.ensureActiveEnvRootStateLocked()
if d.activeEnvRoots[envRoot] <= 1 {
delete(d.activeEnvRoots, envRoot)
return
}
d.activeEnvRoots[envRoot]--
}
func (d *Daemon) isActiveEnvRoot(envRoot string) bool {
d.activeEnvRootsMu.Lock()
defer d.activeEnvRootsMu.Unlock()
d.ensureActiveEnvRootStateLocked()
return d.activeEnvRoots[envRoot] > 0
}
func (d *Daemon) ensureActiveEnvRootStateLocked() {
if d.activeEnvRoots == nil {
d.activeEnvRoots = make(map[string]int)
}
if d.deletingEnvRoots == nil {
d.deletingEnvRoots = make(map[string]bool)
}
if d.activeEnvRootsCond == nil {
d.activeEnvRootsCond = sync.NewCond(&d.activeEnvRootsMu)
}
}
// reserveEnvRootForGC atomically confirms that no live task is using envRoot
// and prevents a new task from entering until release runs. This closes the
// check-then-remove race between the GC loop and task startup: either GC sees
// the active task and skips, or task startup waits for the mutation to finish
// and recreates/uses the post-GC environment.
func (d *Daemon) reserveEnvRootForGC(envRoot string) (release func(), ok bool) {
if envRoot == "" {
return nil, false
}
d.activeEnvRootsMu.Lock()
defer d.activeEnvRootsMu.Unlock()
d.ensureActiveEnvRootStateLocked()
if d.activeEnvRoots[envRoot] > 0 || d.deletingEnvRoots[envRoot] {
return nil, false
}
d.deletingEnvRoots[envRoot] = true
return func() {
d.activeEnvRootsMu.Lock()
delete(d.deletingEnvRoots, envRoot)
d.activeEnvRootsCond.Broadcast()
d.activeEnvRootsMu.Unlock()
}, true
}
// markActiveStore records that a task is about to use the given persistent
// store — a per-issue Codex session store or a per-agent Hermes memory store —
// so the GC never reclaims it mid-task. These stores live outside the env root,
// so isActiveEnvRoot does not cover them (MUL-4424). If a GC
// delete has already reserved this store, we wait for that removal to finish
// before claiming it, so a task never mounts a store mid-removal; the store is
// then recreated fresh by Prepare. Reference-counted like the env-root guard.
func (d *Daemon) markActiveStore(store string) {
if store == "" {
return
}
d.activeStoresMu.Lock()
defer d.activeStoresMu.Unlock()
for d.deletingStores[store] {
d.activeStoresCond.Wait()
}
d.activeStores[store]++
}
func (d *Daemon) unmarkActiveStore(store string) {
if store == "" {
return
}
d.activeStoresMu.Lock()
defer d.activeStoresMu.Unlock()
if d.activeStores[store] <= 1 {
delete(d.activeStores, store)
return
}
d.activeStores[store]--
}
// reserveStoreForDeletion atomically checks that no live task holds store
// and, if so, marks it reserved so no task can claim it until the caller runs
// the returned commit (after the actual removal). ok=false means a task holds it
// — do not delete. This is the exclusive protocol the store pruners
// (PruneCodexSessionStores, PruneHermesMemoryStores) need:
// the "confirm inactive" and the mark happen under one lock acquisition, so a
// markActiveStore either loses the check (store stays) or blocks on the
// reservation, closing the stat->remove race (MUL-4424).
func (d *Daemon) reserveStoreForDeletion(store string) (commit func(), ok bool) {
d.activeStoresMu.Lock()
defer d.activeStoresMu.Unlock()
if d.activeStores[store] > 0 || d.deletingStores[store] {
return nil, false
}
d.deletingStores[store] = true
return func() {
d.activeStoresMu.Lock()
delete(d.deletingStores, store)
d.activeStoresCond.Broadcast()
d.activeStoresMu.Unlock()
}, true
}
// shortID returns the first 8 characters of an ID for a human-facing label.
//
// Display only, and only where the label is read rather than joined: the
// waiting-reason string the UI renders as "Waiting for {reason}" is the one
// caller left. Structured log fields must carry the FULL id — see the
// task-scoped logger in handleTask for why a prefix cannot identify a task.
func shortID(id string) string {
if len(id) <= 8 {
return id
}
return id[:8]
}
// truncateLog truncates a string to maxLen, appending "…" if truncated.
// Also collapses newlines to spaces for single-line log output.
func truncateLog(s string, maxLen int) string {
s = strings.ReplaceAll(s, "\n", " ")
s = strings.TrimSpace(s)
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "…"
}
func convertSkillsForEnv(skills []SkillData) []execenv.SkillContextForEnv {
if len(skills) == 0 {
return nil
}
result := make([]execenv.SkillContextForEnv, len(skills))
for i, s := range skills {
result[i] = execenv.SkillContextForEnv{
Name: s.Name,
Description: s.Description,
Content: s.Content,
}
for _, f := range s.Files {
result[i].Files = append(result[i].Files, execenv.SkillFileContextForEnv{
Path: f.Path,
Content: f.Content,
})
}
}
return result
}
func convertDisabledRuntimeSkillsForEnv(agentData *AgentData, runtimeID, provider string) []execenv.RuntimeSkillRefForEnv {
if agentData == nil || len(agentData.DisabledRuntimeSkills) == 0 {
return nil
}
result := make([]execenv.RuntimeSkillRefForEnv, 0, len(agentData.DisabledRuntimeSkills))
for _, skill := range agentData.DisabledRuntimeSkills {
if skill.RuntimeID != runtimeID || skill.Provider != provider {
continue
}
result = append(result, execenv.RuntimeSkillRefForEnv{
Root: skill.Root,
Key: skill.Key,
Name: skill.Name,
Plugin: skill.Plugin,
})
}
return result
}
// composeOpenclawIncludeRoots returns the value the daemon should set for
// OPENCLAW_INCLUDE_ROOTS on the child openclaw process so its `$include`
// loader will follow the wrapper's reference out of envRoot into the
// user's active config directory.
//
// addRoot is the directory we must grant (typically dirname of the user's
// active openclaw.json). userValue is whatever the daemon's own
// environment already has under OPENCLAW_INCLUDE_ROOTS — the user's own
// cross-directory layout. We prepend addRoot, dedupe by string equality,
// drop empty path segments, and return ok=false when there's nothing to
// grant (addRoot is empty — fresh install case), so callers can leave the
// env var alone in that case.
//
// Path separator is the OS-native list separator (`:` on Unix, `;` on
// Windows) to match how OpenClaw splits the env var.
func composeOpenclawIncludeRoots(addRoot, userValue string) (string, bool) {
if addRoot == "" {
return "", false
}
parts := []string{addRoot}
seen := map[string]struct{}{addRoot: {}}
for _, p := range strings.Split(userValue, string(os.PathListSeparator)) {
if p == "" {
continue
}
if _, dup := seen[p]; dup {
continue
}
seen[p] = struct{}{}
parts = append(parts, p)
}
return strings.Join(parts, string(os.PathListSeparator)), true
}
// ensureTaskTempDir creates this task's private temp directory and returns it
// with its execution lock held. The caller owns the lock for the lifetime of
// the run and must release it before removing the directory — see the cleanup
// defer in runTask, and execenv.PruneTaskTempDirs for what the lock buys.
func ensureTaskTempDir(envRoot string, workspaceID string, taskID string) (string, *os.File, error) {
envRoot = strings.TrimSpace(envRoot)
if envRoot == "" {
return "", nil, errors.New("env root is empty")
}
workspaceID = strings.TrimSpace(workspaceID)
if workspaceID == "" {
return "", nil, errors.New("workspace id is empty")
}
taskID = strings.TrimSpace(taskID)
if taskID == "" {
return "", nil, errors.New("task id is empty")
}
base, overrideConfigured, err := taskTempBaseDir()
if err != nil {
return "", nil, err
}
dir, err := os.MkdirTemp(base, execenv.TaskTempDirPrefix)
if err != nil {
if overrideConfigured {
return "", nil, fmt.Errorf("MULTICA_AGENT_TEMP_BASE: create task temp dir: %w", err)
}
return "", nil, err
}
if err := os.Chmod(dir, 0o700); err != nil {
_ = os.RemoveAll(dir)
return "", nil, err
}
lock, err := execenv.LockTaskTempDir(dir)
if err != nil {
_ = os.RemoveAll(dir)
return "", nil, err
}
return dir, lock, nil
}
// taskTempBaseDir resolves the parent directory for private per-task temp
// dirs on Linux and macOS. The daemon operator can relocate it with
// MULTICA_AGENT_TEMP_BASE, which must be an absolute path to an existing,
// writable directory; an invalid value fails task startup instead of silently
// falling back. Windows ignores the variable. Unset keeps the platform default
// exactly as before, down to the syscalls made.
// Operators should pick a short path: child tools may bind AF_UNIX sockets
// under $TMPDIR (sun_path is 108 bytes on Linux, 104 on macOS).
func taskTempBaseDir() (string, bool, error) {
if runtime.GOOS == "windows" {
return socketSafeTempBaseDir(), false, nil
}
base := strings.TrimSpace(os.Getenv("MULTICA_AGENT_TEMP_BASE"))
if base == "" {
return socketSafeTempBaseDir(), false, nil
}
if !filepath.IsAbs(base) {
return "", true, fmt.Errorf("MULTICA_AGENT_TEMP_BASE must be an absolute path, got %q", base)
}
return base, true, nil
}
func socketSafeTempBaseDir() string {
if os.PathSeparator == '/' {
if info, err := os.Stat("/tmp"); err == nil && info.IsDir() {
return "/tmp"
}
}
return os.TempDir()
}
// isBlockedEnvKey returns true if the key must not be overridden by user-
// configured custom_env. This prevents accidental or malicious override of
// daemon-internal variables and critical system paths.
func isBlockedEnvKey(key string) bool {
upper := strings.ToUpper(key)
if strings.HasPrefix(upper, "MULTICA_") {
return true
}
switch upper {
case "HOME", "PATH", "USER", "SHELL", "TERM", "TMPDIR", "TMP", "TEMP", "CODEX_HOME", "REASONIX_STATE_HOME", "CURSOR_DATA_DIR", execenv.CursorMcpAuthSourceEnv, "OPENCLAW_CONFIG_PATH", "OPENCLAW_INCLUDE_ROOTS":
return true
}
return false
}
// layerCustomEnvAndHermesHome applies the agent's custom_env onto the child env
// (skipping daemon-internal blocklisted keys), then overrides HERMES_HOME with
// the per-task overlay when one was built. HERMES_HOME is intentionally NOT
// blocklisted: with skills bound the overlay is built FROM the user's
// HERMES_HOME and must win here; with no skills bound (overlayHome empty) the
// user's own HERMES_HOME passes through unchanged, so a skill-less Hermes task
// keeps its original behavior.
// sanitizeAgentEnv returns the agent custom_env with daemon-blocklisted keys
// removed — the effective env the Hermes child actually sees, used to expand
// ${VAR} in external_dirs consistently (blocked keys like HOME resolve to the
// daemon process value, not the dropped custom one). Uses the same
// isBlockedEnvKey rule as layerCustomEnvAndHermesHome so the two agree.
func sanitizeAgentEnv(customEnv map[string]string) map[string]string {
if len(customEnv) == 0 {
return nil
}
out := make(map[string]string, len(customEnv))
for k, v := range customEnv {
if isBlockedEnvKey(k) {
continue
}
out[k] = v
}
return out
}
// hermesProviderUnconfiguredHint is appended verbatim to a "no LLM provider
// configured" failure. It is a CONSTANT, and that is a correctness property,
// not a style choice — see annotateHermesProviderUnconfigured.
//
// It must stay clear of every phrase the resume guards match, because this text
// is persisted in agent_task_queue.error and re-scanned there indefinitely:
// service.ResumeUnsafeFailure, taskfailure.Classify, and the ILIKE/regex guards
// in pkg/db/queries/agent.sql (GetLastTaskSession / GetLastChatTaskSession).
// TestAnnotationCannotChangeMachineDecisions pins that.
const hermesProviderUnconfiguredHint = " [multica] hermes did not read the HERMES_HOME your shell uses: " +
"this task ran against a per-task overlay, seeded from the home the daemon process resolved. " +
"The daemon log line \"hermes home resolved\" for this task names that source home — if your hermes " +
"config lives somewhere else, set HERMES_HOME in the agent's custom_env to point at it."
// annotateHermesProviderUnconfigured explains a "no LLM provider configured"
// failure that Hermes itself cannot explain.
//
// Hermes reports it against whichever HERMES_HOME it was started with and tells
// the user to run `hermes model` — but under Multica it was started with a
// per-task overlay, seeded from a source home the daemon resolved from ITS OWN
// process environment. When that disagrees with where the user keeps their
// config, the remedy Hermes names edits a file the task will never read, and
// every attempt fails identically. That is GH #6872: eight documented
// workarounds, none of which could have worked.
//
// The two paths themselves are deliberately NOT interpolated here. They are
// user-controlled (HERMES_HOME comes from the agent's custom_env, the overlay
// root from MULTICA_WORKSPACES_ROOT), and this string is persisted as the
// task's error text, which the resume guards keep matching against for the life
// of the row. A source home under /srv/400-invalid_request_error/ would trip
// ResumeUnsafeFailure and the SQL guard, dropping a healthy session pointer —
// a directory name must never decide whether a session can be resumed. So the
// hint is fixed prose and names the log line that does carry the paths.
//
// Text only: the caller has already classified the failure, and this changes no
// reason, status, or control flow.
func annotateHermesProviderUnconfigured(errMsg, provider string, overlayActive bool) string {
if provider != "hermes" || !overlayActive || !taskfailure.ProviderUnconfigured(errMsg) {
return errMsg
}
return errMsg + hermesProviderUnconfiguredHint
}
// codexRetiredCompactionHint is appended verbatim to a Codex compaction
// failure against the retired route. Like hermesProviderUnconfiguredHint
// above it is a CONSTANT, for the same correctness reason — see
// annotateCodexRetiredCompaction — and is worded to stay clear of every
// phrase the resume guards and taskfailure.Classify match, since it is
// persisted in agent_task_queue.error and re-scanned there indefinitely.
// TestCodexCompactionAnnotationCannotChangeMachineDecisions pins that.
//
// It names every place the setting can be off, because being a constant means
// it cannot know which one applied. The shared config is the common case and
// the only one the upstream reports mention, but the launch arguments reach
// the same state: nothing strips `--disable remote_compaction_v2` or
// `-c features.remote_compaction_v2=false` out of an agent's custom args, a
// daemon's extra args, or a custom runtime profile's launch prefix — only
// fast_mode gets that treatment, and only when a service tier is selected.
// Naming just the file would send anyone in that case to edit a line that is
// not there, which is the failure mode this hint exists to prevent.
//
// For the same reason the closing line excludes only the generated per-task
// copy, rather than claiming nothing on the Multica side needs changing. Once
// the hint sends people to look at custom_args, a daemon's extra args, or a
// profile's fixed args, "nothing to change here" contradicts the instruction
// directly above it and strands exactly the users the argument half was added
// for. The per-task copy is the one target that is genuinely wrong to edit: it
// is rebuilt from the shared config every run, so an edit there is discarded.
const codexRetiredCompactionHint = " [multica] codex could not compact this conversation: it called a " +
"compaction endpoint OpenAI has retired. That route is selected by turning `remote_compaction_v2` " +
"off, so look in both places it can be off: `[features]` in the codex config this agent uses " +
"(~/.codex/config.toml by default), and the codex launch arguments on the agent, the daemon, or a " +
"custom runtime profile (`--disable remote_compaction_v2`, `-c features.remote_compaction_v2=false`). " +
"Remove it wherever it appears — or set it to true — and run this task again; both sources are re-read " +
"on the next run. The one place not to edit is the per-task codex config this run used: it is " +
"regenerated from your shared one every run, so a change there is lost. A thread stuck this way " +
"continues where it left off once compaction works."
// annotateCodexRetiredCompaction explains a retired-route compaction failure
// that Codex itself cannot explain.
//
// Codex reports the failing URL and status and stops there. The setting that
// chose that URL — `remote_compaction_v2` — appears nowhere in the text, so the
// error names no file, no key and no remedy; the reporter in GH #8000 had to
// open an issue to learn the fix was deleting one line. Nor does the failure
// resolve on its own: the conversation stays above the auto-compact threshold,
// so every following turn retries the same retired call.
//
// Text only. This changes no reason, status, or control flow — in particular
// it does NOT retire the session. That is a deliberate choice, not an
// omission: unlike the resume-unsafe failures classified above, this thread is
// recoverable, and it recovers by itself once the setting is right. Dropping
// the session pointer would discard the conversation the fix brings back.
//
// Scoped to the codex provider, which is exactly the set of runs that can
// produce this text (only "codex" resolves to codexBackend), so the check
// costs nothing and keeps a lookalike string from another runtime out.
func annotateCodexRetiredCompaction(errMsg, provider string) string {
if strings.ToLower(strings.TrimSpace(provider)) != "codex" ||
!agent.CodexRetiredCompactionError(errMsg) {
return errMsg
}
return errMsg + codexRetiredCompactionHint
}
func layerCustomEnvAndHermesHome(agentEnv, customEnv map[string]string, overlayHome string, logger *slog.Logger) {
for k, v := range customEnv {
if isBlockedEnvKey(k) {
if logger != nil {
logger.Warn("custom_env: blocked key skipped", "key", k)
}
continue
}
agentEnv[k] = v
}
if overlayHome != "" {
agentEnv["HERMES_HOME"] = overlayHome
}
}
// prepareReasonixTaskStateHome isolates persisted transcripts and leases per
// (runtime, agent) while leaving REASONIX_HOME untouched. Current Reasonix
// reads credentials/config from REASONIX_HOME and state from
// REASONIX_STATE_HOME, so `reasonix setup` remains the sole credential owner
// and Multica never copies API keys into task-managed files.
func prepareReasonixTaskStateHome(profile, runtimeID, agentID string) (string, error) {
profileDir, err := cli.ProfileDir(profile)
if err != nil {
return "", err
}
runtimeSegment, err := validateReasonixStateSegment("runtime", runtimeID)
if err != nil {
return "", err
}
agentSegment, err := validateReasonixStateSegment("agent", agentID)
if err != nil {
return "", err
}
path := filepath.Join(profileDir, "reasonix-state", runtimeSegment, agentSegment)
if err := os.MkdirAll(path, 0o700); err != nil {
return "", err
}
if err := os.Chmod(path, 0o700); err != nil {
return "", err
}
return path, nil
}
// prepareDshTaskSessionRoot keeps DSH transcripts private to one Multica
// runtime/agent pair. Credentials and the user's DSH profile remain in the
// ordinary DSH_HOME; only session persistence is redirected.
func prepareDshTaskSessionRoot(profile, runtimeID, agentID string) (string, error) {
profileDir, err := cli.ProfileDir(profile)
if err != nil {
return "", err
}
runtimeSegment, err := validateReasonixStateSegment("runtime", runtimeID)
if err != nil {
return "", err
}
agentSegment, err := validateReasonixStateSegment("agent", agentID)
if err != nil {
return "", err
}
path := filepath.Join(profileDir, "dsh-sessions", runtimeSegment, agentSegment)
if err := os.MkdirAll(path, 0o700); err != nil {
return "", err
}
if err := os.Chmod(path, 0o700); err != nil {
return "", err
}
return path, nil
}
func validateReasonixStateSegment(name, value string) (string, error) {
if value == "" {
return "", fmt.Errorf("%s ID is required", name)
}
for _, r := range value {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
continue
default:
return "", fmt.Errorf("%s ID contains an unsafe path character", name)
}
}
return value, nil
}
// codexShellAuthorizedCustomEnvNames returns names from the current agent's
// custom_env that pass the same daemon blocklist used when assembling the child
// environment. Returning names only keeps credential values out of the managed
// Codex config path.
func codexShellAuthorizedCustomEnvNames(customEnv map[string]string) []string {
names := make([]string, 0, len(customEnv))
for key := range customEnv {
if key == "" || isBlockedEnvKey(key) {
continue
}
names = append(names, key)
}
return names
}
// configureCodexTaskShellEnvironment writes the managed shell policy only
// after the task and agent custom environment are fully assembled. This makes
// the allowlist reflect the child environment that will actually be launched,
// including platform-specific essentials and blocklist-checked custom_env
// credentials.
// Failure is fatal: launching with an unowned or malformed policy could either
// drop the task-scoped token again or expose inherited daemon credentials.
func configureCodexTaskShellEnvironment(provider, codexHome string, inherited []string, agentEnv, agentCustomEnv map[string]string, logger *slog.Logger) error {
if provider != "codex" {
return nil
}
if strings.TrimSpace(codexHome) == "" {
return errors.New("configure Codex shell environment: task CODEX_HOME is missing")
}
authorizedExplicit := codexShellAuthorizedCustomEnvNames(agentCustomEnv)
includeOnly := execenv.CodexShellEnvAllowlist(inherited, agentEnv, authorizedExplicit)
configPath := filepath.Join(codexHome, "config.toml")
if err := execenv.EnsureCodexShellEnvPolicyConfig(configPath, includeOnly, logger); err != nil {
return fmt.Errorf("configure Codex shell environment: %w", err)
}
return nil
}
func defaultArgsForProvider(cfg Config, provider string) []string {
var args []string
switch provider {
case "claude":
args = cfg.ClaudeArgs
case "codex":
args = cfg.CodexArgs
case "codebuddy":
args = cfg.CodebuddyArgs
case "qwen":
args = cfg.QwenArgs
case "qwenpaw":
args = cfg.QwenpawArgs
default:
return nil
}
return append([]string(nil), args...)
}