MUL-6686 feat(daemon): use readable workdir paths (#7316)

Create new task environments under sanitized, bounded readable segments:
<workspace-slug>-<workspace-short>/<issue-key>-<task-short>/workdir.

The readable name is the visible part; the substance is moving task identity
off the directory name. A human-readable segment is mutable — workspace slugs
and issue prefixes change — so workspace/task identity now lives in
.task_owner, .gc_meta.json and a stable ID-keyed index under .task_roots that
freezes one physical root per task for its lifetime. GC and disk-usage read
that authoritative identity instead of inferring a workspace from a path
string, which also fixes active and preparation-crashed roots being attributed
to a nonexistent workspace.

Existing workdirs are not renamed or migrated, legacy roots are adopted into
the index, and both layouts render privacy-safe relative paths. Labels are
lowercased ASCII with separators and traversal collapsed, segments are capped
at 24 characters so the two together stay within the previous 49-character
Windows path budget, and stable ID suffixes drawn from the UUIDv7 random tail
keep roots distinct across renames and case-insensitive filesystems.

Closes #6063

Co-authored-by: iiwish <[email protected]>
Co-authored-by: J <[email protected]>
This commit is contained in:
iiwish
2026-08-26 16:34:10 +08:00
committed by GitHub
co-authored by iiwish J
parent 7b8c783991
commit cd169947b5
20 changed files with 1997 additions and 142 deletions
+39 -21
View File
@@ -5071,13 +5071,16 @@ func (d *Daemon) handleTask(ctx context.Context, task Task, slot int) {
// reportTaskResult and execenv.WriteGCMeta below. markActiveEnvRoot
// is reference-counted, so the duplicate marks runTask installs are
// correctly nested within these.
predictedEnvRoot := execenv.PredictRootDir(d.cfg.WorkspacesRoot, task.WorkspaceID, task.ID)
if predictedEnvRoot != "" {
d.markActiveEnvRoot(predictedEnvRoot)
defer d.unmarkActiveEnvRoot(predictedEnvRoot)
resolvedEnvRoot, resolveRootErr := execenv.ResolveRootDir(taskRootDirParams(d.cfg.WorkspacesRoot, task))
if resolveRootErr != nil {
taskLog.Error("resolve stable task env root", "error", resolveRootErr)
}
if resolvedEnvRoot != "" {
d.markActiveEnvRoot(resolvedEnvRoot)
defer d.unmarkActiveEnvRoot(resolvedEnvRoot)
}
if task.PriorWorkDir != "" {
if priorRoot := filepath.Dir(task.PriorWorkDir); priorRoot != "" && priorRoot != predictedEnvRoot {
if priorRoot := filepath.Dir(task.PriorWorkDir); priorRoot != "" && priorRoot != resolvedEnvRoot {
d.markActiveEnvRoot(priorRoot)
defer d.unmarkActiveEnvRoot(priorRoot)
}
@@ -5602,7 +5605,7 @@ func (d *Daemon) reportTerminalTask(parentCtx context.Context, report terminalTa
// internal task with no IDs at all). The caller skips writing a meta file
// in that case so the directory falls back to mtime-based orphan cleanup.
func gcMetaForTask(task Task) (execenv.GCMeta, bool) {
meta := execenv.GCMeta{WorkspaceID: task.WorkspaceID}
meta := execenv.GCMeta{WorkspaceID: task.WorkspaceID, TaskID: task.ID}
switch {
case task.ChatSessionID != "":
meta.Kind = execenv.GCKindChat
@@ -5626,6 +5629,16 @@ func gcMetaForTask(task Task) (execenv.GCMeta, bool) {
return meta, true
}
func taskRootDirParams(workspacesRoot string, task Task) execenv.RootDirParams {
return execenv.RootDirParams{
WorkspacesRoot: workspacesRoot,
WorkspaceID: task.WorkspaceID,
WorkspaceSlug: task.WorkspaceSlug,
TaskID: task.ID,
IssueIdentifier: task.IssueIdentifier,
}
}
// runtimeDisplayNameOverrides maps a provider key to the human-facing runtime
// name when simple title-casing would read awkwardly. Providers not listed
// here fall back to capitalizing the key (claude → "Claude", codex → "Codex").
@@ -5792,7 +5805,7 @@ func sessionHomeReachable(provider string, env *execenv.Environment, envReused b
// shouldReusePriorWorkdir keeps the local_directory lock and cross-agent
// isolation invariants without forcing managed follow-ups onto a fresh
// provider session. Every managed issue or chat task may reuse only directories
// that resolve to the {workspace}/{task}/workdir shape, carry Prepare-time
// that resolve to the two-segment managed root shape, carry Prepare-time
// managed-env provenance for the same workspace/scope/agent, and carry a
// matching daemon task-context marker. Other task kinds have no durable scope
// with which to prove ownership and therefore start fresh.
@@ -5827,7 +5840,7 @@ func shouldReusePriorWorkdir(task Task, localAssignment *localDirectoryAssignmen
return "", false
}
parts := strings.Split(rel, string(filepath.Separator))
if len(parts) != 3 || parts[0] != task.WorkspaceID || parts[1] == "" || parts[2] != "workdir" {
if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] != "workdir" {
return "", false
}
if task.AgentID == "" || (task.IssueID == "" && task.ChatSessionID == "") {
@@ -6720,14 +6733,17 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i
// Mark candidate env roots as active before any env work so the GC loop
// can't reclaim artifacts inside them mid-execution. We mark both the
// predicted root for a fresh Prepare and the prior root for Reuse — they
// stable root for a fresh Prepare and the prior root for Reuse — they
// usually differ (Reuse keeps the original task's directory).
predictedRoot := execenv.PredictRootDir(d.cfg.WorkspacesRoot, task.WorkspaceID, task.ID)
d.markActiveEnvRoot(predictedRoot)
defer d.unmarkActiveEnvRoot(predictedRoot)
resolvedRoot, err := execenv.ResolveRootDir(taskRootDirParams(d.cfg.WorkspacesRoot, task))
if err != nil {
return TaskResult{}, fmt.Errorf("resolve stable task env root: %w", err)
}
d.markActiveEnvRoot(resolvedRoot)
defer d.unmarkActiveEnvRoot(resolvedRoot)
if task.PriorWorkDir != "" {
priorRoot := filepath.Dir(task.PriorWorkDir)
if priorRoot != predictedRoot {
if priorRoot != resolvedRoot {
d.markActiveEnvRoot(priorRoot)
defer d.unmarkActiveEnvRoot(priorRoot)
}
@@ -6742,7 +6758,7 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i
// *os.File cannot cross its JSON response back to us. Claiming there would
// leave the agent running with no protection at all — which is exactly the
// re-dispatch window this guards.
envClaim, err := execenv.ClaimEnvRoot(d.cfg.WorkspacesRoot, task.WorkspaceID, task.ID)
envClaim, err := execenv.ClaimEnvRoot(taskRootDirParams(d.cfg.WorkspacesRoot, task))
if err != nil {
return TaskResult{}, fmt.Errorf("claim execution environment: %w", err)
}
@@ -7025,11 +7041,13 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i
if env == nil {
var err error
prepParams := execenv.PrepareParams{
WorkspacesRoot: d.cfg.WorkspacesRoot,
Profile: d.cfg.Profile,
WorkspaceID: task.WorkspaceID,
TaskID: task.ID,
AgentName: agentName,
WorkspacesRoot: d.cfg.WorkspacesRoot,
Profile: d.cfg.Profile,
WorkspaceID: task.WorkspaceID,
WorkspaceSlug: task.WorkspaceSlug,
TaskID: task.ID,
IssueIdentifier: task.IssueIdentifier,
AgentName: agentName,
// This run already holds the claim (envClaim above) and the reset
// it implies; preparation must not try to take it again.
EnvRootPreclaimed: true,
@@ -7126,8 +7144,8 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i
}
}
// Belt-and-suspenders: also mark whatever root we ended up with, in case
// future changes diverge from PredictRootDir.
if env.RootDir != predictedRoot && env.RootDir != "" {
// future changes diverge from ResolveRootDir.
if env.RootDir != resolvedRoot && env.RootDir != "" {
d.markActiveEnvRoot(env.RootDir)
defer d.unmarkActiveEnvRoot(env.RootDir)
}
+24 -7
View File
@@ -190,8 +190,8 @@ func ScanDiskUsage(workspacesRoot string, artifactPatterns []string) (DiskUsageR
if strings.HasPrefix(wsEntry.Name(), ".") {
continue
}
wsID := wsEntry.Name()
wsDir := filepath.Join(workspacesRoot, wsID)
physicalWorkspace := wsEntry.Name()
wsDir := filepath.Join(workspacesRoot, physicalWorkspace)
taskEntries, err := os.ReadDir(wsDir)
if err != nil {
continue
@@ -201,19 +201,20 @@ func ScanDiskUsage(workspacesRoot string, artifactPatterns []string) (DiskUsageR
continue
}
taskDir := filepath.Join(wsDir, t.Name())
usage := buildTaskUsage(taskDir, wsID, t.Name(), matcher)
usage := buildTaskUsage(taskDir, physicalWorkspace, t.Name(), matcher)
report.Tasks = append(report.Tasks, usage)
report.TotalSizeBytes += usage.SizeBytes
report.TotalArtifactSizeBytes += usage.ArtifactSizeBytes
ws, ok := wsAgg[wsID]
workspaceID := usage.WorkspaceID
ws, ok := wsAgg[workspaceID]
if !ok {
ws = &WorkspaceDiskUsage{
WorkspaceID: wsID,
WorkspaceShort: ShortID(wsID),
WorkspaceID: workspaceID,
WorkspaceShort: usage.WorkspaceShort,
}
wsAgg[wsID] = ws
wsAgg[workspaceID] = ws
}
ws.TaskCount++
ws.SizeBytes += usage.SizeBytes
@@ -313,8 +314,24 @@ func buildTaskUsage(taskDir, wsID, taskShort string, matcher artifactMatcher) Ta
}
metaPresent := false
if provenance, err := execenv.ReadManagedEnvProvenance(taskDir); err == nil && provenance != nil {
if workspaceID := strings.TrimSpace(provenance.WorkspaceID); workspaceID != "" {
usage.WorkspaceID = workspaceID
usage.WorkspaceShort = ShortID(workspaceID)
}
}
if owner, err := execenv.ReadEnvRootOwner(taskDir); err == nil && owner != nil {
if workspaceID := strings.TrimSpace(owner.WorkspaceID); workspaceID != "" {
usage.WorkspaceID = workspaceID
usage.WorkspaceShort = ShortID(workspaceID)
}
}
if meta, err := execenv.ReadGCMeta(taskDir); err == nil && meta != nil {
metaPresent = true
if workspaceID := strings.TrimSpace(meta.WorkspaceID); workspaceID != "" {
usage.WorkspaceID = workspaceID
usage.WorkspaceShort = ShortID(workspaceID)
}
usage.Kind = string(meta.Kind)
usage.ParentID = parentIDForMeta(meta)
if !meta.CompletedAt.IsZero() {
+95
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"runtime"
@@ -207,6 +208,100 @@ func TestScanDiskUsage_AggregatesAndCategorizes(t *testing.T) {
}
}
func TestScanDiskUsage_MixedLayoutsUseMetadataIdentity(t *testing.T) {
t.Parallel()
root := t.TempDir()
workspaceID := "a05b0e10-ee7a-4603-a72d-a548b2390cb2"
readableTaskID := "22222222-ee7a-4603-a72d-b659c34a1dc3"
legacyTaskSegment := "a548b2390cb2"
readableTaskSegment := "mul-6063-b659c34a1dc3"
legacyDir := filepath.Join(root, workspaceID, legacyTaskSegment)
writeFile(t, filepath.Join(legacyDir, "workdir", "legacy.txt"), 10)
mustWriteMeta(t, legacyDir, execenv.GCMeta{
Kind: execenv.GCKindIssue,
IssueID: "issue-legacy",
WorkspaceID: workspaceID,
CompletedAt: time.Now().Add(-time.Hour),
})
readableDir := filepath.Join(root, "asset-feed-a548b2390cb2", readableTaskSegment)
writeFile(t, filepath.Join(readableDir, "workdir", "readable.txt"), 20)
mustWriteMeta(t, readableDir, execenv.GCMeta{
Kind: execenv.GCKindIssue,
IssueID: "issue-readable",
TaskID: readableTaskID,
WorkspaceID: workspaceID,
CompletedAt: time.Now().Add(-time.Hour),
})
report, err := ScanDiskUsage(root, nil)
if err != nil {
t.Fatalf("ScanDiskUsage: %v", err)
}
if report.TotalTaskCount != 2 || report.TotalWorkspaceCount != 1 {
t.Fatalf("totals = tasks:%d workspaces:%d, want 2/1", report.TotalTaskCount, report.TotalWorkspaceCount)
}
for _, task := range report.Tasks {
if task.WorkspaceID != workspaceID {
t.Errorf("task %q workspace_id = %q, want metadata id %q", task.Path, task.WorkspaceID, workspaceID)
}
}
byShort := map[string]TaskDiskUsage{}
for _, task := range report.Tasks {
byShort[task.TaskShort] = task
}
if _, ok := byShort[legacyTaskSegment]; !ok {
t.Errorf("legacy task was not reported under physical directory segment %q", legacyTaskSegment)
}
if _, ok := byShort[readableTaskSegment]; !ok {
t.Errorf("readable task was not reported under physical directory segment %q", readableTaskSegment)
}
}
func TestScanDiskUsage_ReadableActiveRootUsesOwnerIdentityWithoutGCMeta(t *testing.T) {
t.Parallel()
root := t.TempDir()
workspaceID := "a05b0e10-ee7a-4603-a72d-a548b2390cb2"
taskID := "22222222-ee7a-4603-a72d-b659c34a1dc3"
env, err := execenv.Prepare(execenv.PrepareParams{
WorkspacesRoot: root,
WorkspaceID: workspaceID,
WorkspaceSlug: "Asset Feed",
TaskID: taskID,
IssueIdentifier: "MUL-6063",
AgentName: "Active Task",
}, slog.Default())
if err != nil {
t.Fatalf("Prepare: %v", err)
}
defer env.Cleanup(true)
if _, err := os.Stat(filepath.Join(env.RootDir, ".gc_meta.json")); !os.IsNotExist(err) {
t.Fatalf("fixture must represent an active root without completion metadata; stat err = %v", err)
}
report, err := ScanDiskUsage(root, nil)
if err != nil {
t.Fatalf("ScanDiskUsage: %v", err)
}
if len(report.Tasks) != 1 {
t.Fatalf("tasks = %d, want 1", len(report.Tasks))
}
usage := report.Tasks[0]
if usage.WorkspaceID != workspaceID {
t.Fatalf("workspace_id = %q, want owner identity %q", usage.WorkspaceID, workspaceID)
}
wantTaskSegment := filepath.Base(env.RootDir)
if usage.TaskShort != wantTaskSegment {
t.Fatalf("task_short = %q, want physical directory segment %q", usage.TaskShort, wantTaskSegment)
}
if len(report.Workspaces) != 1 || report.Workspaces[0].WorkspaceID != workspaceID {
t.Fatalf("workspace aggregate = %+v, want authoritative workspace %q", report.Workspaces, workspaceID)
}
}
func TestScanDiskUsage_ManagedCodexSandboxIsExactAndDeduplicated(t *testing.T) {
t.Parallel()
+197 -35
View File
@@ -37,10 +37,12 @@ type ProjectResourceForEnv struct {
// 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
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.
//
@@ -350,14 +352,59 @@ type Environment struct {
lockFile *os.File
}
// 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 == "" {
// 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(workspacesRoot, workspaceID, taskKey(taskID))
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.
@@ -374,7 +421,16 @@ func Prepare(params PrepareParams, logger *slog.Logger) (*Environment, error) {
return nil, fmt.Errorf("execenv: task ID is required")
}
envRoot := PredictRootDir(params.WorkspacesRoot, params.WorkspaceID, params.TaskID)
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
@@ -409,7 +465,7 @@ func Prepare(params PrepareParams, logger *slog.Logger) (*Environment, error) {
return nil, fmt.Errorf("execenv: create env root %s: %w", envRoot, err)
}
} else {
lock, reset, err := claimEnvRoot(envRoot, params.TaskID)
lock, reset, err := claimEnvRoot(envRoot, params.WorkspaceID, params.TaskID)
if err != nil {
return nil, fmt.Errorf("execenv: %w", err)
}
@@ -1013,7 +1069,10 @@ const (
// 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.
// 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
@@ -1222,12 +1281,15 @@ func (c *EnvRootClaim) Release() {
// 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(workspacesRoot, workspaceID, taskID string) (*EnvRootClaim, error) {
envRoot := PredictRootDir(workspacesRoot, workspaceID, taskID)
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, taskID)
lock, reset, err := claimEnvRoot(envRoot, params.WorkspaceID, params.TaskID)
if err != nil {
return nil, fmt.Errorf("execenv: %w", err)
}
@@ -1313,9 +1375,16 @@ func LockEnvRootForReuse(wsRoot *os.Root, rel, envRoot string) (*EnvRootClaim, o
return &EnvRootClaim{rootDir: envRoot, lock: lock}, info, nil
}
// envRootOwnerFile records which task an env root belongs to: WHO owns it.
// 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
@@ -1344,7 +1413,7 @@ const envRootLockFile = ".task_lock"
//
// 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, taskID string) (lockFile *os.File, reset bool, err error) {
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)
}
@@ -1370,18 +1439,28 @@ func claimEnvRoot(envRoot, taskID string) (lockFile *os.File, reset bool, err er
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)
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:
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 != "":
return nil, false, fmt.Errorf("env root %s belongs to task %s; refusing to reset it for task %s", envRoot, owner, taskID)
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
@@ -1394,7 +1473,7 @@ func claimEnvRoot(envRoot, taskID string) (lockFile *os.File, reset bool, err er
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, taskID); err != nil {
if err := writeEnvRootOwner(envRoot, workspaceID, taskID); err != nil {
return nil, false, err
}
return lockFile, false, nil
@@ -1423,13 +1502,61 @@ func releaseLockFile(f *os.File) {
_ = f.Close()
}
// writeEnvRootOwner records taskID as the owner. Callers must hold the env
// root lock, which is what lets this overwrite a marker torn by an earlier
// crash without racing another execution mid-claim.
func writeEnvRootOwner(envRoot, taskID string) error {
// 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)
if err := os.WriteFile(path, []byte(taskID), 0o644); err != nil {
return fmt.Errorf("record env root owner for %s: %w", envRoot, err)
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
}
@@ -1439,14 +1566,40 @@ func writeEnvRootOwner(envRoot, taskID string) error {
// 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) {
b, err := os.ReadFile(filepath.Join(envRoot, envRootOwnerFile))
if errors.Is(err, os.ErrNotExist) {
return "", nil
}
owner, err := ReadEnvRootOwner(envRoot)
if err != nil {
return "", err
}
return strings.TrimSpace(string(b)), nil
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
@@ -1485,5 +1638,14 @@ func envRootHoldsWork(envRoot string) (bool, error) {
}
func isEnvRootBookkeeping(name string) bool {
return name == envRootOwnerFile || name == envRootLockFile
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)
}
+492 -26
View File
@@ -12,6 +12,7 @@ import (
"strings"
"sync"
"testing"
"time"
)
func testLogger() *slog.Logger {
@@ -41,22 +42,395 @@ func TestShortID(t *testing.T) {
func TestPredictRootDir(t *testing.T) {
t.Parallel()
got := PredictRootDir("/root", "ws-uuid", "a1b2c3d4-e5f6-7890-abcd-ef1234567890")
want := filepath.Join("/root", "ws-uuid", "ef1234567890")
got := PredictRootDir(RootDirParams{
WorkspacesRoot: "/root",
WorkspaceID: "a05b0e10-ee7a-4603-a72d-a548b2390cb2",
WorkspaceSlug: "Asset Feed",
TaskID: "5c57b65b-ee7a-4603-a72d-b659c34a1dc3",
IssueIdentifier: "MUL-6063",
})
want := filepath.Join("/root", "asset-feed-a548b2390cb2", "mul-6063-b659c34a1dc3")
if got != want {
t.Errorf("PredictRootDir = %q, want %q", got, want)
}
if got := PredictRootDir("", "ws", "task"); got != "" {
if got := PredictRootDir(RootDirParams{WorkspaceID: "ws", TaskID: "task"}); got != "" {
t.Errorf("expected empty when workspaces root missing, got %q", got)
}
if got := PredictRootDir("/r", "", "task"); got != "" {
if got := PredictRootDir(RootDirParams{WorkspacesRoot: "/r", TaskID: "task"}); got != "" {
t.Errorf("expected empty when workspace ID missing, got %q", got)
}
if got := PredictRootDir("/r", "ws", ""); got != "" {
if got := PredictRootDir(RootDirParams{WorkspacesRoot: "/r", WorkspaceID: "ws"}); got != "" {
t.Errorf("expected empty when task ID missing, got %q", got)
}
}
func TestResolveRootDirFreezesReadableNamesBeforePrepare(t *testing.T) {
t.Parallel()
root := t.TempDir()
base := RootDirParams{
WorkspacesRoot: root,
WorkspaceID: "a05b0e10-ee7a-4603-a72d-a548b2390cb2",
TaskID: "5c57b65b-ee7a-4603-a72d-b659c34a1dc3",
}
first, err := ResolveRootDir(base)
if err != nil {
t.Fatalf("resolve fallback root: %v", err)
}
if _, err := os.Stat(first); !os.IsNotExist(err) {
t.Fatalf("ResolveRootDir should freeze identity before creating the env root; stat err = %v", err)
}
enriched := base
enriched.WorkspaceSlug = "Asset Feed"
enriched.IssueIdentifier = "MUL-6063"
second, err := ResolveRootDir(enriched)
if err != nil {
t.Fatalf("resolve enriched root: %v", err)
}
if second != first {
t.Fatalf("same task moved from %q to %q when readable fields arrived", first, second)
}
renamed := enriched
renamed.WorkspaceSlug = "Renamed Workspace"
renamed.IssueIdentifier = "NEW-6063"
third, err := ResolveRootDir(renamed)
if err != nil {
t.Fatalf("resolve renamed root: %v", err)
}
if third != first {
t.Fatalf("same task moved from %q to %q after workspace/issue rename", first, third)
}
env, err := Prepare(PrepareParams{
WorkspacesRoot: renamed.WorkspacesRoot,
WorkspaceID: renamed.WorkspaceID,
WorkspaceSlug: renamed.WorkspaceSlug,
TaskID: renamed.TaskID,
IssueIdentifier: renamed.IssueIdentifier,
AgentName: "Stable Root",
Task: TaskContextForEnv{IssueID: "issue-stable-root"},
}, testLogger())
if err != nil {
t.Fatalf("Prepare after pre-StartTask reclaim: %v", err)
}
defer env.Cleanup(true)
if env.RootDir != first {
t.Fatalf("Prepare root = %q, want frozen root %q", env.RootDir, first)
}
liveRename := renamed
liveRename.WorkspaceSlug = "Renamed Again"
liveRename.IssueIdentifier = "NEXT-6063"
afterPrepare, err := ResolveRootDir(liveRename)
if err != nil {
t.Fatalf("resolve after live rename: %v", err)
}
if afterPrepare != first {
t.Fatalf("live task moved from %q to %q after issue prefix changed", first, afterPrepare)
}
}
func TestResolveRootDirAdoptsExistingOwnedRootBeforeIndex(t *testing.T) {
t.Parallel()
root := t.TempDir()
const (
workspaceID = "a05b0e10-ee7a-4603-a72d-a548b2390cb2"
taskID = "5c57b65b-ee7a-4603-a72d-b659c34a1dc3"
)
original := PredictRootDir(RootDirParams{
WorkspacesRoot: root,
WorkspaceID: workspaceID,
WorkspaceSlug: "Asset Feed",
TaskID: taskID,
IssueIdentifier: "MUL-6063",
})
if err := os.MkdirAll(original, 0o755); err != nil {
t.Fatalf("seed existing root: %v", err)
}
if err := writeEnvRootOwner(original, workspaceID, taskID); err != nil {
t.Fatalf("seed existing owner: %v", err)
}
resolved, err := ResolveRootDir(RootDirParams{
WorkspacesRoot: root,
WorkspaceID: workspaceID,
WorkspaceSlug: "Renamed Workspace",
TaskID: taskID,
IssueIdentifier: "NEW-6063",
})
if err != nil {
t.Fatalf("resolve renamed existing root: %v", err)
}
if resolved != original {
t.Fatalf("existing owned root %q was orphaned in favor of %q", original, resolved)
}
}
func TestResolveRootDirAdoptsRootWithInterruptedOwnerTemp(t *testing.T) {
t.Parallel()
root := t.TempDir()
const (
workspaceID = "a05b0e10-ee7a-4603-a72d-a548b2390cb2"
taskID = "5c57b65b-ee7a-4603-a72d-b659c34a1dc3"
)
original := PredictRootDir(RootDirParams{
WorkspacesRoot: root,
WorkspaceID: workspaceID,
WorkspaceSlug: "Asset Feed",
TaskID: taskID,
IssueIdentifier: "MUL-6063",
})
if err := os.MkdirAll(original, 0o755); err != nil {
t.Fatalf("seed existing root: %v", err)
}
tempOwner := filepath.Join(original, envRootOwnerTempPrefix+"crashed"+envRootOwnerTempSuffix)
if err := os.WriteFile(tempOwner, []byte(`{"workspace_id":"`+workspaceID+`"`), 0o600); err != nil {
t.Fatalf("seed interrupted owner temp: %v", err)
}
resolved, err := ResolveRootDir(RootDirParams{
WorkspacesRoot: root,
WorkspaceID: workspaceID,
WorkspaceSlug: "Renamed Workspace",
TaskID: taskID,
IssueIdentifier: "NEW-6063",
})
if err != nil {
t.Fatalf("resolve root with interrupted owner temp: %v", err)
}
if resolved != original {
t.Fatalf("recoverable root %q was orphaned in favor of %q", original, resolved)
}
if _, err := os.Stat(tempOwner); err != nil {
t.Fatalf("resolution unexpectedly mutated the candidate root: %v", err)
}
}
func TestResolveRootDirConcurrentFirstClaimsChooseOnePhysicalRoot(t *testing.T) {
t.Parallel()
root := t.TempDir()
params := []RootDirParams{
{
WorkspacesRoot: root,
WorkspaceID: "a05b0e10-ee7a-4603-a72d-a548b2390cb2",
TaskID: "5c57b65b-ee7a-4603-a72d-b659c34a1dc3",
},
{
WorkspacesRoot: root,
WorkspaceID: "a05b0e10-ee7a-4603-a72d-a548b2390cb2",
WorkspaceSlug: "Asset Feed",
TaskID: "5c57b65b-ee7a-4603-a72d-b659c34a1dc3",
IssueIdentifier: "MUL-6063",
},
}
start := make(chan struct{})
paths := make([]string, len(params))
errs := make([]error, len(params))
var wg sync.WaitGroup
for i := range params {
wg.Add(1)
go func() {
defer wg.Done()
<-start
paths[i], errs[i] = ResolveRootDir(params[i])
}()
}
close(start)
wg.Wait()
for i, err := range errs {
if err != nil {
t.Fatalf("resolve %d: %v", i, err)
}
}
if paths[0] != paths[1] {
t.Fatalf("concurrent claims chose two roots: %q and %q", paths[0], paths[1])
}
}
func TestRemoveRootDirRecordKeepsSharedIndexParent(t *testing.T) {
t.Parallel()
params := RootDirParams{
WorkspacesRoot: t.TempDir(),
WorkspaceID: "a05b0e10-ee7a-4603-a72d-a548b2390cb2",
TaskID: "5c57b65b-ee7a-4603-a72d-b659c34a1dc3",
}
envRoot, err := ResolveRootDir(params)
if err != nil {
t.Fatalf("ResolveRootDir: %v", err)
}
if err := RemoveRootDirRecord(params.WorkspacesRoot, envRoot, EnvRootOwner{
WorkspaceID: params.WorkspaceID,
TaskID: params.TaskID,
}); err != nil {
t.Fatalf("RemoveRootDirRecord: %v", err)
}
indexInfo, err := os.Stat(filepath.Join(params.WorkspacesRoot, taskRootIndexDir))
if err != nil {
t.Fatalf("shared task root index was removed: %v", err)
}
if !indexInfo.IsDir() {
t.Fatal("shared task root index is not a directory")
}
}
func TestPruneTaskRootIndexBoundsAbandonedEntries(t *testing.T) {
t.Parallel()
root := t.TempDir()
terminal := RootDirParams{
WorkspacesRoot: root,
WorkspaceID: "a05b0e10-ee7a-4603-a72d-a548b2390cb2",
TaskID: "5c57b65b-ee7a-4603-a72d-b659c34a1dc3",
}
running := terminal
running.TaskID = "6d68c76c-ff8b-5704-b83e-c76ad45b2ed4"
materialized := terminal
materialized.TaskID = "7e79d87d-008c-6805-c94f-d87be56c3fe5"
recent := terminal
recent.TaskID = "8f8ae98e-119d-7906-da50-e98cf67d40f6"
var materializedRoot string
for _, params := range []RootDirParams{terminal, running, materialized, recent} {
resolved, err := ResolveRootDir(params)
if err != nil {
t.Fatalf("ResolveRootDir(%s): %v", params.TaskID, err)
}
if params.TaskID == materialized.TaskID {
materializedRoot = resolved
}
}
if err := os.MkdirAll(materializedRoot, 0o755); err != nil {
t.Fatalf("materialize protected env root: %v", err)
}
indexDir := filepath.Join(root, taskRootIndexDir)
stalePending := filepath.Join(indexDir, taskRootPendingPrefix+"stale")
recentPending := filepath.Join(indexDir, taskRootPendingPrefix+"recent")
for _, dir := range []string{stalePending, recentPending} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("seed pending entry: %v", err)
}
}
now := time.Now()
old := now.Add(-2 * taskRootIndexMinPruneAge)
for _, params := range []RootDirParams{terminal, running, materialized} {
recordPath := filepath.Join(taskRootRecordDir(params), taskRootRecordFile)
if err := os.Chtimes(recordPath, old, old); err != nil {
t.Fatalf("age task root record %s: %v", recordPath, err)
}
}
if err := os.Chtimes(stalePending, old, old); err != nil {
t.Fatalf("age pending entry %s: %v", stalePending, err)
}
removed, err := PruneTaskRootIndex(root, 0, now, func(_, taskID string) bool {
return taskID == terminal.TaskID || taskID == materialized.TaskID || taskID == recent.TaskID
})
if err != nil {
t.Fatalf("PruneTaskRootIndex: %v", err)
}
if removed != 2 {
t.Fatalf("removed = %d, want terminal record plus stale pending entry", removed)
}
for _, removedPath := range []string{taskRootRecordDir(terminal), stalePending} {
if _, err := os.Stat(removedPath); !os.IsNotExist(err) {
t.Fatalf("stale entry %s still exists: %v", removedPath, err)
}
}
for _, keptPath := range []string{taskRootRecordDir(running), taskRootRecordDir(materialized), taskRootRecordDir(recent), recentPending} {
if _, err := os.Stat(keptPath); err != nil {
t.Fatalf("protected entry %s was removed: %v", keptPath, err)
}
}
}
func TestResolveRootDirRejectsRecordOutsideStableIdentity(t *testing.T) {
t.Parallel()
params := RootDirParams{
WorkspacesRoot: t.TempDir(),
WorkspaceID: "a05b0e10-ee7a-4603-a72d-a548b2390cb2",
TaskID: "5c57b65b-ee7a-4603-a72d-b659c34a1dc3",
}
if err := installTaskRootRecord(taskRootRecordDir(params), taskRootRecord{
WorkspaceID: params.WorkspaceID,
TaskID: params.TaskID,
RelativePath: filepath.Join("unrelated-workspace", "unrelated-task"),
}); err != nil {
t.Fatalf("seed corrupt record: %v", err)
}
if _, err := ResolveRootDir(params); err == nil {
t.Fatal("ResolveRootDir accepted a record outside the task's stable identity")
} else if !strings.Contains(err.Error(), "does not match its stable identity") {
t.Fatalf("error = %v, want stable identity rejection", err)
} else if !strings.Contains(err.Error(), taskRootRecordDir(params)) {
t.Fatalf("error = %v, want actionable record directory", err)
}
}
func TestReadablePathSegmentSanitizesUserControlledLabels(t *testing.T) {
t.Parallel()
tests := []struct {
name string
label string
fallback string
id string
want string
}{
{name: "separators and traversal", label: `../Asset\\Feed/Team`, fallback: "workspace", id: "a05b0e10-ee7a-4603-a72d-a548b2390cb2", want: "asset-feed-a548b2390cb2"},
{name: "non ascii removed", label: "日本語 Product", fallback: "workspace", id: "a05b0e10-ee7a-4603-a72d-a548b2390cb2", want: "product-a548b2390cb2"},
{name: "case normalized", label: "MUL-6063", fallback: "task", id: "5c57b65b-ee7a-4603-a72d-a548b2390cb2", want: "mul-6063-a548b2390cb2"},
{name: "empty label falls back", label: "...", fallback: "task", id: "5c57b65b-ee7a-4603-a72d-a548b2390cb2", want: "task-a548b2390cb2"},
{name: "label is bounded", label: strings.Repeat("a", 100), fallback: "task", id: "5c57b65b-ee7a-4603-a72d-a548b2390cb2", want: strings.Repeat("a", 11) + "-a548b2390cb2"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := readablePathSegment(tt.label, tt.fallback, tt.id); got != tt.want {
t.Fatalf("readablePathSegment(%q) = %q, want %q", tt.label, got, tt.want)
}
})
}
}
func TestPredictRootDirWorstCaseLabelsStayWithinWindowsBudget(t *testing.T) {
t.Parallel()
root := PredictRootDir(RootDirParams{
WorkspacesRoot: "/root",
WorkspaceID: "a05b0e10-ee7a-4603-a72d-a548b2390cb2",
WorkspaceSlug: strings.Repeat("workspace", 20),
TaskID: "01a01ec0-e69d-7000-8000-0123456789ab",
IssueIdentifier: strings.Repeat("issue", 20),
})
rel, err := filepath.Rel("/root", root)
if err != nil {
t.Fatalf("relative env root: %v", err)
}
parts := strings.Split(rel, string(filepath.Separator))
if len(parts) != 2 {
t.Fatalf("relative env root = %q, want exactly two segments", rel)
}
for _, part := range parts {
if len(part) > readablePathSegmentMax {
t.Fatalf("path segment %q has length %d, want <= %d", part, len(part), readablePathSegmentMax)
}
}
// main's opaque layout spent 36 + 1 + 12 characters below WorkspacesRoot.
// The readable layout must not consume a larger Windows path budget.
if got, max := len(rel), 36+1+taskKeyLen; got > max {
t.Fatalf("relative env root %q has length %d, want <= %d", rel, got, max)
}
}
func TestSanitizeName(t *testing.T) {
t.Parallel()
tests := []struct {
@@ -6171,7 +6545,13 @@ func TestPredictRootDirDistinctForSharedUUIDv7Prefix(t *testing.T) {
}
seen := make(map[string]string, len(ids))
for _, id := range ids {
root := PredictRootDir("/root", "ws-uuid", id)
root := PredictRootDir(RootDirParams{
WorkspacesRoot: "/root",
WorkspaceID: "a05b0e10-ee7a-4603-a72d-a548b2390cb2",
WorkspaceSlug: "Asset Feed",
TaskID: id,
IssueIdentifier: "MUL-6063",
})
if prev, dup := seen[root]; dup {
t.Fatalf("tasks %s and %s share env root %q — a truncated task id is back", prev, id, root)
}
@@ -6208,11 +6588,13 @@ func TestPrepareDoesNotDeleteConcurrentTaskEnv(t *testing.T) {
)
envA, err := Prepare(PrepareParams{
WorkspacesRoot: workspacesRoot,
WorkspaceID: "ws-collision",
TaskID: taskA,
AgentName: "Agent A",
Task: TaskContextForEnv{IssueID: taskA},
WorkspacesRoot: workspacesRoot,
WorkspaceID: "a05b0e10-ee7a-4603-a72d-a548b2390cb2",
WorkspaceSlug: "Asset Feed",
TaskID: taskA,
IssueIdentifier: "MUL-6063",
AgentName: "Agent A",
Task: TaskContextForEnv{IssueID: taskA},
}, testLogger())
if err != nil {
t.Fatalf("Prepare task A: %v", err)
@@ -6226,11 +6608,13 @@ func TestPrepareDoesNotDeleteConcurrentTaskEnv(t *testing.T) {
}
envB, err := Prepare(PrepareParams{
WorkspacesRoot: workspacesRoot,
WorkspaceID: "ws-collision",
TaskID: taskB,
AgentName: "Agent B",
Task: TaskContextForEnv{IssueID: taskB},
WorkspacesRoot: workspacesRoot,
WorkspaceID: "a05b0e10-ee7a-4603-a72d-a548b2390cb2",
WorkspaceSlug: "Asset Feed",
TaskID: taskB,
IssueIdentifier: "MUL-6063",
AgentName: "Agent B",
Task: TaskContextForEnv{IssueID: taskB},
}, testLogger())
if err != nil {
t.Fatalf("Prepare task B: %v", err)
@@ -6280,11 +6664,17 @@ func TestPrepareRefusesEnvRootOwnedByAnotherTask(t *testing.T) {
workspacesRoot := t.TempDir()
const taskID = "01a01ec0-e69d-7000-8000-0123456789ab"
envRoot := PredictRootDir(workspacesRoot, "ws-owned", taskID)
envRoot := PredictRootDir(RootDirParams{
WorkspacesRoot: workspacesRoot,
WorkspaceID: "ws-owned",
WorkspaceSlug: "Owned Workspace",
TaskID: taskID,
IssueIdentifier: "MUL-1",
})
if err := os.MkdirAll(filepath.Join(envRoot, "workdir"), 0o755); err != nil {
t.Fatalf("seed env root: %v", err)
}
if err := writeEnvRootOwner(envRoot, "11111111-2222-3333-4444-555555555555"); err != nil {
if err := writeEnvRootOwner(envRoot, "ws-owned", "11111111-2222-3333-4444-555555555555"); err != nil {
t.Fatalf("seed owner: %v", err)
}
survivor := filepath.Join(envRoot, "workdir", "other-task-work.txt")
@@ -6293,11 +6683,13 @@ func TestPrepareRefusesEnvRootOwnedByAnotherTask(t *testing.T) {
}
_, err := Prepare(PrepareParams{
WorkspacesRoot: workspacesRoot,
WorkspaceID: "ws-owned",
TaskID: taskID,
AgentName: "Intruder",
Task: TaskContextForEnv{IssueID: taskID},
WorkspacesRoot: workspacesRoot,
WorkspaceID: "ws-owned",
WorkspaceSlug: "Owned Workspace",
TaskID: taskID,
IssueIdentifier: "MUL-1",
AgentName: "Intruder",
Task: TaskContextForEnv{IssueID: taskID},
}, testLogger())
if err == nil {
t.Fatal("Prepare accepted an env root owned by another task")
@@ -6439,7 +6831,7 @@ func TestClaimEnvRootRefusesUnownedDirectoryWithContent(t *testing.T) {
t.Fatalf("seed: %v", err)
}
if _, _, err := claimEnvRoot(envRoot, "aaaaaaaa-1111-2222-3333-0123456789ab"); err == nil {
if _, _, err := claimEnvRoot(envRoot, "ws", "aaaaaaaa-1111-2222-3333-0123456789ab"); err == nil {
t.Fatal("claimEnvRoot took a directory holding files with no owner")
} else if !strings.Contains(err.Error(), "names no owning task") {
t.Fatalf("error = %v, want it to explain the missing owner", err)
@@ -6459,7 +6851,7 @@ func TestClaimEnvRootAdoptsEmptyDirectory(t *testing.T) {
t.Fatalf("seed: %v", err)
}
const id = "aaaaaaaa-1111-2222-3333-0123456789ab"
lock, reset, err := claimEnvRoot(envRoot, id)
lock, reset, err := claimEnvRoot(envRoot, "ws", id)
if err != nil {
t.Fatalf("claimEnvRoot on an empty directory: %v", err)
}
@@ -6578,7 +6970,7 @@ func TestClaimEnvRootRepairsTornOwnerMarker(t *testing.T) {
}
const id = "aaaaaaaa-1111-2222-3333-0123456789ab"
lock, _, err := claimEnvRoot(envRoot, id)
lock, _, err := claimEnvRoot(envRoot, "ws", id)
if err != nil {
t.Fatalf("claimEnvRoot wedged on a torn marker: %v", err)
}
@@ -6588,6 +6980,80 @@ func TestClaimEnvRootRepairsTornOwnerMarker(t *testing.T) {
}
}
// TestWriteEnvRootOwnerAtomicallyReplacesMarker pins the write protocol used
// when a legacy task-only marker is upgraded. Rewriting the file in place can
// expose empty or partial JSON to disk-usage readers and permanently wedge the
// root after a crash. Renaming a complete same-directory temp file changes the
// file identity while leaving only a fully parseable marker at the public path.
func TestWriteEnvRootOwnerAtomicallyReplacesMarker(t *testing.T) {
t.Parallel()
envRoot := t.TempDir()
ownerPath := filepath.Join(envRoot, envRootOwnerFile)
const taskID = "aaaaaaaa-1111-2222-3333-0123456789ab"
if err := os.WriteFile(ownerPath, []byte(taskID), 0o644); err != nil {
t.Fatalf("seed legacy owner: %v", err)
}
before, err := os.Stat(ownerPath)
if err != nil {
t.Fatalf("stat legacy owner: %v", err)
}
if err := writeEnvRootOwner(envRoot, "ws-authoritative", taskID); err != nil {
t.Fatalf("upgrade owner: %v", err)
}
after, err := os.Stat(ownerPath)
if err != nil {
t.Fatalf("stat upgraded owner: %v", err)
}
if os.SameFile(before, after) {
t.Fatal("owner marker was rewritten in place instead of atomically replaced")
}
owner, err := ReadEnvRootOwner(envRoot)
if err != nil {
t.Fatalf("read upgraded owner: %v", err)
}
if owner.WorkspaceID != "ws-authoritative" || owner.TaskID != taskID {
t.Fatalf("owner = %#v, want authoritative workspace and task identity", owner)
}
entries, err := os.ReadDir(envRoot)
if err != nil {
t.Fatalf("read env root: %v", err)
}
if len(entries) != 1 || entries[0].Name() != envRootOwnerFile {
t.Fatalf("env root entries = %v, want only %s", entries, envRootOwnerFile)
}
}
func TestClaimEnvRootRecoversOwnerTempLeftBeforeRename(t *testing.T) {
t.Parallel()
envRoot := t.TempDir()
staleTemp := filepath.Join(envRoot, envRootOwnerTempPrefix+"crashed"+envRootOwnerTempSuffix)
if err := os.WriteFile(staleTemp, []byte(`{"workspace_id":"ws"`), 0o600); err != nil {
t.Fatalf("seed unpublished owner temp: %v", err)
}
const taskID = "aaaaaaaa-1111-2222-3333-0123456789ab"
lock, reset, err := claimEnvRoot(envRoot, "ws", taskID)
if err != nil {
t.Fatalf("claim after interrupted owner write: %v", err)
}
defer releaseLockFile(lock)
if reset {
t.Fatal("recovering an unpublished owner write should not reset the env root")
}
if _, err := os.Stat(staleTemp); !os.IsNotExist(err) {
t.Fatalf("stale owner temp still exists: %v", err)
}
owner, err := ReadEnvRootOwner(envRoot)
if err != nil {
t.Fatalf("read recovered owner: %v", err)
}
if owner.WorkspaceID != "ws" || owner.TaskID != taskID {
t.Fatalf("owner = %#v, want recovered workspace and task identity", owner)
}
}
// TestReleaseLockFreesEnvRootForALaterDispatch pins the lifetime rule that
// Windows CI surfaced: the lock belongs to the task EXECUTION, and nothing in
// production calls Environment.Cleanup — the GC reclaims env roots on its own
@@ -277,7 +277,14 @@ func TestPrepareIsolatedKeepsTheClaimWithTheParent(t *testing.T) {
taskID = "01a01ec0-e69d-7000-8000-0123456789ab"
)
claim, err := ClaimEnvRoot(workspacesRoot, workspaceID, taskID)
rootParams := RootDirParams{
WorkspacesRoot: workspacesRoot,
WorkspaceID: workspaceID,
WorkspaceSlug: "Readable Workspace",
TaskID: taskID,
IssueIdentifier: "MUL-6063",
}
claim, err := ClaimEnvRoot(rootParams)
if err != nil {
t.Fatalf("parent claim: %v", err)
}
@@ -286,7 +293,9 @@ func TestPrepareIsolatedKeepsTheClaimWithTheParent(t *testing.T) {
env, err := PrepareIsolated(context.Background(), preparationHelperTestCommand(), PrepareParams{
WorkspacesRoot: workspacesRoot,
WorkspaceID: workspaceID,
WorkspaceSlug: "Readable Workspace",
TaskID: taskID,
IssueIdentifier: "MUL-6063",
AgentName: "Isolated",
EnvRootPreclaimed: true,
Task: TaskContextForEnv{IssueID: taskID},
@@ -297,17 +306,20 @@ func TestPrepareIsolatedKeepsTheClaimWithTheParent(t *testing.T) {
if env == nil || env.WorkDir == "" {
t.Fatal("PrepareIsolated returned no environment")
}
if env.RootDir != claim.RootDir() {
t.Fatalf("helper prepared %q while parent claimed %q", env.RootDir, claim.RootDir())
}
// The helper has exited. If the claim had been taken inside it, the lock
// would be gone and this second claim would succeed.
if second, err := ClaimEnvRoot(workspacesRoot, workspaceID, taskID); err == nil {
if second, err := ClaimEnvRoot(rootParams); err == nil {
second.Release()
t.Fatal("production PrepareIsolated returned without retaining the execution lock")
}
// And releasing it must hand the env root back for a later dispatch.
claim.Release()
next, err := ClaimEnvRoot(workspacesRoot, workspaceID, taskID)
next, err := ClaimEnvRoot(rootParams)
if err != nil {
t.Fatalf("env root stayed locked after release: %v", err)
}
@@ -380,7 +392,7 @@ func TestPrepareIsolatedFailsLoudlyWhenPreclaimIsNotDeclared(t *testing.T) {
taskID = "01a01ec0-e69d-7000-8000-0123456789ab"
)
claim, err := ClaimEnvRoot(workspacesRoot, workspaceID, taskID)
claim, err := ClaimEnvRoot(RootDirParams{WorkspacesRoot: workspacesRoot, WorkspaceID: workspaceID, TaskID: taskID})
if err != nil {
t.Fatalf("parent claim: %v", err)
}
@@ -49,7 +49,7 @@ func TestPrepareIsolated_PermanentFIFOBlockThenImmediateRetry(t *testing.T) {
if elapsed := time.Since(startedAt); elapsed > 3*time.Second {
t.Fatalf("PrepareIsolated took %s, want a bounded process termination", elapsed)
}
if _, err := os.Stat(PredictRootDir(params.WorkspacesRoot, params.WorkspaceID, params.TaskID)); err != nil {
if _, err := os.Stat(PredictRootDir(RootDirParams{WorkspacesRoot: params.WorkspacesRoot, WorkspaceID: params.WorkspaceID, WorkspaceSlug: params.WorkspaceSlug, TaskID: params.TaskID, IssueIdentifier: params.IssueIdentifier})); err != nil {
t.Fatalf("first helper did not reach environment preparation before timeout: %v", err)
}
@@ -79,7 +79,7 @@ func TestPrepareIsolated_PermanentFIFOBlockThenImmediateRetry(t *testing.T) {
if err != nil {
t.Fatalf("immediate retry PrepareIsolated: %v", err)
}
if env == nil || env.RootDir != PredictRootDir(params.WorkspacesRoot, params.WorkspaceID, params.TaskID) {
if env == nil || env.RootDir != PredictRootDir(RootDirParams{WorkspacesRoot: params.WorkspacesRoot, WorkspaceID: params.WorkspaceID, WorkspaceSlug: params.WorkspaceSlug, TaskID: params.TaskID, IssueIdentifier: params.IssueIdentifier}) {
t.Fatalf("retry environment = %#v, want the predicted root", env)
}
}
@@ -9,6 +9,7 @@ import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
@@ -92,11 +93,13 @@ func TestPrepareIsolated_WindowsKillsDescendantBeforeRetry(t *testing.T) {
ready,
}
params := PrepareParams{
WorkspacesRoot: filepath.Join(dir, "workspaces"),
WorkspaceID: "ws-windows-job",
TaskID: "12345678-1111-2222-3333-444444444444",
Provider: "claude",
Task: TaskContextForEnv{IssueID: "issue-windows-job"},
WorkspacesRoot: filepath.Join(dir, "workspaces"),
WorkspaceID: "a05b0e10-ee7a-4603-a72d-a548b2390cb2",
WorkspaceSlug: strings.Repeat("workspace", 20),
TaskID: "12345678-1111-7222-8333-444444444444",
IssueIdentifier: strings.Repeat("issue", 20),
Provider: "claude",
Task: TaskContextForEnv{IssueID: "issue-windows-job"},
}
ctx, cancel := context.WithCancel(context.Background())
@@ -136,7 +139,7 @@ func TestPrepareIsolated_WindowsKillsDescendantBeforeRetry(t *testing.T) {
if err != nil {
t.Fatalf("immediate retry PrepareIsolated: %v", err)
}
if env == nil || env.RootDir != PredictRootDir(params.WorkspacesRoot, params.WorkspaceID, params.TaskID) {
if env == nil || env.RootDir != PredictRootDir(RootDirParams{WorkspacesRoot: params.WorkspacesRoot, WorkspaceID: params.WorkspaceID, WorkspaceSlug: params.WorkspaceSlug, TaskID: params.TaskID, IssueIdentifier: params.IssueIdentifier}) {
t.Fatalf("retry environment = %#v, want the predicted root", env)
}
}
@@ -0,0 +1,365 @@
package execenv
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
const (
taskRootIndexDir = ".task_roots"
taskRootRecordFile = "root.json"
// taskRootPendingPrefix names the staging directory installTaskRootRecord
// renames into place. A directory still carrying it never became
// authoritative for any task.
taskRootPendingPrefix = ".pending-"
taskRootIndexMinPruneAge = time.Hour
)
type taskRootRecord struct {
WorkspaceID string `json:"workspace_id"`
TaskID string `json:"task_id"`
RelativePath string `json:"relative_path"`
}
// ResolveRootDir returns the one physical env root assigned to a task. The
// first caller freezes the readable path in an index keyed only by stable IDs;
// later claims keep that path even when display labels are added or renamed.
func ResolveRootDir(params RootDirParams) (string, error) {
proposed := PredictRootDir(params)
if proposed == "" {
return "", nil
}
recordDir := taskRootRecordDir(params)
record, err := readTaskRootRecord(recordDir)
if err == nil {
return validateTaskRootRecord(params, recordDir, record)
}
if !errors.Is(err, os.ErrNotExist) {
return "", err
}
candidate, err := findOwnedTaskRoot(params)
if err != nil {
return "", err
}
if candidate == "" {
candidate = proposed
}
relative, err := filepath.Rel(params.WorkspacesRoot, candidate)
if err != nil {
return "", fmt.Errorf("execenv: make task root relative: %w", err)
}
record = taskRootRecord{
WorkspaceID: params.WorkspaceID,
TaskID: params.TaskID,
RelativePath: relative,
}
if err := installTaskRootRecord(recordDir, record); err != nil {
return "", err
}
// Another claimant may have won the atomic install with different readable
// labels. Always re-read the authoritative record instead of returning our
// proposal.
record, err = readTaskRootRecord(recordDir)
if err != nil {
return "", err
}
return validateTaskRootRecord(params, recordDir, record)
}
func taskRootRecordDir(params RootDirParams) string {
return filepath.Join(
params.WorkspacesRoot,
taskRootIndexDir,
stableIdentityKey(params.WorkspaceID+"\x00"+params.TaskID),
)
}
func stableIdentityKey(value string) string {
sum := sha256.Sum256([]byte(value))
return hex.EncodeToString(sum[:16])
}
func readTaskRootRecord(recordDir string) (taskRootRecord, error) {
recordPath := filepath.Join(recordDir, taskRootRecordFile)
data, err := os.ReadFile(recordPath)
if err != nil {
return taskRootRecord{}, err
}
var record taskRootRecord
if err := json.Unmarshal(data, &record); err != nil {
return taskRootRecord{}, fmt.Errorf("execenv: decode task root record %s: %w", recordPath, err)
}
return record, nil
}
func installTaskRootRecord(recordDir string, record taskRootRecord) error {
parent := filepath.Dir(recordDir)
if err := os.MkdirAll(parent, 0o755); err != nil {
return fmt.Errorf("execenv: create task root index: %w", err)
}
tmpDir, err := os.MkdirTemp(parent, taskRootPendingPrefix)
if err != nil {
return fmt.Errorf("execenv: create task root record: %w", err)
}
defer os.RemoveAll(tmpDir)
data, err := json.Marshal(record)
if err != nil {
return fmt.Errorf("execenv: encode task root record: %w", err)
}
if err := os.WriteFile(filepath.Join(tmpDir, taskRootRecordFile), data, 0o644); err != nil {
return fmt.Errorf("execenv: write task root record: %w", err)
}
if err := os.Rename(tmpDir, recordDir); err != nil {
// A complete non-empty directory is installed atomically. If it exists,
// another claimant won and its record is authoritative.
if _, readErr := readTaskRootRecord(recordDir); readErr == nil {
return nil
}
return fmt.Errorf("execenv: install task root record: %w", err)
}
return nil
}
// validateTaskRootRecord fails closed on anything it cannot vouch for: a task
// that cannot prove which root is its own must not fall back to proposing a
// fresh one, because the original may still hold live work. That refusal is
// permanent until an operator intervenes, so every error names recordDir —
// the one path they need to inspect or remove.
func validateTaskRootRecord(params RootDirParams, recordDir string, record taskRootRecord) (string, error) {
if record.WorkspaceID != params.WorkspaceID || record.TaskID != params.TaskID {
return "", fmt.Errorf("execenv: task root record %s belongs to workspace %s task %s, not workspace %s task %s",
recordDir, record.WorkspaceID, record.TaskID, params.WorkspaceID, params.TaskID)
}
relative := filepath.Clean(record.RelativePath)
if relative == "." || filepath.IsAbs(relative) {
return "", fmt.Errorf("execenv: task root record %s holds invalid relative path %q", recordDir, record.RelativePath)
}
parts := strings.Split(relative, string(filepath.Separator))
if len(parts) != 2 || parts[0] == "" || parts[1] == "" || parts[0] == ".." || parts[1] == ".." {
return "", fmt.Errorf("execenv: task root record %s holds invalid relative path %q", recordDir, record.RelativePath)
}
if !validTaskRootSegment(parts[0], params.WorkspaceID, true) || !validTaskRootSegment(parts[1], params.TaskID, false) {
return "", fmt.Errorf("execenv: task root record %s points at %q, which does not match its stable identity", recordDir, record.RelativePath)
}
return filepath.Join(params.WorkspacesRoot, relative), nil
}
func validTaskRootSegment(segment, id string, workspace bool) bool {
segment = strings.ToLower(segment)
id = strings.ToLower(id)
key := strings.ToLower(taskKey(id))
if workspace && segment == id {
return true
}
if !workspace && segment == key {
return true
}
return strings.HasSuffix(segment, "-"+key)
}
// RemoveRootDirRecord removes the stable index after GC has reclaimed a
// terminal task root. It verifies that the record still points at envRoot so a
// stale cleanup can never remove another task's identity.
func RemoveRootDirRecord(workspacesRoot, envRoot string, owner EnvRootOwner) error {
if workspacesRoot == "" || envRoot == "" || owner.WorkspaceID == "" || owner.TaskID == "" {
return nil
}
params := RootDirParams{
WorkspacesRoot: workspacesRoot,
WorkspaceID: owner.WorkspaceID,
TaskID: owner.TaskID,
}
recordDir := taskRootRecordDir(params)
record, err := readTaskRootRecord(recordDir)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return err
}
resolved, err := validateTaskRootRecord(params, recordDir, record)
if err != nil {
return err
}
if filepath.Clean(resolved) != filepath.Clean(envRoot) {
return fmt.Errorf("execenv: task root record points to %s, not reclaimed root %s", resolved, envRoot)
}
if err := os.RemoveAll(recordDir); err != nil {
return fmt.Errorf("execenv: remove task root record: %w", err)
}
// The index directory itself is deliberately left in place. Removing it
// when the last record goes would race installTaskRootRecord, which does
// MkdirAll(parent) and then MkdirTemp(parent, ...): a removal landing
// between those two calls hands the claim an ENOENT and fails the task,
// which is a poor trade for one empty directory.
return nil
}
// PruneTaskRootIndex removes abandoned unpublished directories and stable
// records whose physical env root never materialized. Complete records are
// removed only when eligible confirms the task is terminal or inaccessible;
// a live/non-terminal task keeps its frozen path even when the root is absent.
//
// A minimum grace period protects the tiny publication window even when the
// operator configures the general orphan TTL as zero.
func PruneTaskRootIndex(workspacesRoot string, retention time.Duration, now time.Time, eligible func(workspaceID, taskID string) bool) (removed int, err error) {
indexDir := filepath.Join(workspacesRoot, taskRootIndexDir)
entries, readErr := os.ReadDir(indexDir)
if errors.Is(readErr, os.ErrNotExist) {
return 0, nil
}
if readErr != nil {
return 0, fmt.Errorf("execenv: read task root index: %w", readErr)
}
pruneAge := retention
if pruneAge < taskRootIndexMinPruneAge {
pruneAge = taskRootIndexMinPruneAge
}
var errs []error
for _, entry := range entries {
if !entry.IsDir() {
continue
}
entryPath := filepath.Join(indexDir, entry.Name())
age, ok := taskRootRecordAge(entryPath, now)
if !ok || age <= pruneAge {
continue
}
if strings.HasPrefix(entry.Name(), taskRootPendingPrefix) {
if removeErr := os.RemoveAll(entryPath); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) {
errs = append(errs, fmt.Errorf("remove stale task root pending entry %s: %w", entryPath, removeErr))
continue
}
removed++
continue
}
record, recordErr := readTaskRootRecord(entryPath)
if errors.Is(recordErr, os.ErrNotExist) {
if removeErr := os.RemoveAll(entryPath); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) {
errs = append(errs, fmt.Errorf("remove stale empty task root record %s: %w", entryPath, removeErr))
continue
}
removed++
continue
}
if recordErr != nil {
errs = append(errs, fmt.Errorf("read stale task root record %s: %w", entryPath, recordErr))
continue
}
params := RootDirParams{
WorkspacesRoot: workspacesRoot,
WorkspaceID: record.WorkspaceID,
TaskID: record.TaskID,
}
resolved, validateErr := validateTaskRootRecord(params, entryPath, record)
if validateErr != nil {
errs = append(errs, fmt.Errorf("validate stale task root record %s: %w", entryPath, validateErr))
continue
}
if _, statErr := os.Stat(resolved); statErr == nil {
continue
} else if !errors.Is(statErr, os.ErrNotExist) {
errs = append(errs, fmt.Errorf("inspect env root for record %s: %w", entryPath, statErr))
continue
}
if eligible == nil || !eligible(record.WorkspaceID, record.TaskID) {
continue
}
if removeErr := os.RemoveAll(entryPath); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) {
errs = append(errs, fmt.Errorf("remove stale task root record %s: %w", entryPath, removeErr))
continue
}
removed++
}
return removed, errors.Join(errs...)
}
// findOwnedTaskRoot adopts roots created before the stable index existed. The
// owner marker is authoritative; readable suffixes only narrow the scan.
func findOwnedTaskRoot(params RootDirParams) (string, error) {
workspaceEntries, err := os.ReadDir(params.WorkspacesRoot)
if errors.Is(err, os.ErrNotExist) {
return "", nil
}
if err != nil {
return "", fmt.Errorf("execenv: scan existing task roots: %w", err)
}
workspaceSuffix := strings.ToLower(taskKey(params.WorkspaceID))
var found string
for _, workspaceEntry := range workspaceEntries {
if !workspaceEntry.IsDir() || strings.HasPrefix(workspaceEntry.Name(), ".") {
continue
}
name := workspaceEntry.Name()
if name != params.WorkspaceID && !strings.HasSuffix(strings.ToLower(name), "-"+workspaceSuffix) {
continue
}
workspaceDir := filepath.Join(params.WorkspacesRoot, name)
taskEntries, readErr := os.ReadDir(workspaceDir)
if readErr != nil {
return "", fmt.Errorf("execenv: read candidate workspace root %s: %w", workspaceDir, readErr)
}
for _, taskEntry := range taskEntries {
if !taskEntry.IsDir() {
continue
}
taskName := strings.ToLower(taskEntry.Name())
taskSuffix := strings.ToLower(taskKey(params.TaskID))
if taskName != taskSuffix && !strings.HasSuffix(taskName, "-"+taskSuffix) {
continue
}
candidate := filepath.Join(workspaceDir, taskEntry.Name())
owner, readErr := ReadEnvRootOwner(candidate)
if readErr != nil {
return "", fmt.Errorf("execenv: read candidate env root owner for %s: %w", candidate, readErr)
}
if owner.TaskID == "" {
hasWork, inspectErr := envRootHoldsWork(candidate)
if inspectErr != nil {
return "", fmt.Errorf("execenv: inspect candidate env root %s: %w", candidate, inspectErr)
}
if hasWork {
return "", fmt.Errorf("execenv: candidate env root %s holds files but has no owner", candidate)
}
} else if owner.TaskID != params.TaskID {
continue
}
if owner.WorkspaceID != "" && owner.WorkspaceID != params.WorkspaceID {
continue
}
if found != "" && found != candidate {
return "", fmt.Errorf("execenv: task %s owns multiple env roots", params.TaskID)
}
found = candidate
}
}
return found, nil
}
// taskRootRecordAge dates a record by its file, which is written once inside a
// staging directory and never rewritten, so its mtime is the install time. A
// staging directory that died before the write has no file; fall back to the
// directory itself so those are still reclaimable.
func taskRootRecordAge(recordDir string, now time.Time) (time.Duration, bool) {
if info, err := os.Stat(filepath.Join(recordDir, taskRootRecordFile)); err == nil {
return now.Sub(info.ModTime()), true
}
info, err := os.Stat(recordDir)
if err != nil {
return 0, false
}
return now.Sub(info.ModTime()), true
}
@@ -0,0 +1,276 @@
package execenv
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
const (
pruneWorkspaceID = "a05b0e10-ee7a-4603-a72d-a548b2390cb2"
pruneTTL = 72 * time.Hour
)
func pruneParams(root, taskID string) RootDirParams {
return RootDirParams{
WorkspacesRoot: root,
WorkspaceID: pruneWorkspaceID,
WorkspaceSlug: "Asset Feed",
TaskID: taskID,
IssueIdentifier: "MUL-6063",
}
}
func pruneTaskRootIndexForTest(t *testing.T, root string, now time.Time, eligible func(string, string) bool) int {
t.Helper()
removed, err := PruneTaskRootIndex(root, pruneTTL, now, eligible)
if err != nil {
t.Fatalf("PruneTaskRootIndex: %v", err)
}
return removed
}
// TestPruneTaskRootIndexKeepsTheFreezeWindow is the guard that matters most
// here. ResolveRootDir installs a record BEFORE Prepare creates the directory,
// so "record with no env root" is the correct state for the window in between.
// A sweep that treated absence alone as garbage would delete a live task's
// frozen identity and hand the same task a second physical root — the exact
// orphaning the index exists to prevent.
func TestPruneTaskRootIndexKeepsTheFreezeWindow(t *testing.T) {
t.Parallel()
root := t.TempDir()
params := pruneParams(root, "5c57b65b-ee7a-4603-a72d-b659c34a1dc3")
frozen, err := ResolveRootDir(params)
if err != nil {
t.Fatalf("ResolveRootDir: %v", err)
}
if _, err := os.Stat(frozen); !os.IsNotExist(err) {
t.Fatalf("fixture must represent the pre-Prepare window; stat err = %v", err)
}
if removed := pruneTaskRootIndexForTest(t, root, time.Now(), func(string, string) bool { return true }); removed != 0 {
t.Fatalf("removed %d records inside the freeze window, want 0", removed)
}
again, err := ResolveRootDir(params)
if err != nil {
t.Fatalf("ResolveRootDir after prune: %v", err)
}
if again != frozen {
t.Fatalf("task moved from %q to %q after a prune cycle", frozen, again)
}
}
func TestPruneTaskRootIndexReclaimsOnlyStrandedRecords(t *testing.T) {
t.Parallel()
root := t.TempDir()
liveParams := pruneParams(root, "5c57b65b-ee7a-4603-a72d-b659c34a1dc3")
strandedParams := pruneParams(root, "5c57b65b-ee7a-4603-a72d-c760d45b2ed4")
livePath, err := ResolveRootDir(liveParams)
if err != nil {
t.Fatalf("resolve live root: %v", err)
}
if err := os.MkdirAll(livePath, 0o755); err != nil {
t.Fatalf("create live root: %v", err)
}
// The stranded task got a record but never reached ClaimEnvRoot, so its
// root never materialised and cleanTaskDir will never see it.
if _, err := ResolveRootDir(strandedParams); err != nil {
t.Fatalf("resolve stranded root: %v", err)
}
past := time.Now().Add(pruneTTL + time.Hour)
if removed := pruneTaskRootIndexForTest(t, root, past, func(string, string) bool { return true }); removed != 1 {
t.Fatalf("removed %d records, want exactly the stranded one", removed)
}
if _, err := os.Stat(taskRootRecordDir(strandedParams)); !os.IsNotExist(err) {
t.Fatalf("stranded record survived the sweep; stat err = %v", err)
}
resolved, err := ResolveRootDir(liveParams)
if err != nil {
t.Fatalf("resolve live root after sweep: %v", err)
}
if resolved != livePath {
t.Fatalf("live task moved from %q to %q after the sweep", livePath, resolved)
}
}
// A record that cannot be parsed is what keeps its task failing closed. The
// sweep must not quietly convert that into "propose a fresh root", because the
// original root may still hold work.
func TestPruneTaskRootIndexKeepsUnreadableRecords(t *testing.T) {
t.Parallel()
root := t.TempDir()
params := pruneParams(root, "5c57b65b-ee7a-4603-a72d-b659c34a1dc3")
recordDir := taskRootRecordDir(params)
if err := os.MkdirAll(recordDir, 0o755); err != nil {
t.Fatalf("seed record dir: %v", err)
}
if err := os.WriteFile(filepath.Join(recordDir, taskRootRecordFile), []byte("{not json"), 0o644); err != nil {
t.Fatalf("seed corrupt record: %v", err)
}
past := time.Now().Add(pruneTTL + time.Hour)
removed, err := PruneTaskRootIndex(root, pruneTTL, past, func(string, string) bool { return true })
if err == nil {
t.Fatal("PruneTaskRootIndex did not report the unreadable record")
}
if removed != 0 {
t.Fatalf("removed %d unreadable records, want 0", removed)
}
if _, err := os.Stat(filepath.Join(recordDir, taskRootRecordFile)); err != nil {
t.Fatalf("unreadable record was reclaimed: %v", err)
}
}
// installTaskRootRecord stages into .pending-* and renames. A staging directory
// that outlives the gate is an install that died before the rename, so it was
// never authoritative for any task.
func TestPruneTaskRootIndexReclaimsAbandonedStagingDirs(t *testing.T) {
t.Parallel()
root := t.TempDir()
indexDir := filepath.Join(root, taskRootIndexDir)
if err := os.MkdirAll(indexDir, 0o755); err != nil {
t.Fatalf("seed index: %v", err)
}
pending, err := os.MkdirTemp(indexDir, taskRootPendingPrefix)
if err != nil {
t.Fatalf("seed staging dir: %v", err)
}
if err := os.WriteFile(filepath.Join(pending, taskRootRecordFile), []byte(`{"workspace_id":"w"}`), 0o644); err != nil {
t.Fatalf("seed staging record: %v", err)
}
if removed := pruneTaskRootIndexForTest(t, root, time.Now(), nil); removed != 0 {
t.Fatalf("removed %d staging dirs inside the gate, want 0", removed)
}
past := time.Now().Add(pruneTTL + time.Hour)
if removed := pruneTaskRootIndexForTest(t, root, past, nil); removed != 1 {
t.Fatalf("removed %d staging dirs past the gate, want 1", removed)
}
if _, err := os.Stat(pending); !os.IsNotExist(err) {
t.Fatalf("abandoned staging dir survived; stat err = %v", err)
}
}
func TestPruneTaskRootIndexReclaimsEmptyFinalRecordDir(t *testing.T) {
t.Parallel()
root := t.TempDir()
params := pruneParams(root, "5c57b65b-ee7a-4603-a72d-b659c34a1dc3")
recordDir := taskRootRecordDir(params)
if err := os.MkdirAll(recordDir, 0o755); err != nil {
t.Fatalf("seed empty final record dir: %v", err)
}
past := time.Now().Add(pruneTTL + time.Hour)
if removed := pruneTaskRootIndexForTest(t, root, past, nil); removed != 1 {
t.Fatalf("removed %d empty final record dirs, want 1", removed)
}
if _, err := os.Stat(recordDir); !os.IsNotExist(err) {
t.Fatalf("empty final record dir survived; stat err = %v", err)
}
}
// writeEnvRootOwner stages into a temp file and renames. A crash in between
// leaves a root holding nothing but that temp — no task content, and no owner
// marker either. findOwnedTaskRoot inspects candidates without the env root
// lock, so it must read that root as adoptable rather than refusing it as
// "holds files but has no owner", which would wedge the task permanently.
func TestResolveRootDirAdoptsRootHoldingOnlyAnUnpublishedOwnerTemp(t *testing.T) {
t.Parallel()
root := t.TempDir()
const taskID = "5c57b65b-ee7a-4603-a72d-b659c34a1dc3"
params := pruneParams(root, taskID)
original := PredictRootDir(params)
if err := os.MkdirAll(original, 0o755); err != nil {
t.Fatalf("seed env root: %v", err)
}
tmp, err := os.CreateTemp(original, envRootOwnerTempPrefix+"*"+envRootOwnerTempSuffix)
if err != nil {
t.Fatalf("seed unpublished owner temp: %v", err)
}
if _, err := tmp.WriteString(`{"workspace_id":"` + pruneWorkspaceID + `","task_id":"` + taskID + `"}`); err != nil {
t.Fatalf("write unpublished owner temp: %v", err)
}
tmp.Close()
if _, err := os.Stat(filepath.Join(original, envRootOwnerFile)); !os.IsNotExist(err) {
t.Fatalf("fixture must have no published owner marker; stat err = %v", err)
}
resolved, err := ResolveRootDir(params)
if err != nil {
t.Fatalf("ResolveRootDir refused a root holding only an unpublished owner temp: %v", err)
}
if resolved != original {
t.Fatalf("resolved %q, want the existing root %q", resolved, original)
}
claim, err := ClaimEnvRoot(params)
if err != nil {
t.Fatalf("ClaimEnvRoot: %v", err)
}
defer claim.Release()
owner, err := ReadEnvRootOwner(original)
if err != nil {
t.Fatalf("ReadEnvRootOwner: %v", err)
}
if owner.TaskID != taskID || owner.WorkspaceID != pruneWorkspaceID {
t.Fatalf("owner = %+v, want the claiming task's identity", owner)
}
entries, err := os.ReadDir(original)
if err != nil {
t.Fatalf("read env root: %v", err)
}
for _, e := range entries {
if strings.HasPrefix(e.Name(), envRootOwnerTempPrefix) {
t.Fatalf("claim left an unpublished owner temp behind: %s", e.Name())
}
}
}
// Every fail-closed error must name the record directory: the refusal is
// permanent until an operator removes it, and they need the path to do that.
func TestValidateTaskRootRecordErrorsNameTheRecordDir(t *testing.T) {
t.Parallel()
root := t.TempDir()
params := pruneParams(root, "5c57b65b-ee7a-4603-a72d-b659c34a1dc3")
recordDir := taskRootRecordDir(params)
cases := []struct {
name string
record taskRootRecord
}{
{
name: "identity mismatch",
record: taskRootRecord{WorkspaceID: "other", TaskID: params.TaskID, RelativePath: filepath.Join("a", "b")},
},
{
name: "invalid relative path",
record: taskRootRecord{WorkspaceID: params.WorkspaceID, TaskID: params.TaskID, RelativePath: "."},
},
{
name: "outside stable identity",
record: taskRootRecord{WorkspaceID: params.WorkspaceID, TaskID: params.TaskID, RelativePath: filepath.Join("unrelated-workspace", "unrelated-task")},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := validateTaskRootRecord(params, recordDir, tc.record)
if err == nil {
t.Fatal("validateTaskRootRecord accepted an invalid record")
}
if !strings.Contains(err.Error(), recordDir) {
t.Fatalf("error %q does not name the record dir %q", err, recordDir)
}
})
}
}
+52 -11
View File
@@ -7,6 +7,7 @@ import (
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
@@ -69,12 +70,13 @@ type gcStats struct {
// hermesMemoryStoresReclaimed is counted separately from storesReclaimed:
// the two stores hold different things on different TTLs, so folding them
// into one number would make either figure unreadable for an operator.
hermesMemoryStoresReclaimed int // per-agent Hermes memory stores reclaimed past their TTL
hermesSessionStoresReclaimed int // per-conversation Hermes session stores reclaimed past their TTL
repoCachesReclaimed int // bare repo caches under .repos evicted past their TTL
taskTempDirsReclaimed int // per-task temp dirs under the temp base reclaimed after their owning execution ended
bytesReclaimed int64 // total bytes freed in this cycle
byPattern map[string]int // configured basename or managed path label -> reclaim count
hermesMemoryStoresReclaimed int // per-agent Hermes memory stores reclaimed past their TTL
hermesSessionStoresReclaimed int // per-conversation Hermes session stores reclaimed past their TTL
repoCachesReclaimed int // bare repo caches under .repos evicted past their TTL
taskTempDirsReclaimed int // per-task temp dirs under the temp base reclaimed after their owning execution ended
taskRootIndexEntriesReclaimed int // abandoned stable-root records and unpublished entries reclaimed past the orphan TTL
bytesReclaimed int64 // total bytes freed in this cycle
byPattern map[string]int // configured basename or managed path label -> reclaim count
}
// runGC performs a single GC scan across all workspace directories.
@@ -105,6 +107,26 @@ func (d *Daemon) runGC(ctx context.Context) {
d.gcWorkspace(ctx, wsDir, stats)
}
// Stable-root records are published before the physical env root so a
// re-dispatch cannot choose a different readable path. If preparation never
// reaches ClaimEnvRoot, no task directory exists for the normal GC walk to
// reclaim. Bound those records separately, but only after the orphan grace
// period and an authoritative terminal/not-found task check.
rootRecordsRemoved, rootRecordsErr := execenv.PruneTaskRootIndex(root, d.cfg.GCOrphanTTL, time.Now(), func(_ string, taskID string) bool {
if ctx.Err() != nil || d.client == nil {
return false
}
status, statusErr := d.client.GetTaskGCCheck(ctx, taskID)
if statusErr != nil {
return isAccessNotFound(statusErr)
}
return isAgentTaskTerminal(status.Status)
})
if rootRecordsErr != nil {
d.logger.Warn("gc: prune task root index failed", "error", rootRecordsErr)
}
stats.taskRootIndexEntriesReclaimed += rootRecordsRemoved
// Prune stale worktree references from all bare repo caches, then evict the
// caches nothing needs anymore. These live outside any workspace directory
// and are never reclaimed by the task walk above.
@@ -150,7 +172,7 @@ func (d *Daemon) runGC(ctx context.Context) {
}
}
if stats.cleaned > 0 || stats.orphaned > 0 || stats.artifactDirs > 0 || stats.storesReclaimed > 0 || stats.hermesMemoryStoresReclaimed > 0 || stats.hermesSessionStoresReclaimed > 0 || stats.repoCachesReclaimed > 0 || stats.taskTempDirsReclaimed > 0 {
if stats.cleaned > 0 || stats.orphaned > 0 || stats.artifactDirs > 0 || stats.storesReclaimed > 0 || stats.hermesMemoryStoresReclaimed > 0 || stats.hermesSessionStoresReclaimed > 0 || stats.repoCachesReclaimed > 0 || stats.taskTempDirsReclaimed > 0 || stats.taskRootIndexEntriesReclaimed > 0 {
d.logger.Info("gc: cycle complete",
"cleaned", stats.cleaned,
"orphaned", stats.orphaned,
@@ -162,6 +184,7 @@ func (d *Daemon) runGC(ctx context.Context) {
"hermes_session_stores_reclaimed", stats.hermesSessionStoresReclaimed,
"repo_caches_reclaimed", stats.repoCachesReclaimed,
"task_temp_dirs_reclaimed", stats.taskTempDirsReclaimed,
"task_root_index_entries_reclaimed", stats.taskRootIndexEntriesReclaimed,
"bytes_reclaimed", stats.bytesReclaimed,
"by_pattern", stats.byPattern,
)
@@ -177,7 +200,7 @@ func (d *Daemon) gcWorkspace(ctx context.Context, wsDir string, stats *gcStats)
}
cleanedHere := 0
issueCandidates := make([]issueGCCandidate, 0, len(taskEntries))
issueCandidatesByWorkspace := make(map[string][]issueGCCandidate)
for _, entry := range taskEntries {
if ctx.Err() != nil {
return
@@ -192,13 +215,25 @@ func (d *Daemon) gcWorkspace(ctx context.Context, wsDir string, stats *gcStats)
}
meta, metaErr := execenv.ReadGCMeta(taskDir)
if metaErr == nil && meta.Kind == execenv.GCKindIssue && strings.TrimSpace(meta.IssueID) != "" {
issueCandidates = append(issueCandidates, issueGCCandidate{taskDir: taskDir, meta: meta})
continue
if workspaceID := strings.TrimSpace(meta.WorkspaceID); workspaceID != "" {
issueCandidatesByWorkspace[workspaceID] = append(
issueCandidatesByWorkspace[workspaceID],
issueGCCandidate{taskDir: taskDir, meta: meta},
)
continue
}
}
action := d.shouldCleanTaskDir(ctx, taskDir)
cleanedHere += d.applyGCAction(taskDir, action, stats)
}
cleanedHere += d.gcWorkspaceIssues(ctx, filepath.Base(wsDir), issueCandidates, stats)
workspaceIDs := make([]string, 0, len(issueCandidatesByWorkspace))
for workspaceID := range issueCandidatesByWorkspace {
workspaceIDs = append(workspaceIDs, workspaceID)
}
sort.Strings(workspaceIDs)
for _, workspaceID := range workspaceIDs {
cleanedHere += d.gcWorkspaceIssues(ctx, workspaceID, issueCandidatesByWorkspace[workspaceID], stats)
}
// Remove the workspace directory itself if it's now empty.
if cleanedHere > 0 {
@@ -783,12 +818,18 @@ func isAgentTaskTerminal(status string) bool {
// that count for the cycle summary. A failed removal reports zero reclaimed.
func (d *Daemon) cleanTaskDir(taskDir string) int64 {
bytes := dirSize(taskDir)
owner, ownerErr := execenv.ReadEnvRootOwner(taskDir)
if err := os.RemoveAll(taskDir); err != nil {
d.logger.Warn("gc: remove task dir failed", "dir", taskDir, "error", err)
return 0
} else {
d.logger.Info("gc: removed", "dir", taskDir, "bytes_reclaimed", bytes)
}
if ownerErr == nil && owner != nil {
if err := execenv.RemoveRootDirRecord(d.cfg.WorkspacesRoot, taskDir, *owner); err != nil {
d.logger.Warn("gc: remove stable task root record failed", "dir", taskDir, "error", err)
}
}
return bytes
}
+280
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"io/fs"
"log/slog"
"net/http"
"net/http/httptest"
@@ -57,6 +58,245 @@ func createTaskDir(t *testing.T, root, wsID, dirName string, meta *execenv.GCMet
return taskDir
}
// TestRunGC_StrandedTaskRootRecordsFollowTaskStatus guards the wiring AND the
// invariant that makes the sweep safe.
//
// A record whose env root never materialised has no other remover: cleanTaskDir
// only fires on a root it reclaims, and nothing else walks .task_roots. But
// absence of the root cannot authorise removal on its own — ResolveRootDir
// publishes the record BEFORE Prepare creates the directory, so a record that
// an aged-out re-dispatch is in the middle of reusing looks identical to a
// leftover. Deleting there hands the same task a second physical root, which is
// the orphaning the index exists to prevent.
//
// The age gate alone does not separate those two: an old record IS the state a
// stalled re-dispatch reads. Only the task's own status does, so the sweep asks
// the server and keeps anything non-terminal.
func TestRunGC_StrandedTaskRootRecordsFollowTaskStatus(t *testing.T) {
root := t.TempDir()
const workspaceID = "a05b0e10-ee7a-4603-a72d-a548b2390cb2"
params := func(taskID string) execenv.RootDirParams {
return execenv.RootDirParams{
WorkspacesRoot: root, WorkspaceID: workspaceID,
WorkspaceSlug: "Asset Feed", TaskID: taskID, IssueIdentifier: "MUL-6063",
}
}
const (
runningTask = "5c57b65b-ee7a-4603-a72d-c760d45b2ed4"
terminalTask = "5c57b65b-ee7a-4603-a72d-d871e56c3fe5"
liveRootTask = "5c57b65b-ee7a-4603-a72d-b659c34a1dc3"
)
statuses := map[string]string{
runningTask: "running",
terminalTask: "completed",
// liveRootTask is terminal too: only its surviving root protects it.
liveRootTask: "completed",
}
mux := http.NewServeMux()
mux.HandleFunc("/api/daemon/tasks/", func(w http.ResponseWriter, r *http.Request) {
taskID := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/api/daemon/tasks/"), "/gc-check")
status, ok := statuses[taskID]
if !ok {
w.WriteHeader(http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(map[string]any{"status": status})
})
d := newGCTestDaemon(t, mux)
d.cfg.WorkspacesRoot = root
frozen := map[string]string{}
for _, taskID := range []string{runningTask, terminalTask, liveRootTask} {
resolved, err := execenv.ResolveRootDir(params(taskID))
if err != nil {
t.Fatalf("resolve root for %s: %v", taskID, err)
}
frozen[taskID] = resolved
}
// Only this one ever reached ClaimEnvRoot.
if err := os.MkdirAll(frozen[liveRootTask], 0o755); err != nil {
t.Fatalf("create live root: %v", err)
}
past := time.Now().Add(-31 * 24 * time.Hour)
if err := filepath.WalkDir(filepath.Join(root, ".task_roots"), func(path string, _ fs.DirEntry, err error) error {
if err != nil {
return err
}
return os.Chtimes(path, past, past)
}); err != nil {
t.Fatalf("backdate task root index: %v", err)
}
d.runGC(context.Background())
// Renaming is how we observe survival: a frozen record ignores the new
// labels, a reclaimed one re-proposes from them.
for _, tc := range []struct {
taskID string
want bool
why string
}{
{runningTask, true, "a non-terminal task's frozen identity was swept; a re-dispatch would now get a second root"},
{liveRootTask, true, "a record was swept while its env root still exists"},
{terminalTask, false, "a terminal task's stranded record survived the sweep"},
} {
renamed := params(tc.taskID)
renamed.WorkspaceSlug = "Renamed Workspace"
renamed.IssueIdentifier = "NEW-6063"
resolved, err := execenv.ResolveRootDir(renamed)
if err != nil {
t.Fatalf("resolve %s after GC: %v", tc.taskID, err)
}
if kept := resolved == frozen[tc.taskID]; kept != tc.want {
t.Errorf("task %s: record kept = %v, want %v — %s", tc.taskID, kept, tc.want, tc.why)
}
}
}
func TestRunGC_MixedLayoutsUseMetadataWorkspaceIdentity(t *testing.T) {
workspaceID := "a05b0e10-ee7a-4603-a72d-a548b2390cb2"
legacyIssueID := "11111111-1111-1111-1111-111111111111"
readableIssueID := "22222222-2222-2222-2222-222222222222"
var batchRequests int
mux := http.NewServeMux()
mux.HandleFunc("/api/daemon/workspaces/"+workspaceID+"/issues/gc-check", func(w http.ResponseWriter, r *http.Request) {
batchRequests++
var body struct {
IssueIDs []string `json:"issue_ids"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode batch request: %v", err)
}
issues := make([]map[string]any, 0, len(body.IssueIDs))
for _, issueID := range body.IssueIDs {
issues = append(issues, map[string]any{
"id": issueID, "found": true, "status": "done",
"updated_at": time.Now().Add(-10 * 24 * time.Hour),
})
}
json.NewEncoder(w).Encode(map[string]any{"issues": issues})
})
d := newGCTestDaemon(t, mux)
legacyDir := createTaskDir(t, d.cfg.WorkspacesRoot, workspaceID, "11111111", &execenv.GCMeta{
Kind: execenv.GCKindIssue, IssueID: legacyIssueID, WorkspaceID: workspaceID,
CompletedAt: time.Now().Add(-10 * 24 * time.Hour),
})
readableDir := createTaskDir(t, d.cfg.WorkspacesRoot, "asset-feed-a548b2390cb2", "mul-6063-b659c34a1dc3", &execenv.GCMeta{
Kind: execenv.GCKindIssue, IssueID: readableIssueID, WorkspaceID: workspaceID,
TaskID: "22222222-ee7a-4603-a72d-b659c34a1dc3", CompletedAt: time.Now().Add(-10 * 24 * time.Hour),
})
d.runGC(context.Background())
if batchRequests != 2 {
t.Fatalf("batch requests = %d, want one per physical layout", batchRequests)
}
for _, taskDir := range []string{legacyDir, readableDir} {
if _, err := os.Stat(taskDir); !os.IsNotExist(err) {
t.Fatalf("task dir %s was not reclaimed", taskDir)
}
}
}
func TestRunGC_PrunesOnlyTerminalAbandonedTaskRootRecords(t *testing.T) {
t.Parallel()
const (
workspaceID = "a05b0e10-ee7a-4603-a72d-a548b2390cb2"
terminalTask = "5c57b65b-ee7a-4603-a72d-b659c34a1dc3"
runningTask = "6d68c76c-ff8b-5704-b83e-c76ad45b2ed4"
unavailableTask = "7e79d87d-008c-6805-c94f-d87be56c3fe5"
)
mux := http.NewServeMux()
mux.HandleFunc("/api/daemon/tasks/", func(w http.ResponseWriter, r *http.Request) {
status := "running"
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
if len(parts) >= 2 && parts[len(parts)-2] == unavailableTask {
http.Error(w, "temporarily unavailable", http.StatusServiceUnavailable)
return
}
if len(parts) >= 2 && parts[len(parts)-2] == terminalTask {
status = "failed"
}
json.NewEncoder(w).Encode(map[string]any{"status": status})
})
d := newGCTestDaemon(t, mux)
d.cfg.GCOrphanTTL = time.Hour
terminalParams := execenv.RootDirParams{
WorkspacesRoot: d.cfg.WorkspacesRoot,
WorkspaceID: workspaceID,
TaskID: terminalTask,
}
runningParams := terminalParams
runningParams.TaskID = runningTask
unavailableParams := terminalParams
unavailableParams.TaskID = unavailableTask
terminalRoot, err := execenv.ResolveRootDir(terminalParams)
if err != nil {
t.Fatalf("resolve terminal root: %v", err)
}
runningRoot, err := execenv.ResolveRootDir(runningParams)
if err != nil {
t.Fatalf("resolve running root: %v", err)
}
unavailableRoot, err := execenv.ResolveRootDir(unavailableParams)
if err != nil {
t.Fatalf("resolve unavailable root: %v", err)
}
indexDir := filepath.Join(d.cfg.WorkspacesRoot, ".task_roots")
entries, err := os.ReadDir(indexDir)
if err != nil {
t.Fatalf("read task root index: %v", err)
}
old := time.Now().Add(-2 * time.Hour)
for _, entry := range entries {
recordPath := filepath.Join(indexDir, entry.Name(), "root.json")
if err := os.Chtimes(recordPath, old, old); err != nil {
t.Fatalf("age task root record: %v", err)
}
}
d.runGC(t.Context())
terminalRenamed := terminalParams
terminalRenamed.WorkspaceSlug = "Renamed Workspace"
terminalRenamed.IssueIdentifier = "NEW-6063"
terminalAfterGC, err := execenv.ResolveRootDir(terminalRenamed)
if err != nil {
t.Fatalf("resolve terminal root after GC: %v", err)
}
if terminalAfterGC == terminalRoot {
t.Fatalf("terminal abandoned record still froze root %q", terminalAfterGC)
}
runningRenamed := runningParams
runningRenamed.WorkspaceSlug = "Renamed Workspace"
runningRenamed.IssueIdentifier = "NEW-6064"
runningAfterGC, err := execenv.ResolveRootDir(runningRenamed)
if err != nil {
t.Fatalf("resolve running root after GC: %v", err)
}
if runningAfterGC != runningRoot {
t.Fatalf("GC changed non-terminal task root from %q to %q", runningRoot, runningAfterGC)
}
unavailableRenamed := unavailableParams
unavailableRenamed.WorkspaceSlug = "Renamed Workspace"
unavailableRenamed.IssueIdentifier = "NEW-6065"
unavailableAfterGC, err := execenv.ResolveRootDir(unavailableRenamed)
if err != nil {
t.Fatalf("resolve unavailable root after GC: %v", err)
}
if unavailableAfterGC != unavailableRoot {
t.Fatalf("GC changed task root after an inconclusive status check from %q to %q", unavailableRoot, unavailableAfterGC)
}
}
func TestShouldCleanTaskDir_DoneIssueOverTTL(t *testing.T) {
t.Parallel()
issueID := "11111111-1111-1111-1111-111111111111"
@@ -281,6 +521,43 @@ func TestCleanTaskDir_RemovesDirectory(t *testing.T) {
}
}
func TestCleanTaskDir_RemovesStableRootRecord(t *testing.T) {
t.Parallel()
root := t.TempDir()
params := execenv.PrepareParams{
WorkspacesRoot: root,
WorkspaceID: "a05b0e10-ee7a-4603-a72d-a548b2390cb2",
WorkspaceSlug: "Asset Feed",
TaskID: "5c57b65b-ee7a-4603-a72d-b659c34a1dc3",
IssueIdentifier: "MUL-6063",
AgentName: "GC Identity",
}
env, err := execenv.Prepare(params, slog.Default())
if err != nil {
t.Fatalf("Prepare: %v", err)
}
original := env.RootDir
d := &Daemon{cfg: Config{WorkspacesRoot: root}, logger: slog.Default()}
if bytes := d.cleanTaskDir(original); bytes <= 0 {
t.Fatalf("reclaimed bytes = %d, want owner metadata bytes", bytes)
}
resolved, err := execenv.ResolveRootDir(execenv.RootDirParams{
WorkspacesRoot: root,
WorkspaceID: params.WorkspaceID,
WorkspaceSlug: "Renamed Workspace",
TaskID: params.TaskID,
IssueIdentifier: "NEW-6063",
})
if err != nil {
t.Fatalf("ResolveRootDir after terminal GC: %v", err)
}
if resolved == original {
t.Fatalf("terminal GC left stale root record %q", resolved)
}
}
func TestGcWorkspace_CleansEmptyWorkspaceDir(t *testing.T) {
t.Parallel()
issueID := "77777777-7777-7777-7777-777777777777"
@@ -2059,6 +2336,9 @@ func TestGCMetaForTask(t *testing.T) {
if meta.WorkspaceID != "ws" {
t.Fatalf("workspace_id: want %q, got %q", "ws", meta.WorkspaceID)
}
if meta.TaskID != tc.task.ID {
t.Fatalf("task_id: want %q, got %q", tc.task.ID, meta.TaskID)
}
})
}
@@ -32,6 +32,8 @@ func TestRunTaskSquadLeaderReusesWorkdirBeforeGCMetaWritten(t *testing.T) {
defer cleanup()
first := leaderReuseTestTask("task-first")
first.WorkspaceSlug = "original-workspace"
first.IssueIdentifier = "MUL-6063"
firstResult, err := d.runTask(context.Background(), first, "claude", 0, d.logger)
if err != nil {
t.Fatalf("first runTask: %v", err)
@@ -47,6 +49,10 @@ func TestRunTaskSquadLeaderReusesWorkdirBeforeGCMetaWritten(t *testing.T) {
}
second := leaderReuseTestTask("task-second")
// Labels are mutable display data. A renamed workspace or issue must keep
// reusing the recorded path rather than deriving a new root from the names.
second.WorkspaceSlug = "renamed-workspace"
second.IssueIdentifier = "NEW-6063"
second.PriorSessionID = firstResult.SessionID
second.PriorWorkDir = firstResult.WorkDir
secondResult, err := d.runTask(context.Background(), second, "claude", 0, d.logger)
@@ -3,10 +3,14 @@
package repocache
import (
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"strings"
"testing"
"github.com/multica-ai/multica/server/internal/daemon/execenv"
)
// The isolated checkout exists so a Codex task can commit inside its own
@@ -33,12 +37,41 @@ func TestIsolatedCheckoutIsCommittableOnWindows(t *testing.T) {
baseRef := getRemoteDefaultBranch(barePath)
baseCommit := gitRefCommit(t, barePath, baseRef)
// Exercise the production checkout under the longest readable env-root
// segments. This needs a real Windows runner: Git creates the task branch as
// a nested file below .git/refs/heads, where MAX_PATH applies to the complete
// path and a too-generous readable prefix fails with "Filename too long".
// Keep the base representative of the default user-level workspace root.
// t.TempDir embeds the full test name; nesting the production layout under
// it would spend that synthetic length twice and test the fixture, not the
// readable-path budget. Keep the short base, but derive the task suffix from
// this test's unique source path so concurrent go test processes cannot wipe
// each other's checkout while resetting the fixture.
workspacesRoot := filepath.Join(os.TempDir(), "mw")
taskSuffixHash := sha256.Sum256([]byte(sourceRepo))
taskID := "a1b2c3d4-e5f6-7890-abcd-" + hex.EncodeToString(taskSuffixHash[:6])
envRoot := execenv.PredictRootDir(execenv.RootDirParams{
WorkspacesRoot: workspacesRoot,
WorkspaceID: "a05b0e10-ee7a-4603-a72d-a548b2390cb2",
WorkspaceSlug: strings.Repeat("workspace", 20),
TaskID: taskID,
IssueIdentifier: strings.Repeat("issue", 20),
})
if err := os.RemoveAll(envRoot); err != nil {
t.Fatalf("clear worst-case readable env root: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(envRoot) })
workDir := filepath.Join(envRoot, "workdir")
if err := os.MkdirAll(workDir, 0o755); err != nil {
t.Fatalf("create worst-case readable workdir: %v", err)
}
result, err := cache.CreateWorktree(WorktreeParams{
WorkspaceID: "ws-1",
RepoURL: sourceRepo,
WorkDir: t.TempDir(),
WorkDir: workDir,
AgentName: "Windows Codex",
TaskID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
TaskID: taskID,
IsolatedGitMetadata: true,
})
if err != nil {
@@ -51,6 +84,10 @@ func TestIsolatedCheckoutIsCommittableOnWindows(t *testing.T) {
if !isUnder(gitDir, result.Path) {
t.Fatalf("git dir %s is outside the task checkout %s", gitDir, result.Path)
}
taskRef := filepath.Join(gitDir, "refs", "heads", filepath.FromSlash(result.BranchName))
if _, err := os.Stat(taskRef); err != nil {
t.Fatalf("task branch ref was not created at %s: %v", taskRef, err)
}
// 2. Branch, stage, commit — the sequence that fails on a linked worktree.
runGitAuthored(t, result.Path, "checkout", "-b", "feature/windows-commit")
+2
View File
@@ -86,6 +86,8 @@ type Task struct {
RuntimeID string `json:"runtime_id"`
IssueID string `json:"issue_id"`
WorkspaceID string `json:"workspace_id"`
WorkspaceSlug string `json:"workspace_slug,omitempty"`
IssueIdentifier string `json:"issue_identifier,omitempty"`
RemoteMCPConnections []remotemcp.Connection `json:"remote_mcp_connections,omitempty"`
// RemoteMCPDaemonToken stays inside the daemon and authenticates the local
// broker's credential-resolution calls. It must never enter agent env/config.
+4 -4
View File
@@ -94,7 +94,7 @@ func TestRunTask_StartTaskCalledAfterWorkdirOnDisk(t *testing.T) {
workspacesRoot := t.TempDir()
workspaceID := "ws-runtask"
taskID := "task-runtask-after-mkdir"
expectedEnvRoot := execenv.PredictRootDir(workspacesRoot, workspaceID, taskID)
expectedEnvRoot := execenv.PredictRootDir(execenv.RootDirParams{WorkspacesRoot: workspacesRoot, WorkspaceID: workspaceID, TaskID: taskID})
expectedWorkDir := filepath.Join(expectedEnvRoot, "workdir")
var (
@@ -168,7 +168,7 @@ func TestRunTask_InjectsPrivateTaskTempDir(t *testing.T) {
workspacesRoot := filepath.Join(t.TempDir(), strings.Repeat("long-workspaces-root-", 3))
workspaceID := "ws-private-temp"
taskID := "task-private-temp-with-long-id-that-would-overflow-socket-paths"
envRoot := execenv.PredictRootDir(workspacesRoot, workspaceID, taskID)
envRoot := execenv.PredictRootDir(execenv.RootDirParams{WorkspacesRoot: workspacesRoot, WorkspaceID: workspaceID, TaskID: taskID})
captureFile := filepath.Join(t.TempDir(), "agent-env.txt")
fakeBin := filepath.Join(t.TempDir(), "claude")
@@ -788,7 +788,7 @@ func TestHandleTask_KeepsEnvRootActiveAcrossCompletion(t *testing.T) {
workspacesRoot := t.TempDir()
workspaceID := "ws-active-during-complete"
taskID := "task-active-during-complete"
expectedEnvRoot := execenv.PredictRootDir(workspacesRoot, workspaceID, taskID)
expectedEnvRoot := execenv.PredictRootDir(execenv.RootDirParams{WorkspacesRoot: workspacesRoot, WorkspaceID: workspaceID, TaskID: taskID})
var (
completeCalled atomic.Bool
@@ -824,7 +824,7 @@ func TestHandleTask_KeepsEnvRootActiveAcrossCompletion(t *testing.T) {
// the outer guard added in handleTask, the deferred unmark would bring
// isActiveEnvRoot back to false before reportTaskResult fires.
d.runner = taskRunnerFunc(func(_ context.Context, tk Task, _ string, _ int, _ *slog.Logger) (TaskResult, error) {
predicted := execenv.PredictRootDir(d.cfg.WorkspacesRoot, tk.WorkspaceID, tk.ID)
predicted := execenv.PredictRootDir(taskRootDirParams(d.cfg.WorkspacesRoot, tk))
d.markActiveEnvRoot(predicted)
defer d.unmarkActiveEnvRoot(predicted)
return TaskResult{
+43 -10
View File
@@ -359,6 +359,8 @@ type AgentTaskResponse struct {
RuntimeID string `json:"runtime_id"`
IssueID string `json:"issue_id"`
WorkspaceID string `json:"workspace_id"`
WorkspaceSlug string `json:"workspace_slug,omitempty"`
IssueIdentifier string `json:"issue_identifier,omitempty"`
RemoteMCPConnections []remotemcp.Connection `json:"remote_mcp_connections,omitempty"`
// PluginHookTools are the workspace's agent-trigger plugin hooks, which the
// daemon renders as MCP tools for this task. Resolved at claim time so
@@ -421,8 +423,8 @@ type AgentTaskResponse struct {
PriorSessionResumeUnavailable bool `json:"prior_session_resume_unavailable,omitempty"`
WorkDir string `json:"work_dir,omitempty"` // local working directory pinned for this task; populated once the daemon reports it
// RelativeWorkDir is a privacy-safe display form of WorkDir intended for
// the UI. For standard tasks it strips the daemon's workspaces root so
// the user sees `<wsUUID>/<taskShort>/workdir`; for local_directory
// the UI. For standard tasks it strips the daemon's workspaces root while
// preserving either the legacy or readable workspace/task segments; for local_directory
// tasks the absolute path lives outside the envRoot layout, so we strip
// recognised home-directory prefixes (`/Users/<name>/`, `/home/<name>/`,
// `<drive>:/Users/<name>/`) and otherwise fall back to the basename so
@@ -821,9 +823,11 @@ func taskToResponse(t db.AgentTaskQueue, workspaceID string) AgentTaskResponse {
// rendered in transcripts that frequently end up in screen shares,
// screenshots, and recordings, so this function is the only guard.
//
// - For standard tasks (work_dir laid out as `<workspacesRoot>/<wsUUID>/
// <taskShort>/workdir` by execenv.Prepare), it strips everything up to and
// including the workspaces root, returning `<wsUUID>/<taskShort>/workdir`.
// - For standard tasks, it validates the adjacent workspace/task segments
// by their stable ID suffixes, then strips everything before them. This
// accepts both legacy `<wsUUID>/<taskShort>` roots and readable
// `<workspaceSlug>-<wsShort>/<issueKey>-<taskShort>` roots without treating
// the labels as identity.
// - For local_directory tasks the absolute path lives outside the envRoot
// layout. We try to recognise common home-directory prefixes
// (`/Users/<name>/`, `/home/<name>/`, `<drive>:/Users/<name>/`) and strip
@@ -836,8 +840,8 @@ func taskToResponse(t db.AgentTaskQueue, workspaceID string) AgentTaskResponse {
// (i.e. work_dir was exactly the user's home — rendering nothing is
// preferable to a chip that says `<name>`). taskDirSegment() must stay in
// lock-step with server/internal/daemon/execenv/git.go:taskKey — both
// consume the same task UUID; if that helper changes, this one must too
// or the envRoot match silently degrades to the local_directory fallback.
// consume the same task UUID. legacyTaskDirSegment keeps privacy-safe display
// working for pre-#7347 roots and roots created by an earlier build of this PR.
func relativeWorkDir(workDir, workspaceID, taskID string) string {
if workDir == "" {
return ""
@@ -847,9 +851,12 @@ func relativeWorkDir(workDir, workspaceID, taskID string) string {
normalized := strings.ReplaceAll(workDir, "\\", "/")
if workspaceID != "" && taskID != "" {
envRootSuffix := workspaceID + "/" + taskDirSegment(taskID)
if idx := strings.Index(normalized, envRootSuffix); idx >= 0 {
return normalized[idx:]
parts := strings.Split(normalized, "/")
for i := 0; i+1 < len(parts); i++ {
if matchesWorkspacePathSegment(parts[i], workspaceID) &&
matchesTaskPathSegment(parts[i+1], taskID) {
return strings.Join(parts[i:], "/")
}
}
}
@@ -860,6 +867,22 @@ func relativeWorkDir(workDir, workspaceID, taskID string) string {
return basename(normalized)
}
func matchesWorkspacePathSegment(segment, workspaceID string) bool {
lower := strings.ToLower(segment)
legacy := legacyTaskDirSegment(workspaceID)
current := strings.ToLower(taskDirSegment(workspaceID))
return strings.EqualFold(segment, workspaceID) ||
strings.HasSuffix(lower, "-"+legacy) || strings.HasSuffix(lower, "-"+current)
}
func matchesTaskPathSegment(segment, taskID string) bool {
lower := strings.ToLower(segment)
legacy := legacyTaskDirSegment(taskID)
current := strings.ToLower(taskDirSegment(taskID))
return strings.EqualFold(segment, legacy) || strings.EqualFold(segment, current) ||
strings.HasSuffix(lower, "-"+legacy) || strings.HasSuffix(lower, "-"+current)
}
// taskDirSegmentLen and taskDirSegment mirror execenv.taskKeyLen /
// execenv.taskKey — the LAST 12 hex chars of the task id. Kept inline here so
// the agent handler has zero imports from the daemon package (which would
@@ -879,6 +902,16 @@ func taskDirSegment(uuid string) string {
return s
}
// legacyTaskDirSegment mirrors the historical shortID layout: the first eight
// dash-free characters. It is display compatibility only, never new identity.
func legacyTaskDirSegment(uuid string) string {
s := strings.ReplaceAll(uuid, "-", "")
if len(s) > 8 {
return strings.ToLower(s[:8])
}
return strings.ToLower(s)
}
// homeDirPattern matches the well-known per-user home layouts on macOS,
// Linux, and Windows after backslash normalization:
//
+38 -14
View File
@@ -24,11 +24,12 @@ import (
func TestRelativeWorkDir(t *testing.T) {
const (
wsID = "a05b0e10-ee7a-4603-a72d-a548b2390cb2"
taskID = "5c57b65b-ee7a-4603-a72d-a548b2390cb2"
taskID = "5c57b65b-ee7a-4603-a72d-b659c34a1dc3"
// The env-root segment is the TAIL of the task id, not a leading
// prefix: UUIDv7 puts a timestamp in front, so a leading slice is
// shared by every task created in the same ~65.5s window (#7326).
taskSeg = "a548b2390cb2"
wsSeg = "a548b2390cb2"
taskSeg = "b659c34a1dc3"
)
tests := []struct {
@@ -59,6 +60,27 @@ func TestRelativeWorkDir(t *testing.T) {
taskID: taskID,
expected: wsID + "/" + taskSeg,
},
{
name: "readable envRoot path strips workspaces root",
workDir: "/Users/alice/multica_workspaces/asset-feed-" + wsSeg + "/mul-6063-" + taskSeg + "/workdir",
wsID: wsID,
taskID: taskID,
expected: "asset-feed-" + wsSeg + "/mul-6063-" + taskSeg + "/workdir",
},
{
name: "legacy readable envRoot keeps privacy-safe display",
workDir: "/Users/alice/multica_workspaces/asset-feed-a05b0e10/mul-6063-5c57b65b/workdir",
wsID: wsID,
taskID: taskID,
expected: "asset-feed-a05b0e10/mul-6063-5c57b65b/workdir",
},
{
name: "legacy opaque envRoot keeps privacy-safe display",
workDir: "/Users/alice/multica_workspaces/" + wsID + "/5c57b65b/workdir",
wsID: wsID,
taskID: taskID,
expected: wsID + "/5c57b65b/workdir",
},
{
name: "local_directory path under /Users home is stripped",
workDir: "/Users/df007df/repos/foo",
@@ -207,22 +229,24 @@ func TestTaskToResponseDerivesPrivateDurableWorkDir(t *testing.T) {
}
}
// TestTaskDirSegmentMatchesDaemon pins taskDirSegment() to
// execenv.PredictRootDir's path layout. Both helpers consume the same task
// UUID; if the daemon's taskKey logic drifts, this test trips loudly instead
// of letting the UI silently fall back to the "tail two segments" branch.
// Without this guard, a daemon-side change back to a truncated prefix would
// not break a build — it would just quietly degrade every standard-task
// work_dir chip into the local_directory fallback.
func TestTaskDirSegmentMatchesDaemon(t *testing.T) {
// TestStableIDSuffixMatchesDaemon pins the handler's path validation to the
// current readable suffixes used by execenv.PredictRootDir. The table above
// separately pins compatibility with both historical layouts.
func TestStableIDSuffixMatchesDaemon(t *testing.T) {
const (
workspacesRoot = "/tmp/workspaces"
workspaceID = "a05b0e10-ee7a-4603-a72d-a548b2390cb2"
taskID = "5c57b65b-ee7a-4603-a72d-a548b2390cb2"
taskID = "5c57b65b-ee7a-4603-a72d-b659c34a1dc3"
)
daemonRoot := execenv.PredictRootDir(workspacesRoot, workspaceID, taskID)
expected := workspacesRoot + "/" + workspaceID + "/" + taskDirSegment(taskID)
daemonRoot := execenv.PredictRootDir(execenv.RootDirParams{
WorkspacesRoot: workspacesRoot,
WorkspaceID: workspaceID,
WorkspaceSlug: "asset-feed",
TaskID: taskID,
IssueIdentifier: "MUL-6063",
})
expected := workspacesRoot + "/asset-feed-" + taskDirSegment(workspaceID) + "/mul-6063-" + taskDirSegment(taskID)
if daemonRoot != expected {
t.Fatalf("daemon PredictRootDir = %q, handler-side reconstruction = %q — taskDirSegment is out of sync with execenv.taskKey", daemonRoot, expected)
t.Fatalf("daemon PredictRootDir = %q, handler-side suffix expectation = %q", daemonRoot, expected)
}
}
+6
View File
@@ -1979,6 +1979,7 @@ func claimResponseAgentIdentityMatches(resp AgentTaskResponse) bool {
func (h *Handler) buildClaimedTaskResponse(r *http.Request, task *db.AgentTaskQueue, runtime db.AgentRuntime, runtimeID, runtimeWorkspaceID string) (resp AgentTaskResponse, deliveredCommentIDs []pgtype.UUID, agentSkillCount, builtinSkillCount int, failure *claimBuildFailure) {
// Build response with fresh agent data (name + skills + custom_env + custom_args).
resp = taskToResponse(*task, runtimeWorkspaceID)
var issueNumber int32
if task.IssueID.Valid {
if policy, enabled := h.issueWindowPolicy(r.Context(), runtime.WorkspaceID); enabled {
visible, visibilityErr := h.issueIDsWithinWindow(r.Context(), runtime.WorkspaceID, policy, []pgtype.UUID{task.IssueID})
@@ -2270,6 +2271,7 @@ func (h *Handler) buildClaimedTaskResponse(r *http.Request, task *db.AgentTaskQu
return resp, deliveredCommentIDs, agentSkillCount, builtinSkillCount, failure
}
resp.ThreadName = issue.Title
issueNumber = issue.Number
// Squad-leader briefing injection: keyed off the task being a
// leader-task (is_leader_task) carrying a squad_id — NOT off the
@@ -3081,6 +3083,10 @@ func (h *Handler) buildClaimedTaskResponse(r *http.Request, task *db.AgentTaskQu
// shared context. Empty string when the owner hasn't set one; the daemon
// skips rendering the heading in that case.
if ws, err := h.Queries.GetWorkspace(r.Context(), parseUUID(resp.WorkspaceID)); err == nil {
resp.WorkspaceSlug = ws.Slug
if issueNumber > 0 {
resp.IssueIdentifier = service.IssueIdentifier(ws.IssuePrefix, issueNumber)
}
if ws.Context.Valid {
resp.WorkspaceContext = ws.Context.String
}
+12
View File
@@ -653,6 +653,10 @@ func TestClaimTaskByRuntime_PopulatesWorkspaceContext(t *testing.T) {
runtimeID := createClaimReclaimRuntime(t, ctx, "Workspace context claim runtime")
agentID, issueID := createClaimReclaimAgentAndIssue(t, ctx, runtimeID, "Workspace context claim agent")
taskID := createDispatchedClaimFixtureTask(t, ctx, agentID, runtimeID, issueID, "120 seconds", false)
var workspaceSlug, issuePrefix string
var issueNumber int32
dbfx.QueryRow(t, `SELECT slug, issue_prefix FROM workspace WHERE id = $1`, testWorkspaceID).Scan(&workspaceSlug, &issuePrefix)
dbfx.QueryRow(t, `SELECT number FROM issue WHERE id = $1`, issueID).Scan(&issueNumber)
req := newDaemonTokenRequest("POST", "/api/daemon/runtimes/"+runtimeID+"/tasks/claim", nil,
testWorkspaceID, "workspace-context-claim")
@@ -663,6 +667,8 @@ func TestClaimTaskByRuntime_PopulatesWorkspaceContext(t *testing.T) {
Task *struct {
ID string `json:"id"`
WorkspaceContext string `json:"workspace_context"`
WorkspaceSlug string `json:"workspace_slug"`
IssueIdentifier string `json:"issue_identifier"`
} `json:"task"`
}
w.JSON(&resp)
@@ -675,6 +681,12 @@ func TestClaimTaskByRuntime_PopulatesWorkspaceContext(t *testing.T) {
if resp.Task.WorkspaceContext != wsContext {
t.Errorf("workspace_context = %q, want %q", resp.Task.WorkspaceContext, wsContext)
}
if resp.Task.WorkspaceSlug != workspaceSlug {
t.Errorf("workspace_slug = %q, want %q", resp.Task.WorkspaceSlug, workspaceSlug)
}
if want := service.IssueIdentifier(issuePrefix, issueNumber); resp.Task.IssueIdentifier != want {
t.Errorf("issue_identifier = %q, want %q", resp.Task.IssueIdentifier, want)
}
}
// TestClaimTaskByRuntime_WorkspaceContextEmptyWhenUnset verifies the field