mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
* feat(project): add local_directory project_resource type (MUL-2662)
Adds a second project_resource type alongside github_repo so a project
can be pinned to an existing directory on a specific daemon (the v1 of
the local-working-directory flow tracked in MUL-2618). The ref schema is
{ local_path, daemon_id, label? }; local_path must be absolute and
daemon_id is required. The same (daemon_id, local_path) pair is allowed
on multiple projects by design — no UNIQUE constraint is added.
Implementation reuses the existing project_resource API surface: the new
type is wired through the validator switch with no migration, no new
events, and no daemon-handler changes (daemon already passes through
arbitrary resource types via ProjectResources). The CLI gains
--local-path / --daemon-id / --ref-label shortcuts so
`multica project resource add --type local_directory` mirrors the
existing `--type github_repo --url ...` ergonomics; the generic --ref
flag still works for both types.
Tests cover the full CRUD lifecycle, the same-path-across-projects
allowance, the same-path-same-project conflict, the validator rejections
(missing/blank/relative path, missing daemon_id, wrong payload type),
and the cross-platform isAbsoluteLocalPath helper.
Co-authored-by: multica-agent <github@multica.ai>
* feat(project): add update endpoint + label-shadow guard for project_resource (MUL-2662)
Addresses the Elon review on PR #3263:
- Add PUT /api/projects/{id}/resources/{resourceId} with sqlc query,
matching handler, CLI `project resource update`, and a new
EventProjectResourceUpdated WS event. resource_type stays immutable;
ref/label/position are all individually optional.
- Catch same-project (daemon_id, local_path) collisions where only the
embedded label differs — the row-level UNIQUE only matches the full
ref JSON, so a label typo would otherwise let the same working
directory bind twice.
- Tests cover the update lifecycle (label-only / ref / clear / 404 /
invalid path) and the label-shadow conflict on both create and
update; the in-place rename still succeeds because the conflict
scan ignores the row being edited.
Incidental: regenerating sqlc picked up a missing skills_local scan in
UpdateAgentCustomEnv that drifted in from #3200.
Co-authored-by: multica-agent <github@multica.ai>
* fix(project): close bundled-create label-shadow gap + merge resource_ref on CLI update (MUL-2662)
Two follow-ups from MUL-2662 review round 2:
- CreateProject inline resources path now dedupes local_directory entries on
(daemon_id, local_path) before opening the transaction. The DB-level
UNIQUE(project_id, resource_type, resource_ref) constraint only fires on a
full JSON match, so two rows with the same target but different `label`
would otherwise slip past. Standalone POST/PUT already cover this via
findLocalDirectoryConflict; bundled create was the missing surface.
- `multica project resource update` now seeds resource_ref from the existing
row before applying per-type shortcut flags, so `--default-branch-hint x`
on its own no longer constructs a payload missing `url` (which the server
400s on). Local_directory partial edits get the same merge behavior.
Co-authored-by: multica-agent <github@multica.ai>
* feat(desktop): local_directory project_resource UI (MUL-2665) (#3273)
* feat(desktop): local_directory project_resource UI (MUL-2665)
First UI surface for the local-working-directory flow tracked in MUL-2618.
Lets users on the desktop pin a project to an existing folder on this
machine; web stays read-only since the per-daemon check can't be done in
the browser.
What's new for the renderer:
- ProjectResourcesSection grows a desktop-only "Add local directory"
button next to the existing GitHub-repo popover. Clicking it opens
Electron's native folder picker, validates the path through a new
IPC pair (existence + r/w), and submits a project_resource of
resource_type=local_directory with daemon_id pulled live from
daemonAPI.getStatus.
- LocalDirectoryRow renders the rename pencil + path tooltip, and
greys out when ref.daemon_id != this machine's daemon_id (with a
"only available on the machine that registered this directory"
tooltip). Delete stays enabled so users can drop stale registrations
from any device.
- LocalDirectoryHint sits above the issue-detail comment composer and
shows "Agent will work in-place at {label} ({path})" when the issue's
project has a local_directory matching this daemon. Hidden on web.
- TaskStatusPill picks up a new "waiting_for_directory_release" stage
that the daemon will publish when it dequeues a task but can't
acquire the path lock. The render is in place now so the daemon
sibling subtask can wire the status string without an additional UI
PR.
Plumbing:
- @multica/core/types gains LocalDirectoryResourceRef +
UpdateProjectResourceRequest, and the api client gets the matching
PUT method backed by the server endpoint that landed in
2ac3faebb (MUL-2662). A useUpdateProjectResource hook drives the
in-place label edit.
- New Electron handlers under apps/desktop/src/main/local-directory.ts:
local-directory:pick -> dialog.showOpenDialog (openDirectory)
local-directory:validate -> stat + access(R_OK + W_OK)
exposed through the preload as desktopAPI.pickDirectory /
validateLocalDirectory. View code talks to them via a thin
packages/views/platform helper that returns reason=unsupported on
web instead of crashing.
- useLocalDaemonStatus exposes the local daemon's id, device name, and
running flag from daemonAPI.onStatusChange so the renderer can do the
cross-device match without coupling to the desktop preload typings.
Tests:
- pickStageKeys gets a unit test covering the new stage and proving
the directory-release status outranks availability hints.
- LocalDirectoryHint tests cover the four render branches (no project,
no daemon, foreign daemon, matching daemon).
- i18n parity stays green; new keys added under projects.resources.*
and chat.status_pill.stages.waiting_for_directory_release in both
locales.
Out of scope (will land separately):
- The daemon-side waiting/lock signal that flips the pill into the
new state.
- Adding local_directory to the create-project modal's bulk
attach flow.
- Docs page refresh for project-resources.mdx — left for the
MUL-2618 umbrella sweep.
Co-authored-by: multica-agent <github@multica.ai>
* fix(desktop): hide rename for foreign daemon local_directory rows (MUL-2618)
Address review nit on #3273: the rename pencil was gated only by
`canEdit`, so a foreign / unknown-daemon row still showed it even
though the spec says cross-device rows are disabled. Gate rename on
`!mismatch` so it disappears on those rows; delete stays available
so a stale registration can still be dropped from any device.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* feat(daemon): local_directory execution + path mutex + GC exception (MUL-2663) (#3274)
* feat(daemon): local_directory execution + path mutex + GC exception (MUL-2663)
Wires up the daemon side of the local_directory project_resource introduced
in MUL-2662. When a task is dispatched against a project whose resources
include a local_directory pinned to this daemon's UUID, the daemon now:
- Validates the path (absolute, exists, daemon process can read+write,
not in the system-root / $HOME blacklist) and fails the task fast on
any precondition violation, with a user-readable reason.
- Serialises concurrent tasks on the same on-disk path via a
daemon-local LocalPathLocker keyed by symlink-resolved realpath. The
lock is held for the entire task lifetime (claim → context write →
agent → result report).
- When the lock is contended, the daemon flips the row to a new
waiting_local_directory status on the server (carrying a wait_reason
like "<path> (held by task <short id>)") so the UI can render
"等待本地目录释放" instead of leaving the row silently in dispatched
past the sweeper timeout. The status accepts being woken into running
once the lock is acquired.
- Sets execenv.WorkDir to the user's path (no copy, no mount). envRoot
still lives under workspacesRoot/<wsID>/ and hosts output/, logs/, and
.gc_meta.json — the daemon's logbook for the run.
- Stamps GCMeta.LocalDirectory=true so the GC loop never RemoveAlls
envRoot for these tasks (gcActionClean → gcActionCleanArtifacts,
gcActionOrphan → gcActionSkip). The user's directory was never under
envRoot to begin with, so this is defense in depth.
- Skips execenv.Reuse for local_directory tasks because the prior
WorkDir is the user's path and reusing it through that code path
loses the envRoot association the GC loop needs. Prepare is cheap
here (no clone, no copy), so always running it is fine.
Server-side protocol changes:
- New CHECK value 'waiting_local_directory' on agent_task_queue.status
plus a wait_reason TEXT column (migration 109).
- All cancel / active / counted-as-running / orphan-recovery queries
expanded to include the new status; FailStaleTasks intentionally
excludes it (the daemon owns the wait).
- New SQL MarkAgentTaskWaitingLocalDirectory(id, reason) and a relaxed
StartAgentTask that accepts both dispatched and
waiting_local_directory as preconditions (and clears wait_reason on
the way through).
- New POST /api/daemon/tasks/{taskId}/wait-local-directory endpoint,
TaskService.MarkTaskWaitingLocalDirectory broadcaster, and matching
daemon Client.MarkTaskWaitingLocalDirectory.
Tests cover: path blacklist + R/W enforcement, mutex serialisation +
ctx-cancelled wait, lock handover between two tasks, GC never returns
gcActionClean / gcActionOrphan for local_directory rows (with negative
control for the standard path), and Prepare/Cleanup correctly substitute
+ protect the user's WorkDir.
The desktop UI side (UI for adding a local_directory resource, surfacing
the "等待本地目录" badge) is MUL-2665; the agent-task lifecycle changes
(no branch switch, dirty-tree tolerant, auto-commit) are MUL-2664.
This PR targets the shared MUL-2618 v1 feature branch agent/j/912b8cb1,
not main; the whole v1 will be merged to main together when complete.
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): tighten local_directory status, symlink, cancel handling (MUL-2618)
Address the 3 must-fix items from Elon's review of PR #3274.
1. Status string unified. The server / daemon publish
`waiting_local_directory`; align views, locales, and the
pickStageKeys test (PR #3273 had used `waiting_for_directory_release`
on a placeholder string). Without this, the daemon's wait state
never reached the pill once the two siblings merged.
2. validateLocalPath now also runs the blacklist against the
symlink-resolved realpath, with macOS's `/etc` -> `/private/etc`
redirect handled via `isBlacklistedRealPath` which compares
canonical forms. Without this, a symlink such as
`/Users/me/proj/home -> /Users/me` slipped the literal $HOME check
while every daemon write still landed in the user's home. Tests
cover symlink-to-home, symlink-to-system-root, and the negative
case (symlink to a regular subdirectory).
3. acquireLocalDirectoryLockIfNeeded now spins up a cancellation
watcher inside `onWait` (lazy — the fast path stays free) so the
gap between dispatch and StartTask responds to server-side cancel
or row deletion. If the watcher fires while the daemon is parked
on the path mutex, the lock-wait context is cancelled, Acquire
returns promptly, and the helper exits silently the same way the
run-phase poller does. New TestAcquireLocalDirectoryLock_CancelDuringWait
exercises the path end-to-end with a fake server.
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): unconditional canonical blacklist + Windows drive-root generalisation (MUL-2618)
- validateLocalPath now always runs isBlacklistedRealPath on the
symlink-resolved path, not only when it differs from absPath. The old
guard let users type the canonical form of an OS-symlinked banned root
(e.g. /private/tmp, /private/etc, /private/var on macOS) straight
through, since EvalSymlinks is a no-op on already-canonical input.
- Windows drive-root rejection moved off the static C/D/E/F enumeration
onto filepath.VolumeName via a new isDriveRoot helper, so removable /
network drives mounted at G:..Z: and UNC \\server\share roots are also
blocked. systemRootBlacklist keeps the well-known C:\ trees only.
- Tests: macOS-only case exercises direct /private/{tmp,etc,var}; a
new TestIsDriveRoot covers the Windows generalisation (skipped on
POSIX runners by runtime guard).
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* feat(views): wire waiting_local_directory end-to-end in issue UI + presence (MUL-2618)
Connect the daemon-emitted `task:waiting_local_directory` and `task:running`
events through to issue execution log, sticky agent banner, activity indicator,
and agent presence so a parked task is no longer invisible on the issue page.
- Add `waiting_local_directory` to `AgentTask.status` and the typed
`task:running` / `task:waiting_local_directory` WS event payloads.
- Chat realtime sync writes both new statuses into the pending-task cache so
the chat StatusPill flips out of a stale `dispatched` frame.
- ExecutionLogSection: count `waiting_local_directory` as active, add tone +
status label, treat parked tasks the same as dispatched for time anchor /
transcript visibility / terminate-confirm note.
- AgentLiveCard: subscribe to both new events, rank the parked state between
dispatched and queued, and surface a "is waiting for the local directory"
banner with the muted "Clock" treatment used for queued.
- IssueAgentActivityIndicator: route parked tasks into the queued bucket so
the hover stack and chip stay visible.
- derive-presence: parked tasks count toward `queuedCount` so the agent
workload chip stays out of `idle` while the daemon waits on the path lock.
- Locales: add `agent_live.is_waiting_local_directory` and
`execution_log.status_waiting_local_directory` (en + zh-Hans).
Co-authored-by: multica-agent <github@multica.ai>
* feat(project): enforce one local_directory per (project, daemon) (MUL-2618)
The daemon-side resolver picks the first matching local_directory by
daemon_id, so allowing two rows on the same daemon — even at different
paths — let the agent silently write into whichever sorted first. Tighten
the invariant top to bottom:
- server: `findLocalDirectoryConflict` rejects any second row sharing a
daemon_id, regardless of `local_path` or label. Bundled-create surface in
`CreateProject` runs the same daemon-scoped dedupe up front.
- daemon: `findLocalDirectoryAssignment` fails fast when it finds more than
one row pinned to the current daemon (older API client / direct DB
writes can still produce that state — refuse to guess).
- desktop UI: hide the "Add local directory" action once the current
daemon owns a row on this project, with a hint and a defensive toast on
the call path; foreign-daemon rows stay visible read-only as before.
- Tests:
* daemon: new `two local_directory rows on this daemon fail fast` /
`local_directory rows on different daemons coexist` cases.
* handler: rewrite the legacy `LabelShadow` cases as
`DaemonScopedConflict` / `BundledLocalDirectoryDaemonConflict` —
asserts 409 on same-daemon different-path, 201 on per-daemon bundles.
- Locales: en + zh-Hans copy for the new hint + toast.
Co-authored-by: multica-agent <github@multica.ai>
* chore(sqlc): drop stale skills_local in UpdateAgentCustomEnv (MUL-2618)
Follow-up to the main-merge in 0f8e8ca7: the auto-merge preserved most
of main's skills_local revert but kept the column reference inside the
UpdateAgentCustomEnv scanner because that block hadn't been touched by
either side. Re-running `sqlc generate` regenerates the file without
skills_local in this query, matching the rest of the file and the
post-revert schema.
Co-authored-by: multica-agent <github@multica.ai>
* feat(create-project): binary source picker — repos OR local directory
Turn the create-project dialog's "Repos" pill into a binary Source
picker. A project's source is mutually exclusive: either a set of
GitHub repos (worktree mode, default) or a single local working
directory (local mode, desktop-only). Mirrors the constraint the
backend will enforce next.
Behavior:
- Pill shows the active mode's selection (GitHub icon + repo count, or
folder icon + local label/path).
- Popover has a 2-tab segmented control at the top; the Local tab is
hidden entirely on web (local_directory needs a daemon_id).
- Local tab requires the daemon online — amber notice + disabled picker
when offline, re-renders automatically via useLocalDaemonStatus.
- Switching tabs preserves the other side's stash, but handleSubmit
only emits the resource matching the active sourceMode, so abandoned
picks never leak into the created project.
Backend mutual-exclusion validation + the resources-section
conditional-add-button still to come — this PR just unblocks the
dialog so it can be demoed.
* fix(mobile): cover waiting_local_directory in run row status maps (MUL-2618)
---------
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Multica J <j@multica.ai>
465 lines
20 KiB
Go
465 lines
20 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
|
|
// 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)
|
|
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
|
|
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
|
|
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
|
|
}
|
|
|
|
// 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
|
|
|
|
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).
|
|
if err := writeContextFiles(workDir, params.Provider, params.Task); 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 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})
|
|
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
|
|
// 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,
|
|
}
|
|
|
|
// Refresh context files (issue_context.md, skills).
|
|
if err := writeContextFiles(params.WorkDir, params.Provider, params.Task); 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 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})
|
|
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
|
|
}
|
|
return writeSkillFiles(skillsDir, 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 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
|
|
}
|