Files
multica/server/internal/daemon/execenv/execenv.go
T
90a88519df fix(daemon): defer issue status rules to project workflow (MUL-8145) (#9415)
* fix(daemon): defer issue status rules to the project workflow (MUL-8145)

When the claim carries a project workflow, the runtime brief no longer
tells the agent to set in_progress first and in_review on delivery. Those
statuses may be outside the workflow (the server rejects them), and
entering a handoff step reassigns the issue. The brief now points at the
per-turn [Project workflow] block, and the workflow brief carries the
entry/exit checks in workflow terms. Briefs without a workflow are
byte-identical to before.

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

* fix(squad): word the owning leader's status moves by the project workflow (MUL-8145)

The squad protocol (Agent Identity, which outranks the runtime brief) still
told an owning leader to move the parent to in_progress and then in_review,
which a workflow may not list and which can hand the parent to another
step's handler. When the claim carries a project workflow, responsibility 6
now keeps the ownership grant but moves the parent only through the
[Project workflow] commands. The claim resolves the workflow before the
briefing to choose the wording. Guest leaders and issues without a
workflow are unchanged.

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

* refactor(daemon): list no status keys in the brief for a workflow issue (MUL-8145)

With a project workflow, [Project workflow] already lists the issue's
statuses and the commands that move it, and the built-in keys may not be
accepted there. The status command line now keeps only the command and
points at that block; other issues learn valid statuses from the error.

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

* refactor(workflow): stop forbidding status moves nothing asks for (MUL-8145)

Under a project workflow the current runtime brief no longer states the
built-in in_progress / in_review moves, so the lines that forbade them
(in [Project workflow], the runtime brief and the squad protocol) only
named statuses the agent had no reason to use. Drop them, and the exit
check the runtime brief already states.

Older daemons still state the built-in rules, so the daemon now
advertises workflow-status-rules-v1; without it the claim keeps the
override lines in [Project workflow] and the squad protocol. The
multica-platform skill notes that [Project workflow] supersedes its
built-in status arc.

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

* refactor(workflow): shorten the step's moves in the workflow brief (MUL-8145)

Replace the handoff preamble with a plain "Your moves:" heading, name
who each move hands off to on its own line, and drop the "this
workflow has no blocked status" explanations from Can't proceed.

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

* refactor(workflow): state each step outcome as the status to set (MUL-8145)

The moves block read like raw CLI ('Step done → multica issue status MUL-7 x').
Each outcome now says which status to set and that the server assigns the
next handler; the CLI form is given once.

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

* Revert "refactor(workflow): state each step outcome as the status to set (MUL-8145)"

This reverts commit 2d82d2d342a99afe88cc40c48516b7ea8e860d1e.

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

---------

Co-authored-by: multica-eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-10-26 14:27:09 +08:00

1676 lines
77 KiB
Go

// 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) the overlay links state.db to, 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 path to the per-task 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). 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 session store this task's
// state.db is actually linked to, or "" when the session database stayed
// task-local (no store to key on, or a host that could not create the
// link).
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 <dir> 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
// <workspace UUID>/<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 <url>`.
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 = hermesHome
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 = hermesHome
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)
}