// 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)
}