mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
* refactor(issues): remove the assignment handoff note (MUL-6743) Usage data puts the handoff note at 0.2% of assignments, so the box occupied the confirm dialog's centre for the other 99.8% and bought them nothing. Removing it takes the whole feature with it: the note input and its runtime version gate, the `handoff_note` write field on PATCH/batch issue updates, the task column write, and the daemon's opening-prompt + issue_context.md handoff sections. The confirm dialog itself stays — it still names who is about to run and still offers "Don't start yet" (suppress_run). The `*WithHandoff` enqueue methods only ever varied by that note, so they become `EnqueueTaskForIssueByActor` / `EnqueueTaskForSquadLeaderByActor` — the actor-carrying variants autopilot already called with an empty note. `preview-trigger` drops `handoff_supported`, which nothing read after the gate moved client-side. `agent_task_queue.handoff_note` is deliberately left in place: it holds historical rows and this is the expand half of an expand-contract pair. Nothing reads or writes it now, so a follow-up migration can drop it once no deployed binary selects it. Co-authored-by: multica-agent <[email protected]> * docs(issues): reshoot the assignment confirm dialog (MUL-6743) The asset still showed the handoff note box the surrounding prose no longer describes. Recaptured from the app at the same 600x450 frame; the confirm button now carries the send-chord keycaps it has shipped with since MUL-5694. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Lambda <[email protected]> Co-authored-by: multica-agent <[email protected]>
1661 lines
76 KiB
Go
1661 lines
76 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
|
|
// 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* identify the actor who triggered THIS task (the real
|
|
// requester) as distinct from the runtime owner. Rendered into the brief
|
|
// as `## Task Initiator` when a name is present; InitiatorEmail is shown
|
|
// only for member initiators. Empty for on-assign / autopilot /
|
|
// quick-create tasks, which have no attributable human initiator. See
|
|
// MUL-2645.
|
|
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 (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 issue_context.md and
|
|
// skill directories; 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
|
|
// (issue_context.md, project resources) 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 (issue_context.md, skills). 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 {
|
|
logger.Warn("execenv: refresh codex-home failed", "error", err)
|
|
} 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)
|
|
}
|