Files
multica/server/internal/daemon/execenv/execenv.go
Naiyuan Qing 4ab335b8a5 MUL-3416: Issue pre-trigger preview + Handoff Note (#4383)
* feat(issues): unify run-enqueue decision behind WillEnqueueRun + preview endpoint

Collapse the issue update/batch enqueue copies into one service predicate
service.IssueService.WillEnqueueRun, shared verbatim with a new dry-run
endpoint POST /api/issues/preview-trigger so the four entry points stop
drifting (squad/self-loop/batch omissions, MUL-3375). The private-agent gate
stays at the HTTP boundary: write paths inject allow-all, preview injects the
real gate so it never leaks a private agent's readiness.

Add suppress_run to issue update/batch: the change applies but no run starts.
Remove the now-dead handler mirrors shouldEnqueueSquadLeaderOnAssign /
isSquadLeaderReady. service.Create and the comment trigger chain are untouched.

Tests: preview behavior, preview<->write-path match, batch aggregation,
member no-trigger, suppress_run skip, malformed-body 400.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* feat(issues): inject handoff note into assigned runs via first-class task field

Add an optional handoff_note carried by issue assign/promote into the run's
opening prompt and issue_context.md, via a dedicated agent_task_queue column
(migration 122) and a daemon assignment-handoff render branch — never a
fabricated comment, never trigger_comment_id (MUL-3375 §6.1).

Thread the note through enqueueIssueTask/enqueueMentionTask + WithHandoff
public variants and dispatchIssueRun; suppress_run or a parked write drops it
(no run = nothing to inject). Soft version gate: MinHandoffCLIVersion +
HandoffSupported, surfaced per-trigger as handoff_supported in the preview so
the UI can gray the note box on old daemons; the assignment never hard-fails.

Tests: daemon prompt + issue_context render via the assignment branch (not
quick-create/comment), version helper matrix, note persists on the task,
suppressed assign enqueues nothing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* feat(issues): leave a display-only handoff record on the timeline

When an assign/promote with a handoff note starts a run, write one
type='handoff' timeline record via TaskService.RecordHandoff — a direct
Queries.CreateComment + timeline event that bypasses Handler.CreateComment, so
it never reaches triggerTasksForComment and cannot start a second run
(MUL-3375 §6.2, the must-not-retrigger invariant). Author is the actor who
handed off; body is the note. Migration 123 admits the 'handoff' comment type.
Recorded only on a real run start: suppress_run or a parked write writes
nothing. enqueueSquadLeaderTask now reports whether it enqueued so the trace
is gated on an actual dispatch.

Test: exactly one handoff record on assign-with-note, exactly one task (no
re-trigger), and no record when suppressed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* feat(issues): frontend plumbing for issue-trigger preview + handoff (core)

Add api.previewIssueTrigger + IssueTriggerPreviewSchema (zod parseWithFallback),
the use-issue-trigger-preview hook, issueKeys.issueTriggerPreview(+All) with WS
queue-state invalidation, suppress_run/handoff_note on UpdateIssueRequest, the
'handoff' CommentType, and stripping of the control fields from optimistic
update/batch cache patches (MUL-3375 §9).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): exclude handoff records from new-comment counting

type='handoff' is a display-only timeline record, not conversation. Exclude it
from CountNewCommentsSince so a handoff note never inflates the count of
"new comments to catch up on" fed to a claiming agent (MUL-3375 §12). Analytics
already excludes it (RecordHandoff is a direct write that emits no analytics
event), and the comment-trigger path is already bypassed.

Test: a handoff record does not bump the new-comment count; a real comment does.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* feat(issues): pre-trigger preview UI, handoff note, timeline card (web/desktop)

Wire the §9 frontend onto the preview endpoint + handoff fields:
- Delete the backlog blocking dialog (backlog-agent-hint*) and its modal type;
  the over-eager nag is gone. Backlog awareness is now a passive label.
- RunConfirmModal: single assign + batch assign/status route here. Shows the
  backend predicate's verdict ("将启动 @X" / "将启动 N 个" / parked), an optional
  handoff note (assign only, soft-gated by handoff_supported), and 暂不启动 —
  then applies via update/batch. No frontend guessing.
- create modal: passive CreateRunHint ("将启动 @X" / backlog parked).
- single status change stays a direct apply (unchanged).
- timeline: render type='handoff' as a distinct, non-interactive handoff card.
- i18n run_confirm + handoff_card across en/ja/ko/zh-Hans; drop backlog action
  keys; locale parity green.

