// Package execenv manages isolated per-task execution environments for the daemon. // Each task gets its own directory with injected context files. Repositories are // checked out on demand by the agent via `multica repo checkout`. package execenv import ( "encoding/json" "errors" "fmt" "log/slog" "os" "path/filepath" "strings" "time" "github.com/multica-ai/multica/server/internal/runtimeapps" ) // RepoContextForEnv describes a workspace repo available for checkout. type RepoContextForEnv struct { URL string // remote URL Description string // optional repo description Ref string // optional default checkout ref for this task } // ProjectResourceForEnv describes a single resource attached to the issue's // project. The resource_ref payload is type-specific JSON; the agent reads // resources.json on disk for the full structure. This struct only carries // fields the meta-skill template needs to render a human-readable summary // (URL for github_repo, generic label otherwise). type ProjectResourceForEnv struct { ID string `json:"id"` // server-assigned UUID ResourceType string `json:"resource_type"` // e.g. "github_repo" ResourceRef json.RawMessage `json:"resource_ref"` // raw JSONB payload from the API Label string `json:"label,omitempty"` // optional user-supplied label } // PrepareParams holds all inputs needed to set up an execution environment. type PrepareParams struct { WorkspacesRoot string // base path for all envs (e.g., ~/multica_workspaces) WorkspaceID string // workspace UUID — stable identity and path suffix WorkspaceSlug string // human-readable workspace path prefix TaskID string // task UUID — stable identity and path suffix IssueIdentifier string // human-readable issue key (e.g. MUL-6063); empty for non-issue tasks AgentName string // for git branch naming only // EnvRootPreclaimed says the CALLER already holds this env root's claim // (see ClaimEnvRoot) and has already reset it. Prepare then skips claiming. // // This exists because production preparation runs in a short-lived helper // process (PrepareIsolated): a lock taken here would be released by the // kernel the moment that helper exits, and the *os.File cannot cross the // JSON boundary back to the daemon. The claim therefore has to be held by // the parent, whose lifetime is the task run. EnvRootPreclaimed bool // Profile is the daemon's profile name (empty = default). It namespaces the // per-issue Codex session store so a second profile-daemon sharing the same // ~/.codex cannot see or GC this daemon's stores (MUL-4424). Profile string Provider string // agent provider (determines runtime config and skill injection paths) CodexVersion string // detected Codex CLI version (only used when Provider == "codex") OpenclawBin string // resolved openclaw CLI path (only used when Provider == "openclaw"); empty = look up on PATH // McpConfig is the agent's saved `mcp_config` JSON, forwarded to the // provider-specific config preparer when that provider materialises MCP // via a per-task config file. Cursor, OpenClaw, and OMP consume it here; // other providers wire MCP via ExecOptions.McpConfig in the agent backend. McpConfig json.RawMessage // CursorMcpAuthSource is an explicit opt-in path to a Cursor mcp-auth.json // file, or the Cursor project data directory containing it. Only Cursor's // managed MCP path consumes it. CursorMcpAuthSource string // OpenclawGateway pins the OpenClaw Gateway endpoint inside the per-task // wrapper. Only consulted when Provider == "openclaw" and the agent's // runtime_config selected gateway mode (issue #3260). Zero means "inherit // whatever the user's global openclaw.json already configures". OpenclawGateway OpenclawGatewayPin // LocalWorkDir, when non-empty, redirects the agent's working directory // to a user-supplied absolute path instead of the synthesised envRoot/ // workdir. The path is NOT copied or mounted — the agent operates on // the user's directory in place. The daemon still creates envRoot for // output/, logs/, and .gc_meta.json; only the workdir slot is // substituted. Used by the local_directory project_resource flow // (MUL-2663). When set, the envRoot/workdir directory is not created. LocalWorkDir string // LocalWorktree, when non-nil, is the worktree-mode counterpart of // LocalWorkDir: instead of running in the user's directory, the task gets // its own git worktree of that repo inside envRoot and delivers its work // as a branch. Tasks sharing the directory then run concurrently. // Mutually exclusive with LocalWorkDir — the daemon picks one based on the // resource's execution_mode. LocalWorktree *LocalWorktreeParams // HermesSourceHome is the shared Hermes home the per-task overlay is seeded // from — resolved by the daemon via execenv.ResolveHermesProfile so it honors // the agent's custom_env HERMES_HOME and any -p/--profile or sticky selection. // Only used for the hermes provider; empty falls back to the platform default. HermesSourceHome string // HermesSourceMustExist fails the overlay build closed when HermesSourceHome // is absent — set when an explicit named profile was requested so a typo // doesn't silently seed from an empty home and drop the user's auth/config. HermesSourceMustExist bool // HermesMemoryStore is the agent's persistent Hermes memory store // (HermesMemoryStorePath) the overlay links memories/ to, so memory outlives // the task. Empty keeps memories/ task-local — no agent to key on, or the // Multica profile dir could not be resolved. HermesMemoryStore string // HermesSessionStore is the conversation's persistent Hermes session store // (HermesSessionStorePath) used as HERMES_HOME, so the transcript // outlives the task and a follow-up turn can actually resume it. Empty keeps // state.db task-local — no agent or conversation to key on, or the Multica // profile dir could not be resolved. HermesSessionStore string // HermesEnv is the sanitized effective env (agent custom_env minus the daemon // blocklisted keys) used to expand ${VAR} in Hermes external_dirs so it // matches what the Hermes child process actually sees. Only used for hermes. HermesEnv map[string]string // ReasonixEnv is the sanitized agent custom_env, layered over the daemon's // own environment exactly as the child's env is built. The per-task // reasonix.toml restates the permissions from the user config that env // resolves to, so an agent that re-points (or clears) REASONIX_HOME moves the // daemon's read with it. Only used for reasonix. ReasonixEnv map[string]string // CodexCustomArgs are the effective Codex CLI args this task launches with // (daemon defaults + profile-fixed + per-agent custom_args). Only the // Windows sandbox decision reads them, to honor a `-c windows.sandbox=...` // override that never lands in config.toml (MUL-4957). CodexCustomArgs []string Task TaskContextForEnv // context data for writing files } // TaskContextForEnv is the subset of task context used for writing context files. type TaskContextForEnv struct { IssueID string TriggerCommentID string // comment that triggered this task (empty for on_assign) TriggerThreadID string // root comment ID for the triggering thread; falls back to TriggerCommentID when empty // CommentReplyTargets is set for a comment run that coalesced comments // spanning MORE THAN ONE root thread (MUL-4348). When it has >=2 entries the // workflow's reply step fans out — one reply per thread — instead of the // single --parent=trigger cookbook, keeping this persistent brief in sync // with the per-turn prompt so a cross-thread run cannot get one source // telling it "one comment" and the other "one per thread". Same-thread // follow-ups collapse to a single group upstream, so this stays empty and // the single-parent path is used (no duplicate replies). CommentReplyTargets []ThreadReplyTarget NewCommentCount int // issue-wide comments since this agent's last run (excludes its own and the injected trigger) NewCommentsSince string // RFC3339 anchor (last run's started_at) the count is measured from; empty on cold start PriorSessionResumed bool // true when the daemon will resume an existing provider session for this task // PriorSessionResumeUnavailable is true when this task carried a prior // session the daemon expected to resume but could NOT (the reused workdir was // gone, or the Codex rollout was not present in the task CODEX_HOME). The // brief surfaces this so the agent tells the user its previous conversation // context is gone and this run starts fresh — turning a silent context loss // into a user-visible one (MUL-4424). Distinct from an ordinary cold start, // which never had a prior session to lose. PriorSessionResumeUnavailable bool AgentID string // unique ID of the dispatched agent AgentName string AgentInstructions string // agent identity/persona instructions, injected into CLAUDE.md AgentSkills []SkillContextForEnv DisabledRuntimeSkills []RuntimeSkillRefForEnv Repos []RepoContextForEnv // workspace repos available for checkout ProjectID string // active project for this task, when present ProjectTitle string // human-readable project title ProjectDescription string // durable project-level context, rendered into the brief's Project Context section ProjectResources []ProjectResourceForEnv // resources attached to the project ChatSessionID string // non-empty for chat tasks // ChatChannelType is the IM platform behind a chat session ("slack", // "feishu", "wecom"); empty for a web/mobile chat. It names the surface in // the brief's copy; what that surface can DELIVER is the separate field // below (MUL-4899). The orthogonal audience and history policies live in // the per-turn chat prompt (daemon/prompt.go) — the server has no history // reader for any other channel. ChatChannelType string // ChatChannelDeliversFiles is the server's verdict, for THIS turn, on // whether a file the agent produces reaches the reader: the adapter goes // back for the bound attachment and this deployment has the object storage // it goes back to. It arrives on the claim and is used as given. False // covers an old server that never sent it, a deployment with no storage, // and every channel whose adapter does not perform the hop — all three of // which want the same instruction, the one telling the agent to describe // its file in words. // // Carried here but deliberately NOT rendered into the brief. It is a // per-turn value: a server upgrade that starts sending it, or object // storage being turned on or off, flips it under a chat session that // resumes across the change, and the brief is the prompt-cache prefix // (MUL-5377). The agent-facing verdict is emitted by the per-turn chat // prompt (daemon.buildChatPrompt) instead, and // TestBriefByteIdenticalAcrossRunsForEveryKind is what keeps this field out // of the brief. ChatChannelDeliversFiles bool AutopilotRunID string // non-empty for autopilot run_only tasks AutopilotID string AutopilotTitle string AutopilotDescription string AutopilotSource string AutopilotTriggerPayload string QuickCreatePrompt string // non-empty for quick-create tasks IsSquadLeader bool // true when THIS TASK runs the agent in the squad-leader role (may exit silently on no_action); derived from the claim's is_leader_task / squad_id, never sniffed from instructions text (MUL-5811) // WorkspaceContext is the workspace-level system prompt (workspace.context // in the DB). Rendered into the brief as `## Workspace Context` when // non-empty so every agent in the workspace sees the same shared context, // regardless of issue / chat / autopilot / quick-create. WorkspaceContext string // IssueStatuses is the workspace's active CUSTOM status catalog from the // claim payload (MUL-6460), in catalog order. Rendered into the brief's // status-command line so agents can see and use statuses beyond the seven // built-ins. Like WorkspaceContext, this is durable workspace configuration, // not per-turn state: it may legitimately change brief bytes when an admin // edits the catalog between runs of a resumed session, exactly as a // Workspace Context edit does. Empty — including on old servers that never // send the field — renders the built-in-only line byte-identical to before. IssueStatuses []IssueStatusForEnv // IssueStatusesOmitted is the count of active custom statuses the server's // cap dropped from IssueStatuses, so the brief can disclose truncation // instead of presenting a partial catalog as complete. IssueStatusesOmitted int // HasProjectWorkflow is true when the claim carried a project workflow // brief for this issue (MUL-7420). The brief then swaps its built-in // in_progress / in_review rules for a pointer at that per-turn block, whose // statuses and commands are the issue's real contract (MUL-8145). Servers // that send no workflow leave it false and the brief unchanged. HasProjectWorkflow bool // ConnectedApps lists per-run external app capabilities mounted through // MCP overlays. Rendered briefly so the agent can map app names such as // Notion to the actual MCP server name (`composio`). ConnectedApps []runtimeapps.ConnectedApp // RequestingUserName + RequestingUserProfileDescription describe the // human the agent is acting on behalf of. v1 sources them from the // runtime owner (the user who registered the daemon). Rendered into the // brief as the `## Requesting User` section only when description is // non-empty — empty means the user opted out of injecting profile // context and the agent stays anonymous-user mode. RequestingUserName string RequestingUserProfileDescription string // Initiator* carry the run's authorization human through the existing // daemon claim fields. The name and email are per-turn context, not part of // the runtime brief. Empty when the run has no originator. See GH-8674. InitiatorType string InitiatorID string InitiatorName string InitiatorEmail string } // SkillContextForEnv represents a skill to be written into the execution environment. // IssueStatusForEnv is one active custom workspace status rendered into the // brief (MUL-6460). Name and Description are user-authored text and MUST pass // through the brief sanitizers before rendering; Key is constrained by the // storage CHECK to `^[a-z0-9][a-z0-9_]{0,31}$` but is still guarded with // sanitizeBriefCodeToken as defense-in-depth. type IssueStatusForEnv struct { Key string Name string Category string Description string } type SkillContextForEnv struct { Name string Description string Content string Files []SkillFileContextForEnv } // SkillFileContextForEnv represents a supporting file within a skill. type SkillFileContextForEnv struct { Path string Content string } // Environment represents a prepared, isolated execution environment. type Environment struct { // RootDir is the top-level env directory ({workspacesRoot}/{task_id_short}/). RootDir string // WorkDir is the directory to pass as Cwd to the agent. Normally // ({RootDir}/workdir/); when the task is bound to a local_directory // project_resource, it is the user's path instead. See LocalDirectory. WorkDir string // LocalDirectory is true when WorkDir points at a user-supplied path // outside RootDir (the local_directory flow). Callers that key behavior // on "may I remove WorkDir as scratch?" must check this — for example // the GC loop never deletes the user's directory. // // Deliberately FALSE in worktree mode: there the workdir is a disposable // git worktree inside RootDir, so the env root is ordinary daemon-owned // scratch that the GC should reclaim on the normal schedule, and the // sidecar rollback that protects a user's directory is unnecessary. LocalDirectory bool // MulticaConfigRoot is the private per-task config directory exported to // child CLI invocations. It prevents implicit discovery of the daemon // owner's ~/.multica profile without changing the provider-facing HOME. MulticaConfigRoot string // LocalWorktree is set when the task runs in worktree mode against a // local_directory resource. The daemon calls Finalize on it after the // agent exits to commit leftovers, drop the worktree, and learn the // branch name to report as the task's result. LocalWorktree *LocalWorktree // CodexHome is the path to the per-task CODEX_HOME directory (set only for codex provider). CodexHome string // ClaudeSettingsPath is a task-local --settings JSON file that applies // disabled runtime-skill policy without mutating the user's Claude config. ClaudeSettingsPath string // OpenclawConfigPath is the path to the per-task synthesized OpenClaw // config (set only for openclaw provider). The daemon exports this as // OPENCLAW_CONFIG_PATH on the openclaw subprocess so its native skill // scanner pins workspaceDir to WorkDir. OpenclawConfigPath string // OpenclawIncludeRoot is the directory of the user's active OpenClaw // config (set only for openclaw provider with an on-disk user config). // The daemon must prepend it to OPENCLAW_INCLUDE_ROOTS so OpenClaw is // allowed to follow the wrapper's `$include` link out of envRoot into // the user's config — by default OpenClaw confines `$include` to the // directory holding the wrapper file. Empty when no $include is // emitted (fresh install). OpenclawIncludeRoot string // CursorDataDir is the per-task Cursor data directory (set only for // cursor provider when the agent has managed mcp_config). The daemon // exports this as CURSOR_DATA_DIR so project-level MCP approvals are // isolated from the user's persistent ~/.cursor/projects state. CursorDataDir string // HermesHome is the effective HERMES_HOME overlay (set only for // the hermes provider, and only when the agent has skills bound — empty // otherwise, leaving the user's real home in place). With a conversation // store it lives there; otherwise it lives under the task root. It mirrors ~/.hermes/ // via symlink, derives a config.yaml that references the user's real skills // as an external root, and holds the bound skills in its skills/ subdir. The // daemon exports it as HERMES_HOME so the hermes CLI discovers those skills // natively — Hermes has no workspace-relative discovery, so the previous // .agent_context/skills/ fallback was never read (issue #5242). See // hermes_home.go. HermesHome string // HermesSessionStore is the conversation's durable home, or "" when // there is no conversation store and the home stays task-local. HermesSessionStore string // HermesSessionHistoryPresent reports that the mounted store actually holds // a session database — a prior turn's transcript this task can resume. // Mounting alone does not imply it: a conversation's first turn, a store the // GC reclaimed between turns, a switched Hermes profile and an operator's // `rm` all mount cleanly onto nothing. The daemon reads THIS, not the store // path, as the answer to "can a prior session id still resolve here?" — see // gateResumeToReachableSession. HermesSessionHistoryPresent bool // QwenpawWorkspace is the path to the per-task QwenPaw workspace directory // (set only for the qwenpaw provider). It is populated with the bound skills // and their skill.json manifest with enabled: true, so the skills are // immediately effective. The daemon passes --workspace to // `qwenpaw acp` so the QwenPaw agent discovers the skills natively. // See qwenpaw_workspace.go. QwenpawWorkspace string logger *slog.Logger // for cleanup logging // lockFile holds the env root's exclusive execution lock for as long as // this Environment is alive. Released by Cleanup. See claimEnvRoot. lockFile *os.File } // RootDirParams is the identity and display data used to derive a new task's // environment root. IDs provide stable collision-safe suffixes; user-controlled // labels are only readable prefixes and never serve as identity. type RootDirParams struct { WorkspacesRoot string WorkspaceID string WorkspaceSlug string TaskID string IssueIdentifier string } // Keep each readable segment within the same aggregate budget as main's // /<12-char task key> layout. The env root prefixes both the // checkout and git ref paths, so every extra character is costly on Windows. const readablePathSegmentMax = 24 // PredictRootDir returns the readable path proposed for a task without doing // I/O. ResolveRootDir freezes the first proposal and must be used by callers // that need the task's authoritative physical root. func PredictRootDir(params RootDirParams) string { if params.WorkspacesRoot == "" || params.WorkspaceID == "" || params.TaskID == "" { return "" } return filepath.Join( params.WorkspacesRoot, readablePathSegment(params.WorkspaceSlug, "workspace", params.WorkspaceID), readablePathSegment(params.IssueIdentifier, "task", params.TaskID), ) } // readablePathSegment converts a user-controlled label into a bounded, // lowercase ASCII prefix and appends the stable ID suffix. The suffix keeps // paths distinct when labels differ only by case, sanitize to the same value, // or change later. func readablePathSegment(label, fallback, id string) string { prefix := strings.ToLower(strings.TrimSpace(label)) prefix = nonAlphanumeric.ReplaceAllString(prefix, "-") prefix = strings.Trim(prefix, "-") if prefix == "" { prefix = fallback } // UUIDv7's leading bits are a timestamp shared by burst-created tasks. // taskKey takes the random tail; it is equally suitable for workspace UUIDs. suffix := strings.ToLower(taskKey(id)) maxPrefix := readablePathSegmentMax - len(suffix) - 1 if len(prefix) > maxPrefix { prefix = strings.TrimRight(prefix[:maxPrefix], "-") } if prefix == "" { prefix = fallback } return prefix + "-" + suffix } // Prepare creates an isolated execution environment for a task. // The workdir starts empty (no repo checkouts). The agent checks out repos // on demand via `multica repo checkout `. func Prepare(params PrepareParams, logger *slog.Logger) (*Environment, error) { if params.WorkspacesRoot == "" { return nil, fmt.Errorf("execenv: workspaces root is required") } if params.WorkspaceID == "" { return nil, fmt.Errorf("execenv: workspace ID is required") } if params.TaskID == "" { return nil, fmt.Errorf("execenv: task ID is required") } envRoot, err := ResolveRootDir(RootDirParams{ WorkspacesRoot: params.WorkspacesRoot, WorkspaceID: params.WorkspaceID, WorkspaceSlug: params.WorkspaceSlug, TaskID: params.TaskID, IssueIdentifier: params.IssueIdentifier, }) if err != nil { return nil, err } // Self-heal the root-level daemon marker on every task start so a marker // removed while the daemon runs is restored before the agent spawns. The // per-workdir marker written below only covers cwds inside the workdir; // the root marker keeps the CLI fail-closed guard active for subprocesses // that lose all MULTICA_* env vars AND escape above the workdir. Non-fatal: // without it the workdir marker still protects the common case. if err := EnsureWorkspacesRootMarker(params.WorkspacesRoot); err != nil && logger != nil { logger.Warn("execenv: workspaces root marker not written; fail-closed guard limited to the task workdir", "error", err) } // Take exclusive ownership of the env root before touching anything in it. // What follows wipes the directory, and while the segment was a UUIDv7 // prefix that routinely wiped a live sibling task's workdir, worktree and // task-scoped config (#7326). taskKey now reads the id's random tail, which // makes a shared path improbable rather than impossible — so prove // ownership instead of assuming it. A task that refuses to start is // recoverable; one that deletes a running sibling's uncommitted work is not. // // claimEnvRoot is the only thing standing between two same-key tasks, so it // has to be atomic end to end: a read-then-delete would let both pass the // check and one still delete the other. Once claimed, the claim is held for // the rest of Prepare — the reset below clears the directory's CONTENTS and // leaves the marker in place, so there is never a moment where the env root // looks unowned to a racing task. var lockFile *os.File lockClaimed := false if params.EnvRootPreclaimed { // The caller holds the claim and already reset the root; just make sure // the directory is there before populating it. if err := os.MkdirAll(envRoot, 0o755); err != nil { return nil, fmt.Errorf("execenv: create env root %s: %w", envRoot, err) } } else { lock, reset, err := claimEnvRoot(envRoot, params.WorkspaceID, params.TaskID) if err != nil { return nil, fmt.Errorf("execenv: %w", err) } lockFile = lock // Release the lock on every failure path below. The successful path // hands it to the Environment. lockClaimed = true defer func() { if lockClaimed { releaseLockFile(lockFile) } }() // reset means this task already owned the directory and the execution // that left it there is gone — a rerun, which is meant to start from a // clean tree. Reuse of a PRIOR task's directory never reaches here; // that is Reuse, which takes an explicit WorkDir and deletes nothing. if reset { if err := resetEnvRootContents(envRoot); err != nil { return nil, fmt.Errorf("execenv: reset existing env: %w", err) } } } // Create directory tree. For the standard flow the agent's workdir is // envRoot/workdir; for local_directory tasks the user's path takes its // place and we only need to create the scratch directories under // envRoot. workDir := filepath.Join(envRoot, "workdir") scratchDirs := []string{filepath.Join(envRoot, "output"), filepath.Join(envRoot, "logs")} if params.LocalWorkDir == "" && params.LocalWorktree == nil { scratchDirs = append(scratchDirs, workDir) } else if params.LocalWorkDir != "" { workDir = params.LocalWorkDir } for _, dir := range scratchDirs { if err := os.MkdirAll(dir, 0o755); err != nil { return nil, fmt.Errorf("execenv: create directory %s: %w", dir, err) } } multicaConfigRoot := filepath.Join(envRoot, "multica-config") if err := os.MkdirAll(multicaConfigRoot, 0o700); err != nil { return nil, fmt.Errorf("execenv: create task-local Multica config directory: %w", err) } if err := os.Chmod(multicaConfigRoot, 0o700); err != nil { return nil, fmt.Errorf("execenv: restrict task-local Multica config directory: %w", err) } // Worktree mode: build the task's own checkout of the user's repo inside // envRoot and use it as the workdir. Done before any context file is // written so the sidecars land inside the disposable worktree instead of // the user's directory. var localWorktree *LocalWorktree // Tracks whether Prepare reached its successful return. Everything after // worktree creation can still fail — context files, provider homes, MCP // config — and on those paths the caller never receives an Environment, so // nothing downstream knows a worktree exists to clean up. Without the // rollback below, each such failure would leave a registration in the // user's repo and a branch that no task ever ran in. prepareSucceeded := false if params.LocalWorktree != nil { wtParams := *params.LocalWorktree wtParams.EnvRoot = envRoot wtParams.AgentName = params.AgentName wtParams.TaskID = params.TaskID wtParams.ConversationKey, wtParams.ConversationID = localWorktreeConversation(params) wtParams.WorkspaceID = params.WorkspaceID wtParams.AgentID = params.Task.AgentID var err error localWorktree, err = PrepareLocalWorktree(wtParams, logger) if err != nil { return nil, err } defer func() { if prepareSucceeded { return } // Safe to discard unconditionally: no agent has run yet, so the // worktree holds only what Prepare itself put there. localWorktree.Discard(logger) }() workDir = localWorktree.WorkDir // The resource may point at a subdirectory that holds only ignored // files, in which case git doesn't materialise it in the worktree. if err := os.MkdirAll(workDir, 0o755); err != nil { return nil, fmt.Errorf("execenv: create worktree workdir %s: %w", workDir, err) } } env := &Environment{ RootDir: envRoot, WorkDir: workDir, LocalDirectory: params.LocalWorkDir != "", LocalWorktree: localWorktree, MulticaConfigRoot: multicaConfigRoot, logger: logger, lockFile: lockFile, } // Write context files into workdir (skills go to provider-native paths). // Track every file/dir we create in a manifest so CleanupSidecars can // roll a local_directory workdir back to its pre-Prepare state. Cloud // tasks don't need the manifest (the GC loop wipes envRoot wholesale), // but we always write one — it's cheap, keeps Prepare/Reuse symmetric, // and avoids a conditional that would silently disable cleanup if the // local_directory detection logic ever drifts. manifest := &sidecarManifest{} // Arm the rollback BEFORE the first write, not after writeContextFiles // returns. writeContextFiles puts the daemon task marker down as its very // first act and can then fail on any later step — .agent_context, skill // files, project resources — so a defer registered after it returns would // miss exactly the failures that strand a marker with nothing else around // it. Rolling back an empty manifest is a no-op, which is what makes it // safe to arm this early. // // The manifest that records these writes is not persisted until the end of // Prepare, and the caller receives no Environment on any failure path, so // none of the teardown defers that normally undo this tree are ever // registered. Without this rollback the files stay on disk with no record // of what to remove (MUL-6132). // // In place only. Worktree mode discards the whole worktree on failure just // above, and a cloud envRoot is wiped wholesale by the GC — only the // local_directory flow writes into a directory that outlives the task and // belongs to the user, where a leftover marker disables every multica // command in that directory tree until someone removes it by hand. if params.LocalWorkDir != "" { defer func() { if prepareSucceeded { return } if err := rollBackPreparedSidecars(*manifest); err != nil && logger != nil { logger.Warn("execenv: roll back sidecars after failed prepare", "work_dir", workDir, "error", err) } }() } if err := writeContextFiles(workDir, params.Provider, params.Task, manifest); err != nil { return nil, fmt.Errorf("execenv: write context files: %w", err) } if err := prepareOmpMcpConfig(workDir, params.Provider, params.McpConfig, manifest); err != nil { return nil, fmt.Errorf("execenv: prepare omp mcp config: %w", err) } // Persist managed-env provenance for non-local resumable envs at Prepare time // (not on completion, where .gc_meta.json is written). A same-issue // follow-up can be claimed the instant the prior task completes — before // the prior handler writes .gc_meta.json — so reuse eligibility must be // provable from an artifact that exists the moment the env is created. Only // managed (non-local_directory) issue and chat envs get this marker; that is // exactly the set with a durable conversation scope. Non-fatal: a write failure // only costs the next follow-up its session reuse (it falls back to a fresh // session), which must never block dispatching this task. if params.LocalWorkDir == "" && (params.Task.IssueID != "" || params.Task.ChatSessionID != "") { if err := WriteManagedEnvProvenance(envRoot, ManagedEnvProvenance{ WorkspaceID: params.WorkspaceID, IssueID: params.Task.IssueID, ChatSessionID: params.Task.ChatSessionID, AgentID: params.Task.AgentID, }); err != nil && logger != nil { logger.Warn("execenv: write managed env provenance failed (non-fatal); a follow-up may start a fresh session", "error", err) } } // For Codex, set up a per-task CODEX_HOME seeded from ~/.codex/ with skills. if params.Provider == "codex" { codexHome := filepath.Join(envRoot, codexHomeDirName) if err := prepareCodexHomeWithOpts(codexHome, CodexHomeOptions{CodexVersion: params.CodexVersion, IsLocalDirectory: params.LocalWorkDir != "" || params.LocalWorktree != nil, SessionStoreKey: codexSessionStoreKey(params.Profile, params.Task), CodexCustomArgs: params.CodexCustomArgs}, logger); err != nil { return nil, fmt.Errorf("execenv: prepare codex-home: %w", err) } if err := hydrateCodexSkills(codexHome, params.Task.AgentSkills, params.Task.DisabledRuntimeSkills, logger); err != nil { return nil, fmt.Errorf("execenv: hydrate codex skills: %w", err) } env.CodexHome = codexHome } if params.Provider == "claude" { settingsPath, err := prepareClaudeSkillSettings(envRoot, params.Task.DisabledRuntimeSkills, params.Task.AgentSkills) if err != nil { return nil, fmt.Errorf("execenv: prepare claude skill settings: %w", err) } env.ClaudeSettingsPath = settingsPath } // For Hermes, redirect HERMES_HOME to a per-task compatibility overlay ONLY // when the agent has skills bound. A skill-less Hermes task keeps the user's // real home and its original behavior untouched. The overlay makes the bound // skills visible — Hermes discovers skills only from its home, so the old // .agent_context/skills/ fallback was never read (issue #5242). See // hermes_home.go. // // Note this is a local contract, not an observable product behaviour: the // server appends the platform's built-in skills to every agent's skill set // (service.LoadAgentSkillBundles), so a claimed task's AgentSkills is never // empty and the skill-less branch is effectively unreachable in production. // Emptying an agent's own skill list is NOT a way to opt out of the overlay. if params.Provider == "hermes" && len(params.Task.AgentSkills) > 0 { hermesHome := filepath.Join(envRoot, "hermes-home") sessions, err := prepareHermesHome(hermesHome, params.HermesSourceHome, params.HermesSourceMustExist, params.Task.AgentSkills, params.HermesEnv, params.HermesMemoryStore, params.HermesSessionStore, logger) if err != nil { return nil, fmt.Errorf("execenv: prepare hermes-home: %w", err) } env.HermesHome = sessions.Home if sessions.Mounted { env.HermesSessionStore = params.HermesSessionStore env.HermesSessionHistoryPresent = sessions.HistoryPresent } } if params.Provider == "qwenpaw" { qwenpawWorkspace := filepath.Join(envRoot, "qwenpaw-workspace") if err := prepareQwenpawWorkspace(qwenpawWorkspace, params.Task.AgentSkills, logger); err != nil { return nil, fmt.Errorf("execenv: prepare qwenpaw workspace: %w", err) } env.QwenpawWorkspace = qwenpawWorkspace } // For Reasonix, deny the `ask` tool for this task through a project-scoped // reasonix.toml. Degraded, not fatal: without it the task still runs under // the backend's fail-closed question handling. if params.Provider == "reasonix" { if err := writeReasonixProjectConfig(workDir, params.ReasonixEnv, manifest, logger); err != nil { logger.Warn("execenv: write reasonix project config failed", "error", err) } } // For Cursor, materialize managed MCP into project-local config and use // an isolated CURSOR_DATA_DIR for the per-workdir approval sidecar. Cursor // still reads ~/.cursor/mcp.json, but only servers with approval entries in // this per-task data dir can load, so user-global MCP servers do not leak // into managed-MCP runs. if params.Provider == "cursor" { cursorDataDir, err := prepareCursorMcpConfig(envRoot, workDir, params.McpConfig, params.CursorMcpAuthSource, manifest) if err != nil { return nil, fmt.Errorf("execenv: prepare cursor mcp config: %w", err) } env.CursorDataDir = cursorDataDir } if err := writeSidecarManifest(envRoot, manifest); err != nil { // In place the manifest is the ONLY record of what we wrote into the // user's own directory, so losing it strands the sidecar tree there // permanently — no crash required, a disk or permission hiccup is // enough (MUL-6132). Fail so the rollback registered above removes the // tree now, while we still hold the in-memory manifest. Elsewhere the // manifest is a convenience the GC can do without, so a warning stays // the right response. if params.LocalWorkDir != "" { return nil, fmt.Errorf("execenv: write sidecar manifest: %w", err) } logger.Warn("execenv: write sidecar manifest failed (non-fatal)", "error", err) } // For OpenClaw, synthesize a per-task config that pins workspace to // workDir. The skill scanner then reads {workDir}/skills/ (written by // writeContextFiles above). Fail closed on errors: a malformed user // config that the openclaw CLI can't read is a real problem and // silently degrading to a minimal config would mask it by booting // OpenClaw without the agents / providers / API keys it expects. if params.Provider == "openclaw" { result, err := prepareOpenclawConfig(envRoot, workDir, OpenclawConfigPrep{ OpenclawBin: params.OpenclawBin, CacheDir: openclawProfileCacheDir(params.Profile, logger), McpConfig: params.McpConfig, Gateway: params.OpenclawGateway, Logger: logger, }) if err != nil { return nil, fmt.Errorf("execenv: prepare openclaw config: %w", err) } env.OpenclawConfigPath = result.ConfigPath env.OpenclawIncludeRoot = result.IncludeRoot } logger.Info("execenv: prepared env", "root", envRoot, "repos_available", len(params.Task.Repos)) prepareSucceeded = true lockClaimed = false // ownership of any lock passes to the Environment return env, nil } // ReuseParams describes the inputs to Reuse. It mirrors PrepareParams for // the per-provider knobs (CodexVersion, OpenclawBin) so callers can pass // the same resolved binary path on both first-run and reuse paths. type ReuseParams struct { // WorkspacesRoot is the daemon-owned root under which all task envs live. // Passed on reuse so the root-level fail-closed marker is self-healed here // too — a marker removed while the daemon runs is restored before a reused // task spawns, not only on the fresh-Prepare path. WorkspacesRoot string WorkDir string Provider string CodexVersion string // only used when Provider == "codex" // ResumeSessionID is the prior Codex thread/session ID this reused task // intends to resume, when any. Only consulted when Provider == "codex" and // only used while migrating a legacy per-task home whose sessions/ still // symlinks the shared ~/.codex/sessions — the single rollout for this ID is // exposed into the new task-local sessions dir so thread/resume still finds // it. Empty means a fresh thread. See prepareCodexSessionsDir (MUL-4424). ResumeSessionID string OpenclawBin string // only used when Provider == "openclaw"; empty = PATH lookup // McpConfig is the agent's saved `mcp_config` JSON. Reused on reuse so a // freshly-saved managed set re-materialises into the wrapper before the // task starts — without this a stale wrapper from a prior run would keep // the old MCP set in play. McpConfig json.RawMessage // CursorMcpAuthSource mirrors PrepareParams.CursorMcpAuthSource on reuse. CursorMcpAuthSource string // OpenclawGateway is the per-task Gateway pin re-applied on reuse so the // agent picks up any runtime_config changes saved since the prior run. OpenclawGateway OpenclawGatewayPin // Profile is the daemon's profile name (empty = default), mirroring // PrepareParams.Profile so a reused task keys its per-issue Codex session // store into the same profile namespace (MUL-4424). Profile string // LocalDirectory is true when the reused WorkDir is a user-supplied // directory (the local_directory flow). The flag is propagated into // the returned Environment so downstream callers (notably the GC // loop) keep the "never delete the user's directory" invariant on // reuse paths. LocalDirectory bool // HermesSourceHome, HermesEnv, HermesMemoryStore and HermesSessionStore // mirror PrepareParams on reuse so the Hermes overlay re-derives against the // agent's current source home / profile, external_dirs vars, memory store and // conversation session store. HermesSourceHome string HermesSourceMustExist bool HermesEnv map[string]string HermesMemoryStore string HermesSessionStore string // ReasonixEnv mirrors PrepareParams.ReasonixEnv on reuse so the rewritten // reasonix.toml keeps restating the owner's current permissions. ReasonixEnv map[string]string // CodexCustomArgs mirrors PrepareParams.CodexCustomArgs on reuse so the // Windows sandbox decision honors a `-c windows.sandbox=...` override here // too (MUL-4957). CodexCustomArgs []string Task TaskContextForEnv // refreshed context files / skills } // Reuse wraps an existing workdir into an Environment and refreshes context files. // Returns nil if the workdir does not exist or required provider setup fails // (caller should fall back to Prepare). func Reuse(params ReuseParams, logger *slog.Logger) *Environment { if _, err := os.Stat(params.WorkDir); err != nil { return nil } // Self-heal the root-level daemon marker on the reuse path too, so a marker // removed while the daemon runs is restored before a reused task spawns — // otherwise reuse could run without the fail-closed guard until the next // fresh Prepare. Non-fatal: the per-workdir marker still protects the common // case, and an empty WorkspacesRoot (legacy callers) simply skips this. if params.WorkspacesRoot != "" { if err := EnsureWorkspacesRootMarker(params.WorkspacesRoot); err != nil && logger != nil { logger.Warn("execenv: workspaces root marker not written on reuse; fail-closed guard limited to the task workdir", "error", err) } } rootDir := filepath.Dir(params.WorkDir) if params.LocalDirectory { // For local_directory tasks the user's WorkDir is unrelated to // envRoot (envRoot still lives under workspacesRoot/{wsID}/...), // so reading it from filepath.Dir(WorkDir) would point at the // parent of the user's directory. Callers that need a real // RootDir on the reuse path should arrange to pass it in // explicitly; for v1 the daemon only ever reuses local_directory // workdirs after a fresh Prepare in the same task lifetime, so // the empty RootDir on reuse is fine for the current callers // (GC writes meta from Prepare's result, not Reuse's). rootDir = "" } env := &Environment{ RootDir: rootDir, WorkDir: params.WorkDir, LocalDirectory: params.LocalDirectory, logger: logger, } if env.RootDir != "" { env.MulticaConfigRoot = filepath.Join(env.RootDir, "multica-config") if err := os.MkdirAll(env.MulticaConfigRoot, 0o700); err != nil { logger.Warn("execenv: restore task-local Multica config directory failed; forcing fresh prepare", "error", err) return nil } if err := os.Chmod(env.MulticaConfigRoot, 0o700); err != nil { logger.Warn("execenv: restrict task-local Multica config directory failed; forcing fresh prepare", "error", err) return nil } } // Roll back the previous dispatch's sidecar writes before refreshing. // On reuse the workdir still holds the prior run's skill directories (and, // for a workdir prepared before MUL-6984, its issue_context.md); without // clearing them first, writeSkillFiles sees // its own earlier output occupying the canonical slug and falls back to // a collision-free sibling (issue-review, issue-review-multica, // issue-review-multica-2, …), accumulating a fresh duplicate on every // re-dispatch to the same issue. allocateCollisionFreeSkillDir exists to // dodge *user*-owned skill dirs (the local_directory flow), not our own // prior writes, so we undo them via the prior manifest first and let the // refresh below re-create each skill at its natural slug. This also brings // the standard providers in line with the Codex path, where // hydrateCodexSkills already wipes its skills dir before re-hydrating. // // Two steps, in order: // 1. removeReusedManagedSkillDirs reclaims the platform's own skill // directories even when a prior-run agent left a file inside one. // CleanupSidecars alone can't do this — it preserves any recorded dir // the agent populated (correct on the local_directory teardown path), // which would otherwise keep the canonical slug occupied and push the // refresh back to issue-review-multica. // 2. CleanupSidecars rolls back the remaining sidecar files (project // resources today, plus any issue_context.md recorded by a manifest // an older build wrote — legacy upgrade cleanup, not a live writer) // and the manifest itself. // // No-op when RootDir is empty (legacy local_directory reuse, which the // daemon skips anyway) or when no prior manifest exists (older build). if env.RootDir != "" { if err := removeReusedManagedSkillDirs(env.RootDir, skillsDirPath(params.WorkDir, params.Provider)); err != nil { logger.Warn("execenv: reclaim managed skill dirs on reuse failed", "error", err) } if err := CleanupSidecars(env.RootDir); err != nil { logger.Warn("execenv: roll back prior sidecars on reuse failed", "error", err) } } // Refresh context files (skills, project resources). Reuse tracks a // fresh manifest under env.RootDir so a later CleanupSidecars sees // the up-to-date list of writes (an old manifest from a prior run // would otherwise reference files this Reuse no longer creates). For // local_directory tasks the daemon skips Reuse entirely (see // daemon.runTask), but writing the manifest unconditionally keeps // Prepare/Reuse symmetric so a future caller can rely on the // manifest being current after either path. RootDir is empty on the // legacy local_directory Reuse fallback — skip the persist in that // case to avoid creating a stray manifest at the filesystem root. manifest := &sidecarManifest{} if err := writeContextFiles(params.WorkDir, params.Provider, params.Task, manifest); err != nil { logger.Warn("execenv: refresh context files failed", "error", err) } if err := prepareOmpMcpConfig(params.WorkDir, params.Provider, params.McpConfig, manifest); err != nil { logger.Warn("execenv: refresh omp mcp config failed; forcing fresh prepare", "error", err) return nil } // Restore CodexHome for Codex provider — the per-task codex-home directory // lives alongside the workdir. Re-run prepareCodexHomeWithOpts to ensure // config (especially sandbox/network access) is up to date. if params.Provider == "codex" { codexHome := filepath.Join(env.RootDir, codexHomeDirName) if err := prepareCodexHomeWithOpts(codexHome, CodexHomeOptions{CodexVersion: params.CodexVersion, ResumeSessionID: params.ResumeSessionID, IsLocalDirectory: params.LocalDirectory, SessionStoreKey: codexSessionStoreKey(params.Profile, params.Task), CodexCustomArgs: params.CodexCustomArgs}, logger); err != nil { // Leaving env.CodexHome empty does not launch Codex against an // ambient home: configureCodexTaskShellEnvironment rejects the empty // value ("task CODEX_HOME is missing") and the run fails before // launch. Decline the reuse instead, so the caller falls back to // Prepare and the task still gets a usable task-local home. The // prior session is not carried over, and a fresh Prepare that fails // the same way still stops the task. logger.Warn("execenv: refresh codex-home failed; forcing fresh prepare", "error", err) return nil } else { env.CodexHome = codexHome if err := hydrateCodexSkills(codexHome, params.Task.AgentSkills, params.Task.DisabledRuntimeSkills, logger); err != nil { logger.Warn("execenv: refresh codex skills failed", "error", err) } } } if params.Provider == "claude" && env.RootDir != "" { settingsPath, err := prepareClaudeSkillSettings(env.RootDir, params.Task.DisabledRuntimeSkills, params.Task.AgentSkills) if err != nil { logger.Warn("execenv: refresh claude skill settings failed", "error", err) } else { env.ClaudeSettingsPath = settingsPath } } // Re-deny Reasonix's `ask` tool on reuse: CleanupSidecars above removed the // prior run's reasonix.toml, so without this the next turn would run with // the tool available again. if params.Provider == "reasonix" { if err := writeReasonixProjectConfig(params.WorkDir, params.ReasonixEnv, manifest, logger); err != nil { logger.Warn("execenv: refresh reasonix project config failed", "error", err) } } // Refresh (or tear down) the per-task QwenPaw workspace on reuse. // Rebuild the workspace so an added/removed/edited skill is reflected. if params.Provider == "qwenpaw" && env.RootDir != "" { qwenpawWorkspace := filepath.Join(env.RootDir, "qwenpaw-workspace") if err := prepareQwenpawWorkspace(qwenpawWorkspace, params.Task.AgentSkills, logger); err != nil { logger.Warn("execenv: refresh qwenpaw workspace failed; forcing fresh prepare", "error", err) return nil } env.QwenpawWorkspace = qwenpawWorkspace } // Refresh (or tear down) the per-task HERMES_HOME on reuse. With skills // bound, rebuild the overlay so an added/removed/edited skill and the // mirrored home/config track the user's current ~/.hermes/ before the next // hermes process starts. With no skills bound, drop the redirect entirely so // the task reverts to the user's real home — matching a fresh Prepare for a // skill-less agent. if params.Provider == "hermes" && env.RootDir != "" { hermesHome := filepath.Join(env.RootDir, "hermes-home") if len(params.Task.AgentSkills) > 0 { sessions, err := prepareHermesHome(hermesHome, params.HermesSourceHome, params.HermesSourceMustExist, params.Task.AgentSkills, params.HermesEnv, params.HermesMemoryStore, params.HermesSessionStore, logger) if err != nil { // Fail closed: a half-built overlay must not run. Returning nil // makes the daemon fall back to a fresh Prepare, whose error // then blocks dispatch rather than silently dropping the bound // skill. logger.Warn("execenv: refresh hermes-home failed; forcing fresh prepare", "error", err) return nil } env.HermesHome = sessions.Home env.HermesSessionStore = "" env.HermesSessionHistoryPresent = false if sessions.Mounted { env.HermesSessionStore = params.HermesSessionStore env.HermesSessionHistoryPresent = sessions.HistoryPresent } } else { env.HermesHome = "" env.HermesSessionStore = "" env.HermesSessionHistoryPresent = false if err := os.RemoveAll(hermesHome); err != nil { logger.Warn("execenv: remove stale hermes-home failed", "error", err) } } } // Refresh Cursor's managed MCP sidecars on reuse. A newly saved agent // mcp_config must replace the prior run's .cursor/mcp.json and isolated // approvals before the next cursor-agent process starts. if params.Provider == "cursor" && env.RootDir != "" { cursorDataDir, err := prepareCursorMcpConfig(env.RootDir, params.WorkDir, params.McpConfig, params.CursorMcpAuthSource, manifest) if err != nil { logger.Warn("execenv: refresh cursor mcp config failed", "error", err) return nil } env.CursorDataDir = cursorDataDir } if env.RootDir != "" { if err := writeSidecarManifest(env.RootDir, manifest); err != nil { logger.Warn("execenv: refresh sidecar manifest failed", "error", err) } } // Refresh the per-task OpenClaw config on reuse — the user may have // added/removed agents or rotated providers since the prior task ran, // and the workspace override always re-targets the current workDir. // Fail closed: a user config that can no longer be parsed should block // reuse rather than degrade to a minimal config that boots OpenClaw // without the registered agents. if params.Provider == "openclaw" { result, err := prepareOpenclawConfig(env.RootDir, params.WorkDir, OpenclawConfigPrep{ OpenclawBin: params.OpenclawBin, CacheDir: openclawProfileCacheDir(params.Profile, logger), McpConfig: params.McpConfig, Gateway: params.OpenclawGateway, Logger: logger, }) if err != nil { logger.Warn("execenv: refresh openclaw config failed", "error", err) return nil } env.OpenclawConfigPath = result.ConfigPath env.OpenclawIncludeRoot = result.IncludeRoot } logger.Info("execenv: reusing env", "workdir", params.WorkDir) return env } // hydrateCodexSkills populates the per-task CODEX_HOME/skills directory with // both user-installed skills (from the shared ~/.codex/skills/) and // workspace-assigned skills. Workspace skills win on name conflict — they are // written last and seedUserCodexSkills already pre-filters their names. // // The skills directory is wiped first so two stale-state classes that the // Reuse path would otherwise leak are gone: // // - A name now claimed by a workspace skill that previously held only a // user-seeded copy — support files from the user version would otherwise // linger under the workspace skill's directory. // - A user skill removed from the shared ~/.codex/skills/ since the last // run — its old contents would otherwise remain visible to the codex // CLI. // // Codex is the only runtime that needs this two-stage hydration because the // daemon sets CODEX_HOME to a per-task directory, isolating the CLI from the // user's real ~/.codex/. Other runtimes leave HOME untouched and discover // user-level skills natively (see context.go for the workdir-local paths // they use for workspace skills). func hydrateCodexSkills(codexHome string, workspaceSkills []SkillContextForEnv, disabledRuntimeSkills []RuntimeSkillRefForEnv, logger *slog.Logger) error { skillsDir := filepath.Join(codexHome, "skills") if err := os.RemoveAll(skillsDir); err != nil { return fmt.Errorf("clear codex skills dir: %w", err) } if err := seedUserCodexSkills(codexHome, workspaceSkills, logger); err != nil { logger.Warn("execenv: seed user codex skills failed", "error", err) } if len(workspaceSkills) > 0 { if err := writeSkillFiles(skillsDir, workspaceSkills, nil); err != nil { return err } } return ensureCodexDisabledSkillsConfig(filepath.Join(codexHome, "config.toml"), codexHome, disabledRuntimeSkills, workspaceSkills) } // GCMetaKind identifies which kind of parent record a task workdir belongs to. // The GC loop dispatches its decision tree on this value so chat / autopilot / // quick-create tasks are no longer forced through the issue-centric path. type GCMetaKind string const ( GCKindIssue GCMetaKind = "issue" GCKindChat GCMetaKind = "chat" GCKindAutopilotRun GCMetaKind = "autopilot_run" GCKindQuickCreate GCMetaKind = "quick_create" ) // GCMeta is persisted to .gc_meta.json inside the env root so the GC loop // can decide whether the directory is reclaimable. It is a discriminated // union keyed on Kind: only the parent ID field matching Kind is meaningful. // TaskID is also persisted for every new task so local reports never need to // recover task identity from the directory name; for quick-create it doubles // as the parent ID used by the GC status endpoint. // // Older meta files (pre-v2) lack the Kind field; readers must default empty // Kind to GCKindIssue for backward compatibility — only IssueID was written // before, and only issue-centric tasks ever produced a meta file. type GCMeta struct { Kind GCMetaKind `json:"kind,omitempty"` IssueID string `json:"issue_id,omitempty"` ChatSessionID string `json:"chat_session_id,omitempty"` AutopilotRunID string `json:"autopilot_run_id,omitempty"` TaskID string `json:"task_id,omitempty"` WorkspaceID string `json:"workspace_id"` CompletedAt time.Time `json:"completed_at"` // LocalDirectory marks tasks whose WorkDir pointed at a user-owned // path rather than the synthesised envRoot/workdir. The GC loop honours // this by never falling into the gcActionClean branch (which would // RemoveAll envRoot — safe by structure, but we still want to keep the // envRoot's output/ and logs/ around longer so users can inspect what // the agent did in their own tree). Pattern-based artifact cleanup is // still allowed. LocalDirectory bool `json:"local_directory,omitempty"` } const gcMetaFile = ".gc_meta.json" // WriteGCMeta writes GC metadata into the given directory. The caller is // responsible for choosing Kind and populating the matching ID field; // CompletedAt is stamped here so callers don't have to think about clocks. func WriteGCMeta(envRoot string, meta GCMeta, logger *slog.Logger) error { if envRoot == "" { return nil } if meta.Kind == "" { // Defensive: a task that doesn't fit any known kind would write a // meta file the GC loop can't dispatch on. Skip silently — the // directory falls back to the orphan-by-mtime path. logger.Debug("execenv: skipping .gc_meta.json write: kind is empty", "envRoot", envRoot) return nil } meta.CompletedAt = time.Now().UTC() data, err := json.Marshal(meta) if err != nil { return fmt.Errorf("marshal gc meta: %w", err) } return os.WriteFile(filepath.Join(envRoot, gcMetaFile), data, 0o644) } // ReadGCMeta reads GC metadata from a task directory root. Pre-v2 meta files // (no kind field) are normalized to GCKindIssue so the legacy issue path // keeps working without a migration. func ReadGCMeta(envRoot string) (*GCMeta, error) { data, err := os.ReadFile(filepath.Join(envRoot, gcMetaFile)) if err != nil { return nil, err } var meta GCMeta if err := json.Unmarshal(data, &meta); err != nil { return nil, err } if meta.Kind == "" { meta.Kind = GCKindIssue } return &meta, nil } const managedEnvProvenanceFile = ".managed_env.json" // ManagedEnvProvenanceManagedBy discriminates a managed-env provenance file // the daemon wrote from any lookalike JSON that happens to share the path. const ManagedEnvProvenanceManagedBy = "multica-daemon-managed-env" // ManagedEnvProvenance is persisted to .managed_env.json inside the env root at // Prepare time (NOT on completion, unlike .gc_meta.json). It records that this // env root is a daemon-managed, non-local_directory resumable env owned by a // specific workspace, conversation scope, and agent. // // Its whole reason to exist is timing. A squad-leader follow-up on the same // issue can be claimed the instant the prior task completes — the server's // task-complete handler reconciles the follow-up and wakes the runtime before // the prior task's daemon handler writes .gc_meta.json. Keying reuse // eligibility off .gc_meta.json therefore raced: the successor read a // not-yet-written file and started a fresh session (MUL-4886). This marker is // on disk from the moment the env is created, so the successor can prove reuse // safety inside that window. It is written only for non-local managed issue or // chat envs, so its presence is itself the "safe to reuse, not a user // local_directory" assertion; see shouldReusePriorWorkdir. type ManagedEnvProvenance struct { ManagedBy string `json:"managed_by"` WorkspaceID string `json:"workspace_id"` IssueID string `json:"issue_id,omitempty"` ChatSessionID string `json:"chat_session_id,omitempty"` AgentID string `json:"agent_id"` } // WriteManagedEnvProvenance persists the reuse-eligibility marker at the env // root. Callers must only invoke it for non-local_directory resumable envs, since // the file's presence is the non-local assertion. ManagedBy is stamped here so // callers cannot forget the discriminator. func WriteManagedEnvProvenance(envRoot string, p ManagedEnvProvenance) error { if envRoot == "" { return nil } p.ManagedBy = ManagedEnvProvenanceManagedBy data, err := json.Marshal(p) if err != nil { return fmt.Errorf("marshal managed env provenance: %w", err) } return os.WriteFile(filepath.Join(envRoot, managedEnvProvenanceFile), data, 0o644) } // ReadManagedEnvProvenance reads the Prepare-time reuse-eligibility marker from // an env root. A missing or malformed file returns an error; callers fail // closed (no reuse) on any error. func ReadManagedEnvProvenance(envRoot string) (*ManagedEnvProvenance, error) { data, err := os.ReadFile(filepath.Join(envRoot, managedEnvProvenanceFile)) if err != nil { return nil, err } var p ManagedEnvProvenance if err := json.Unmarshal(data, &p); err != nil { return nil, err } return &p, nil } // Cleanup tears down the execution environment. // If removeAll is true, the entire env root is deleted. Otherwise, workdir is // removed but output/ and logs/ are preserved for debugging. // // For local_directory tasks (env.LocalDirectory==true) WorkDir is the // user's own path — Cleanup MUST NEVER delete it, regardless of removeAll. // In that mode we only ever delete the envRoot scratch directory. func (env *Environment) Cleanup(removeAll bool) error { if env == nil { return nil } // Drop the execution lock first. Until it is released the env root still // reads as "a live execution owns this", which would make a legitimate // rerun fail closed. The daemon also defers ReleaseLock for the task run; // both paths are idempotent. env.ReleaseLock() if env.LocalDirectory { // Never touch the user's directory. RootDir is the daemon's own // scratch; safe to remove when the caller asked for a full // teardown. if removeAll && env.RootDir != "" { if err := os.RemoveAll(env.RootDir); err != nil { env.logger.Warn("execenv: cleanup local_directory envRoot failed", "error", err) return err } } return nil } if removeAll { if err := os.RemoveAll(env.RootDir); err != nil { env.logger.Warn("execenv: cleanup removeAll failed", "error", err) return err } return nil } // Partial cleanup: remove workdir, keep output/ and logs/. if err := os.RemoveAll(env.WorkDir); err != nil { env.logger.Warn("execenv: cleanup workdir failed", "error", err) return err } return nil } // EnvRootClaim is a held claim on a task's env root. It exists so the claim can // outlive the code that prepares the directory. // // Production preparation runs in a short-lived helper process // (PrepareIsolated / ReuseIsolated). A lock taken inside that helper is // released by the kernel as soon as it exits, and *os.File cannot travel back // through the helper's JSON response — so a claim taken there protects // nothing by the time the agent actually runs. The daemon parent takes the // claim instead and holds it for the whole task run. type EnvRootClaim struct { rootDir string lock *os.File } // RootDir is the env root this claim covers. func (c *EnvRootClaim) RootDir() string { if c == nil { return "" } return c.rootDir } // Release drops the claim. Safe on nil and safe to call twice. func (c *EnvRootClaim) Release() { if c == nil || c.lock == nil { return } releaseLockFile(c.lock) c.lock = nil } // ClaimEnvRoot takes the full claim for a fresh preparation of taskID: it // proves ownership, refuses a root a live execution or another task holds, and // resets a stale root this task owns so the caller receives a clean directory. // // Callers that pass the claim to Prepare must also set // PrepareParams.EnvRootPreclaimed, or Prepare will try to take a lock this // claim already holds and fail. func ClaimEnvRoot(params RootDirParams) (*EnvRootClaim, error) { envRoot, err := ResolveRootDir(params) if err != nil { return nil, fmt.Errorf("execenv: resolve env root: %w", err) } if envRoot == "" { return nil, fmt.Errorf("execenv: claim env root: workspaces root, workspace ID and task ID are all required") } lock, reset, err := claimEnvRoot(envRoot, params.WorkspaceID, params.TaskID) if err != nil { return nil, fmt.Errorf("execenv: %w", err) } if reset { if err := resetEnvRootContents(envRoot); err != nil { releaseLockFile(lock) return nil, fmt.Errorf("execenv: reset existing env: %w", err) } } return &EnvRootClaim{rootDir: envRoot, lock: lock}, nil } // ErrEnvRootBusy reports that a live execution already holds an env root. On // the reuse path it is a reason to fall back to a fresh Prepare, not to fail // the task. var ErrEnvRootBusy = errors.New("env root is held by a running execution") // LockEnvRootForReuse takes the exclusion half of a claim, without the identity // half, for a task continuing in a PRIOR task's directory. // // Reuse adopts another task's env root on purpose, so the owner marker names // that earlier task and must not be reinterpreted or overwritten — but two // continuations of the same task still must not refresh and run the same // directory at once. // // wsRoot must be a Root the CALLER opened before it validated anything, and // rel the env root's path within it. Both matter: // // - Opening the Root here, from a name the caller validated a moment ago, // would re-resolve that name. Renaming the whole workspaces root aside and // leaving a symlink to a look-alike tree in its place would have os.Root // faithfully pin the replacement — Root guarantees you cannot escape the // tree it opened, not that it opened the tree you meant. // - Every operation below goes through ONE sub-Root pinned on the env root, // so the directory whose identity is returned and the directory the lock // file is created in cannot be two different directories. Resolving rel // twice from wsRoot would allow exactly that: A on the first resolution, // B on the second. // // It returns that directory's FileInfo so the caller can confirm it is the one // validation approved; Root still follows symlinks that stay inside the tree, // so "locked A, reused B" has to be ruled out by identity, not containment. // // Returns a nil claim and no error when the env root is missing, and // ErrEnvRootBusy when another execution holds it — both mean "fall back to a // fresh Prepare". func LockEnvRootForReuse(wsRoot *os.Root, rel, envRoot string) (*EnvRootClaim, os.FileInfo, error) { if wsRoot == nil || rel == "" || !filepath.IsLocal(rel) { return nil, nil, fmt.Errorf("execenv: env root %s is not inside the workspaces root", envRoot) } // Pin the env root itself. From here on nothing is resolved by name again. envRootHandle, err := wsRoot.OpenRoot(rel) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil, nil, nil } return nil, nil, fmt.Errorf("execenv: open env root %s: %w", envRoot, err) } defer envRootHandle.Close() info, err := envRootHandle.Stat(".") if err != nil { return nil, nil, fmt.Errorf("execenv: inspect env root %s: %w", envRoot, err) } if !info.IsDir() { return nil, nil, nil } lock, err := envRootHandle.OpenFile(envRootLockFile, os.O_CREATE|os.O_RDWR, 0o644) if err != nil { return nil, nil, fmt.Errorf("execenv: open env root lock for %s: %w", envRoot, err) } locked, err := lockFileExclusiveNonBlocking(lock) if err != nil { lock.Close() return nil, nil, fmt.Errorf("execenv: lock env root %s: %w", envRoot, err) } if !locked { lock.Close() return nil, nil, fmt.Errorf("execenv: reuse %s: %w", envRoot, ErrEnvRootBusy) } return &EnvRootClaim{rootDir: envRoot, lock: lock}, info, nil } // envRootOwnerFile records which workspace and task an env root belongs to: // WHO owns it. The execution lock below separately answers whether that owner // is still running. const envRootOwnerFile = ".task_owner" const ( envRootOwnerTempPrefix = ".task_owner-" envRootOwnerTempSuffix = ".tmp" ) // envRootLockFile carries the env root's exclusive execution lock: whether the // owner is STILL RUNNING. The two answer different questions and both are // needed — a dead task's directory still holds its work, and a live task's // directory must not be reset even by another execution of the same task. const envRootLockFile = ".task_lock" // claimEnvRoot takes exclusive ownership of envRoot for taskID. // // It returns the held lock file — the caller owns it until Cleanup — and // whether the directory needs resetting before use. // // The lock is an OS advisory lock, and the kernel releases it when the holding // process exits for any reason. That is what makes it a lock rather than a // third marker file: it answers "is the previous execution still alive?" // across processes, with no heartbeat, no PID table and no stale-state cleanup // path. An identity marker alone cannot answer it, which is how the previous // design let a re-dispatched execution of the SAME task id wipe the directory // of an execution that was still running (#7326 follow-up): // // - The server re-delivers a task row whose prepare lease expired // (ReclaimStaleDispatchedTaskForRuntime) using the same task id. // - A failing lease renewal only logs; it does not cancel the Prepare that // is still in flight. // - So two Prepare calls for one task id could overlap, and "owner == mine" // read as "my own rerun, safe to reset". // // Holding the lock also serialises everything below it, which is what makes // repairing a torn marker safe: no other execution can be mid-claim. func claimEnvRoot(envRoot, workspaceID, taskID string) (lockFile *os.File, reset bool, err error) { if err := os.MkdirAll(envRoot, 0o755); err != nil { return nil, false, fmt.Errorf("create env root %s: %w", envRoot, err) } lockFile, err = openLockFile(filepath.Join(envRoot, envRootLockFile)) if err != nil { return nil, false, fmt.Errorf("open env root lock for %s: %w", envRoot, err) } locked, err := lockFileExclusiveNonBlocking(lockFile) if err != nil { lockFile.Close() return nil, false, fmt.Errorf("lock env root %s: %w", envRoot, err) } if !locked { lockFile.Close() return nil, false, fmt.Errorf("env root %s is held by a running execution; refusing to reset it for task %s", envRoot, taskID) } // Past this point we are the only execution touching this env root, in this // process or any other, so the checks below cannot race. defer func() { if err != nil { releaseLockFile(lockFile) lockFile = nil } }() if err := removeStaleEnvRootOwnerTemps(envRoot); err != nil { return nil, false, fmt.Errorf("remove stale env root owner temp files for %s: %w", envRoot, err) } owner, err := ReadEnvRootOwner(envRoot) if err != nil { return nil, false, fmt.Errorf("read env root owner for %s: %w", envRoot, err) } switch { case owner.TaskID == taskID && (owner.WorkspaceID == "" || owner.WorkspaceID == workspaceID): // Upgrade legacy task-only markers while the lock makes the rewrite // exclusive. Disk usage can then attribute active roots by workspace. if owner.WorkspaceID == "" { if err := writeEnvRootOwner(envRoot, workspaceID, taskID); err != nil { return nil, false, err } } // Ours, and the execution that left it is provably gone — we hold the // lock it would still be holding. return lockFile, true, nil case owner.TaskID != "": return nil, false, fmt.Errorf("env root %s belongs to task %s in workspace %s; refusing to reset it for task %s in workspace %s", envRoot, owner.TaskID, owner.WorkspaceID, taskID, workspaceID) } // No owner recorded. Either the directory is new, or a crash tore the // marker before it named anyone. Work with no owner is never ours to // delete; an env root with nothing in it costs nothing to adopt. hasWork, err := envRootHoldsWork(envRoot) if err != nil { return nil, false, fmt.Errorf("inspect env root %s: %w", envRoot, err) } if hasWork { return nil, false, fmt.Errorf("env root %s already holds files but names no owning task; refusing to delete it", envRoot) } if err := writeEnvRootOwner(envRoot, workspaceID, taskID); err != nil { return nil, false, err } return lockFile, false, nil } // ReleaseLock drops the env root's execution lock. The daemon defers this for // the duration of a task run: the lock's lifetime is the EXECUTION, not this // value. Cleanup is not the right place on its own — production never calls it // (the GC reclaims env roots later), so a lock released only there would be // held until the daemon exits and would fail-closed every legitimate rerun of // that task. Safe on nil and safe to call twice. func (env *Environment) ReleaseLock() { if env == nil || env.lockFile == nil { return } releaseLockFile(env.lockFile) env.lockFile = nil } // releaseLockFile drops the execution lock and closes the file. Safe on nil. func releaseLockFile(f *os.File) { if f == nil { return } _ = unlockFile(f) _ = f.Close() } // writeEnvRootOwner records authoritative workspace/task identity. Callers // must hold the env root lock, which makes overwriting a torn or legacy marker // safe from another execution racing mid-claim. The same-directory temp file // and rename keep lock-free readers from observing a truncated JSON marker. func writeEnvRootOwner(envRoot, workspaceID, taskID string) error { path := filepath.Join(envRoot, envRootOwnerFile) data, err := json.Marshal(EnvRootOwner{WorkspaceID: workspaceID, TaskID: taskID}) if err != nil { return fmt.Errorf("encode env root owner for %s: %w", envRoot, err) } tmp, err := os.CreateTemp(envRoot, envRootOwnerTempPrefix+"*"+envRootOwnerTempSuffix) if err != nil { return fmt.Errorf("create temp env root owner for %s: %w", envRoot, err) } tmpPath := tmp.Name() defer os.Remove(tmpPath) if _, err := tmp.Write(data); err != nil { tmp.Close() return fmt.Errorf("write temp env root owner for %s: %w", envRoot, err) } if err := tmp.Chmod(0o644); err != nil { tmp.Close() return fmt.Errorf("chmod temp env root owner for %s: %w", envRoot, err) } if err := tmp.Sync(); err != nil { tmp.Close() return fmt.Errorf("sync temp env root owner for %s: %w", envRoot, err) } if err := tmp.Close(); err != nil { return fmt.Errorf("close temp env root owner for %s: %w", envRoot, err) } if err := os.Rename(tmpPath, path); err != nil { return fmt.Errorf("replace env root owner for %s: %w", envRoot, err) } return nil } // removeStaleEnvRootOwnerTemps clears unpublished files left by a process that // exited before the atomic rename. Callers hold the env root lock, so no live // owner write can be using one of these files. func removeStaleEnvRootOwnerTemps(envRoot string) error { entries, err := os.ReadDir(envRoot) if err != nil { return err } for _, entry := range entries { name := entry.Name() if !strings.HasPrefix(name, envRootOwnerTempPrefix) || !strings.HasSuffix(name, envRootOwnerTempSuffix) { continue } if err := os.RemoveAll(filepath.Join(envRoot, name)); err != nil { return err } } return nil } // readEnvRootOwner returns the task id that owns envRoot, or "" when no marker // is present or it was never written through. An unreadable marker is an // error, not an empty owner: treating it as unowned would hand the caller a // licence to delete the very directory it could not identify. func readEnvRootOwner(envRoot string) (string, error) { owner, err := ReadEnvRootOwner(envRoot) if err != nil { return "", err } return owner.TaskID, nil } // EnvRootOwner is written before any task content so active and partially // prepared roots retain authoritative identity without completion metadata. type EnvRootOwner struct { WorkspaceID string `json:"workspace_id,omitempty"` TaskID string `json:"task_id"` } // ReadEnvRootOwner reads both current JSON markers and legacy plain task IDs. func ReadEnvRootOwner(envRoot string) (*EnvRootOwner, error) { b, err := os.ReadFile(filepath.Join(envRoot, envRootOwnerFile)) if errors.Is(err, os.ErrNotExist) { return &EnvRootOwner{}, nil } if err != nil { return nil, err } trimmed := strings.TrimSpace(string(b)) if !strings.HasPrefix(trimmed, "{") { return &EnvRootOwner{TaskID: trimmed}, nil } var owner EnvRootOwner if err := json.Unmarshal(b, &owner); err != nil { return nil, err } owner.TaskID = strings.TrimSpace(owner.TaskID) owner.WorkspaceID = strings.TrimSpace(owner.WorkspaceID) return &owner, nil } // resetEnvRootContents empties an env root the caller already owns and holds // the lock on, keeping the bookkeeping files. Removing and recreating the // directory instead would drop both the claim and the lock for as long as the // recreate takes, which is exactly the window claimEnvRoot exists to close. func resetEnvRootContents(envRoot string) error { entries, err := os.ReadDir(envRoot) if err != nil { return err } for _, e := range entries { if isEnvRootBookkeeping(e.Name()) { continue } if err := os.RemoveAll(filepath.Join(envRoot, e.Name())); err != nil { return err } } return nil } // envRootHoldsWork reports whether envRoot contains anything beyond the owner // marker and the lock file — that is, anything a task could lose. func envRootHoldsWork(envRoot string) (bool, error) { entries, err := os.ReadDir(envRoot) if err != nil { return false, err } for _, e := range entries { if !isEnvRootBookkeeping(e.Name()) { return true, nil } } return false, nil } func isEnvRootBookkeeping(name string) bool { if name == envRootOwnerFile || name == envRootLockFile { return true } // An unpublished owner temp is a crash leftover from writeEnvRootOwner, not // task content. claimEnvRoot clears these before it looks, but // findOwnedTaskRoot inspects candidate roots WITHOUT the lock — and reading // one as work makes adoption refuse a root that holds nothing a task could // lose, wedging that task permanently. return strings.HasPrefix(name, envRootOwnerTempPrefix) && strings.HasSuffix(name, envRootOwnerTempSuffix) }