Tests: use-issue-actions (assign → run-confirm modal, member → direct),
create-issue + comment-card suites updated/green; views typecheck + lint clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* test(issues): use a valid anchor in the handoff count-exclusion test

CountNewCommentsSince filters id <> @anchor_id; SQL id <> NULL is NULL and
excludes every row, so an empty anchor made the control assertion read 0. The
production caller always passes a real anchor — mirror that with a non-matching
sentinel uuid.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* test(issues): RunConfirmModal apply logic (start/suppress/note-gate/batch)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* test(core): preview schema malformed/missing/null fallback coverage

Cover IssueTriggerPreviewSchema via parseWithFallback (MUL-3375): well-formed
parse, top-level + item default fills (empty/older backend), and fallback to
{ triggers: [], total_count: 0 } for malformed shapes, a dropped required
issue_id, a wrong-typed total_count, and null/non-object bodies — so the four
entry points degrade to "nothing will start" instead of throwing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* refactor(issues): remove display-only handoff timeline record (留痕)

The handoff "留痕" timeline record (type='handoff' comment written on run
start) was judged superfluous and dropped per product call. This removes
only the display-only trace; the handoff NOTE injection into the run's
opening prompt + issue_context.md is untouched.

- backend: drop RecordHandoff + its call in dispatchIssueRun
- db: drop the `type <> 'handoff'` exclusion in CountNewCommentsSince and
  migration 123 (comment_type_check reverts to the 4-type set from 001);
  no production data exists for this unreleased feature
- frontend: drop the "handoff" CommentType, HandoffCard, and handoff_card
  i18n (all locales)
- tests: drop handoff_count_test.go and the record-write assertions in
  issue_trigger_preview_test.go (note-injection tests retained)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* feat(issues): dismissable run-confirm modal + team-handoff copy

Two fixes to the pre-trigger confirm modal (MUL-3375).

1. Dismissable: switch RunConfirmModal from AlertDialog to the standard
   shadcn Dialog so it has the close (X) button + Esc + click-outside.
   Previously the only choices were "start" / "don't start now" with no
   way to abort the action entirely; dismissing now cancels with no write.

2. Copy: rework the action-surface wording away from the backend term
   "run" toward team-handoff voice — 指派 / 开始 / 交接 (run stays only on
   record surfaces). Unifies the note's three names to "交接说明", and
   parallels the rewrite across en/ja/ko.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* chore(agent): bump handoff note min CLI version to 0.3.28

The daemon release that renders handoff notes ships in 0.3.28 (0.3.27
was the prior tag), so move the soft-gate threshold up. Below this the
note is silently dropped and the frontend grays the note box — assignment
is never blocked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(issues): skip run-confirm when batch-moving issues to backlog

A move into backlog never starts a run (service/issue_trigger.go), so the
pre-trigger confirm modal degenerated to an empty "won't start" box with a
single Apply button — pure friction. Apply directly instead, matching the
single-issue status path. Other target statuses still route through the
modal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(issues): refine pre-trigger preview hint and copy

- Move the create-issue run hint to a reveal band (grid 0fr→1fr) above the
  property toolbar. It was sharing the footer button row and, lacking a
  width constraint, reflowed the submit buttons whenever it appeared.
  Restyle to a borderless, comment-style avatar+caption that is purely a
  caption (non-interactive avatar).
- Distinguish squad from agent in the pre-trigger copy: a squad's leader
  evaluates and delegates rather than "starting work" itself. Add
  will_start_named_squad / will_start_squad / create_will_start_squad across
  en/zh/ja/ko (reusing the squad_leader_* evaluate→arrange vocabulary) and
  branch run-confirm + the create hint on squad assignees.
- Bold the assignee name in the run-confirm headline via a language-safe
  sentinel split (no per-language prefix/suffix keys).
- Align zh "开始处理" → "开始工作" on the single-assign copy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(issues): stub ActorAvatar in create-issue suite

CreateRunHint now renders an ActorAvatar for agent/squad assignees, which
pulls in getActorInitials/getActorAvatarUrl + the workspace/presence/navigation
hook tree. This form-focused suite only stubbed getActorName, so the
squad-forwarding test crashed with "getActorInitials is not a function". Stub
the avatar inert — its own behavior is covered elsewhere.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Walt <walt@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-23 13:17:13 +08:00

602 lines
27 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"
"fmt"
"log/slog"
"os"
"path/filepath"
"time"
)
// RepoContextForEnv describes a workspace repo available for checkout.
type RepoContextForEnv struct {
URL string // remote URL
Description string // optional repo description
}
// 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 // server-assigned UUID
ResourceType string // e.g. "github_repo"
ResourceRef json.RawMessage // raw JSONB payload from the API
Label string // 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 — tasks are grouped under this
TaskID string // task UUID — used for directory name
AgentName string // for git branch naming only
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 and OpenClaw consume it here; other
// providers wire MCP via ExecOptions.McpConfig in the agent backend.
McpConfig json.RawMessage
// 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
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
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
AgentID string // unique ID of the dispatched agent
AgentName string
AgentInstructions string // agent identity/persona instructions, injected into CLAUDE.md
AgentSkills []SkillContextForEnv
Repos []RepoContextForEnv // workspace repos available for checkout
ProjectID string // issue's project, 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
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
HandoffNote string // assignment handoff instruction; rendered into issue_context.md (MUL-3375)
IsSquadLeader bool // true when the agent is acting as a squad leader (may exit silently on no_action)
// 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
// 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.
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.
LocalDirectory bool
// CodexHome is the path to the per-task CODEX_HOME directory (set only for codex provider).
CodexHome 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
logger *slog.Logger // for cleanup logging
}
// PredictRootDir returns the env root path that Prepare would create for the
// given task, without performing any I/O. Callers use this to claim ownership
// of the directory (e.g. against the GC loop) before Prepare/Reuse runs.
func PredictRootDir(workspacesRoot, workspaceID, taskID string) string {
if workspacesRoot == "" || workspaceID == "" || taskID == "" {
return ""
}
return filepath.Join(workspacesRoot, workspaceID, shortID(taskID))
}
// 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 := filepath.Join(params.WorkspacesRoot, params.WorkspaceID, shortID(params.TaskID))
// Remove existing env if present (defensive — task IDs are unique).
if _, err := os.Stat(envRoot); err == nil {
if err := os.RemoveAll(envRoot); err != nil {
return nil, fmt.Errorf("execenv: remove 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 == "" {
scratchDirs = append(scratchDirs, workDir)
} else {
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)
}
}
env := &Environment{
RootDir: envRoot,
WorkDir: workDir,
LocalDirectory: params.LocalWorkDir != "",
logger: logger,
}
// 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{}
if err := writeContextFiles(workDir, params.Provider, params.Task, manifest); err != nil {
return nil, fmt.Errorf("execenv: write context files: %w", err)
}
// For Codex, set up a per-task CODEX_HOME seeded from ~/.codex/ with skills.
if params.Provider == "codex" {
codexHome := filepath.Join(envRoot, "codex-home")
if err := prepareCodexHomeWithOpts(codexHome, CodexHomeOptions{CodexVersion: params.CodexVersion}, logger); err != nil {
return nil, fmt.Errorf("execenv: prepare codex-home: %w", err)
}
if err := hydrateCodexSkills(codexHome, params.Task.AgentSkills, logger); err != nil {
return nil, fmt.Errorf("execenv: hydrate codex skills: %w", err)
}
env.CodexHome = codexHome
}
// 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, 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 {
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,
McpConfig: params.McpConfig,
Gateway: params.OpenclawGateway,
})
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))
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 {
WorkDir string
Provider string
CodexVersion string // only used when Provider == "codex"
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
// 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
// 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
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
}
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,
}
// 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)
}
// 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, "codex-home")
if err := prepareCodexHomeWithOpts(codexHome, CodexHomeOptions{CodexVersion: params.CodexVersion}, logger); err != nil {
logger.Warn("execenv: refresh codex-home failed", "error", err)
} else {
env.CodexHome = codexHome
if err := hydrateCodexSkills(codexHome, params.Task.AgentSkills, logger); err != nil {
logger.Warn("execenv: refresh codex skills 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, 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,
McpConfig: params.McpConfig,
Gateway: params.OpenclawGateway,
})
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, 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 {
return nil
}
// Codex skills live under env.RootDir/codex-home, which the GC loop
// (cloud) or env teardown (local_directory) wipes wholesale — they
// don't sit inside the user's workdir and don't need sidecar manifest
// tracking.
return writeSkillFiles(skillsDir, workspaceSkills, nil)
}
// 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 ID field matching Kind is meaningful.
//
// 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
}
// 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
}
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
}