diff --git a/server/internal/daemon/daemon.go b/server/internal/daemon/daemon.go index bea576437e..dc4470e9df 100644 --- a/server/internal/daemon/daemon.go +++ b/server/internal/daemon/daemon.go @@ -3520,6 +3520,80 @@ func gateResumeToReusedWorkdir(task *Task, taskCtx *execenv.TaskContextForEnv, e return reused } +// shouldReusePriorWorkdir keeps the local_directory lock invariant without +// forcing every squad-leader follow-up onto a fresh provider session. Worker +// tasks already expose their current local-directory assignment, so their +// existing reuse behavior remains unchanged. Leader tasks intentionally skip +// that assignment and its lock; they may therefore reuse only directories +// that resolve to the {workspace}/{task}/workdir shape, carry Prepare-time +// managed-env provenance for the same workspace/issue/agent, and carry a +// matching daemon task-context marker. +// +// Reuse eligibility is deliberately keyed off .managed_env.json (written by +// execenv.Prepare) and NOT .gc_meta.json (written only after the task reaches +// terminal state). The server's task-complete handler reconciles a follow-up +// and wakes the runtime before the prior task's daemon handler writes the GC +// file, so a successor can be claimed inside that window; keying off the +// terminal file raced and dropped the session (MUL-4886). Both proofs this +// function reads — the env-root provenance and the workdir task-context marker +// — are written at Prepare time, so neither depends on completion ordering. +func shouldReusePriorWorkdir(task Task, localAssignment *localDirectoryAssignment, workspacesRoot string) bool { + if task.PriorWorkDir == "" || localAssignment != nil { + return false + } + if !task.IsLeaderTask { + return true + } + + root, err := filepath.EvalSymlinks(workspacesRoot) + if err != nil { + return false + } + workdir, err := filepath.EvalSymlinks(task.PriorWorkDir) + if err != nil { + return false + } + info, err := os.Stat(workdir) + if err != nil || !info.IsDir() { + return false + } + rel, err := filepath.Rel(root, workdir) + if err != nil || !filepath.IsLocal(rel) { + return false + } + parts := strings.Split(rel, string(filepath.Separator)) + if len(parts) != 3 || parts[0] != task.WorkspaceID || parts[1] == "" || parts[2] != "workdir" { + return false + } + if task.AgentID == "" || task.IssueID == "" { + return false + } + // Managed-env provenance is written only for non-local managed issue envs, + // so its presence (plus the workspace/issue/agent match) proves this is a + // safe daemon-managed reuse target and not a residual local_directory path. + prov, err := execenv.ReadManagedEnvProvenance(filepath.Dir(workdir)) + if err != nil || prov.ManagedBy != execenv.ManagedEnvProvenanceManagedBy || + prov.WorkspaceID != task.WorkspaceID || prov.IssueID != task.IssueID || + prov.AgentID != task.AgentID { + return false + } + + data, err := os.ReadFile(filepath.Join(workdir, execenv.TaskContextMarkerRelPath)) + if err != nil { + return false + } + var marker struct { + ManagedBy string `json:"managed_by"` + AgentID string `json:"agent_id"` + IssueID string `json:"issue_id"` + } + if json.Unmarshal(data, &marker) != nil { + return false + } + return marker.ManagedBy == execenv.TaskContextMarkerManagedBy && + marker.AgentID == task.AgentID && marker.IssueID == task.IssueID +} + // gateCodexResumeToRolloutPresence drops the prior Codex session when its // rollout is not actually present in the task's CODEX_HOME sessions. A reused // workdir keeps PriorSessionID (gateResumeToReusedWorkdir), but Codex session @@ -3873,8 +3947,10 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i // WorkDir is the user's own path (always present) but the reuse path // loses the envRoot association the GC loop needs, and re-running // Prepare against a stable user path is cheap (no clone, no copy). - // Squad-leader tasks also skip reuse so a pre-fix leader session recorded - // against the user's local_directory cannot be re-entered without a lock. + // Leader tasks have no localAssignment; shouldReusePriorWorkdir separately + // requires Prepare-time managed-env provenance and a daemon-owned marker + // before allowing reuse, so a pre-fix leader session recorded against + // local_directory still fails closed. var agentMcpConfig json.RawMessage var effectiveMcpConfig json.RawMessage var cursorMcpAuthSource string @@ -3946,7 +4022,7 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i defer d.unmarkActiveCodexStore(store) } } - if task.PriorWorkDir != "" && localAssignment == nil && !task.IsLeaderTask { + if shouldReusePriorWorkdir(task, localAssignment, d.cfg.WorkspacesRoot) { env = execenv.Reuse(execenv.ReuseParams{ WorkspacesRoot: d.cfg.WorkspacesRoot, Profile: d.cfg.Profile, diff --git a/server/internal/daemon/execenv/execenv.go b/server/internal/daemon/execenv/execenv.go index 6f1b3f6666..ff0857c833 100644 --- a/server/internal/daemon/execenv/execenv.go +++ b/server/internal/daemon/execenv/execenv.go @@ -305,6 +305,25 @@ func Prepare(params PrepareParams, logger *slog.Logger) (*Environment, error) { return nil, fmt.Errorf("execenv: write context files: %w", err) } + // Persist managed-env provenance for non-local issue envs at Prepare time + // (not on completion, where .gc_meta.json is written). A same-issue + // follow-up can be claimed the instant the prior task completes — before + // the prior handler writes .gc_meta.json — so reuse eligibility must be + // provable from an artifact that exists the moment the env is created. Only + // managed (non-local_directory) issue envs get this marker; that is exactly + // the set squad-leader reuse targets (MUL-4886). Non-fatal: a write failure + // only costs the next follow-up its session reuse (it falls back to a fresh + // session), which must never block dispatching this task. + if params.LocalWorkDir == "" && params.Task.IssueID != "" { + if err := WriteManagedEnvProvenance(envRoot, ManagedEnvProvenance{ + WorkspaceID: params.WorkspaceID, + IssueID: params.Task.IssueID, + AgentID: params.Task.AgentID, + }); err != nil && logger != nil { + logger.Warn("execenv: write managed env provenance failed (non-fatal); a follow-up may start a fresh session", "error", err) + } + } + // For Codex, set up a per-task CODEX_HOME seeded from ~/.codex/ with skills. if params.Provider == "codex" { codexHome := filepath.Join(envRoot, "codex-home") @@ -723,6 +742,65 @@ func ReadGCMeta(envRoot string) (*GCMeta, error) { return &meta, nil } +const managedEnvProvenanceFile = ".managed_env.json" + +// ManagedEnvProvenanceManagedBy discriminates a managed-env provenance file +// the daemon wrote from any lookalike JSON that happens to share the path. +const ManagedEnvProvenanceManagedBy = "multica-daemon-managed-env" + +// ManagedEnvProvenance is persisted to .managed_env.json inside the env root at +// Prepare time (NOT on completion, unlike .gc_meta.json). It records that this +// env root is a daemon-managed, non-local_directory issue env owned by a +// specific workspace/issue/agent. +// +// Its whole reason to exist is timing. A squad-leader follow-up on the same +// issue can be claimed the instant the prior task completes — the server's +// task-complete handler reconciles the follow-up and wakes the runtime before +// the prior task's daemon handler writes .gc_meta.json. Keying reuse +// eligibility off .gc_meta.json therefore raced: the successor read a +// not-yet-written file and started a fresh session (MUL-4886). This marker is +// on disk from the moment the env is created, so the successor can prove reuse +// safety inside that window. It is written ONLY for non-local managed issue +// envs, so its presence is itself the "safe to reuse, not a user +// local_directory" assertion; see shouldReusePriorWorkdir. +type ManagedEnvProvenance struct { + ManagedBy string `json:"managed_by"` + WorkspaceID string `json:"workspace_id"` + IssueID string `json:"issue_id"` + AgentID string `json:"agent_id"` +} + +// WriteManagedEnvProvenance persists the reuse-eligibility marker at the env +// root. Callers must only invoke it for non-local_directory issue envs, since +// the file's presence is the non-local assertion. ManagedBy is stamped here so +// callers cannot forget the discriminator. +func WriteManagedEnvProvenance(envRoot string, p ManagedEnvProvenance) error { + if envRoot == "" { + return nil + } + p.ManagedBy = ManagedEnvProvenanceManagedBy + data, err := json.Marshal(p) + if err != nil { + return fmt.Errorf("marshal managed env provenance: %w", err) + } + return os.WriteFile(filepath.Join(envRoot, managedEnvProvenanceFile), data, 0o644) +} + +// ReadManagedEnvProvenance reads the Prepare-time reuse-eligibility marker from +// an env root. A missing or malformed file returns an error; callers fail +// closed (no reuse) on any error. +func ReadManagedEnvProvenance(envRoot string) (*ManagedEnvProvenance, error) { + data, err := os.ReadFile(filepath.Join(envRoot, managedEnvProvenanceFile)) + if err != nil { + return nil, err + } + var p ManagedEnvProvenance + if err := json.Unmarshal(data, &p); err != nil { + return nil, err + } + return &p, nil +} + // Cleanup tears down the execution environment. // If removeAll is true, the entire env root is deleted. Otherwise, workdir is // removed but output/ and logs/ are preserved for debugging. diff --git a/server/internal/daemon/execenv/managed_env_provenance_test.go b/server/internal/daemon/execenv/managed_env_provenance_test.go new file mode 100644 index 0000000000..c26be3aea8 --- /dev/null +++ b/server/internal/daemon/execenv/managed_env_provenance_test.go @@ -0,0 +1,98 @@ +package execenv + +import ( + "os" + "path/filepath" + "testing" +) + +// TestPrepareManagedIssueEnvWritesProvenance verifies Prepare drops the +// reuse-eligibility marker for a normal (non-local) issue env, carrying the +// owning workspace/issue/agent. This is the artifact shouldReusePriorWorkdir +// keys off so a follow-up claimed before .gc_meta.json is written can still +// prove the workdir is a safe reuse target (MUL-4886). +func TestPrepareManagedIssueEnvWritesProvenance(t *testing.T) { + root := t.TempDir() + env, err := Prepare(PrepareParams{ + WorkspacesRoot: root, + WorkspaceID: "ws-prov-001", + TaskID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + AgentName: "Prov Agent", + Task: TaskContextForEnv{ + IssueID: "issue-prov-1", + AgentID: "agent-prov-1", + }, + }, testLogger()) + if err != nil { + t.Fatalf("Prepare failed: %v", err) + } + defer env.Cleanup(true) + + prov, err := ReadManagedEnvProvenance(env.RootDir) + if err != nil { + t.Fatalf("read managed env provenance: %v", err) + } + if prov.ManagedBy != ManagedEnvProvenanceManagedBy { + t.Fatalf("managed_by = %q, want %q", prov.ManagedBy, ManagedEnvProvenanceManagedBy) + } + if prov.WorkspaceID != "ws-prov-001" || prov.IssueID != "issue-prov-1" || prov.AgentID != "agent-prov-1" { + t.Fatalf("provenance owner mismatch: %+v", prov) + } +} + +// TestPrepareLocalDirectoryWritesNoProvenance pins the local_directory branch: +// a task bound to a user-supplied path must NOT get a managed-env provenance +// marker, so a squad leader can never treat the user's directory as a reusable +// managed workdir. Absence of the marker is the fail-closed signal. +func TestPrepareLocalDirectoryWritesNoProvenance(t *testing.T) { + root := t.TempDir() + localDir := t.TempDir() + env, err := Prepare(PrepareParams{ + WorkspacesRoot: root, + WorkspaceID: "ws-prov-local", + TaskID: "bbbbbbbb-cccc-dddd-eeee-ffffffffffff", + AgentName: "Local Agent", + LocalWorkDir: localDir, + Task: TaskContextForEnv{ + IssueID: "issue-prov-local", + AgentID: "agent-prov-local", + }, + }, testLogger()) + if err != nil { + t.Fatalf("Prepare failed: %v", err) + } + defer env.Cleanup(true) + + if _, err := ReadManagedEnvProvenance(env.RootDir); !os.IsNotExist(err) { + t.Fatalf("local_directory Prepare must not write managed env provenance; got err = %v", err) + } + // The user's own directory must never receive the marker either. + if _, err := os.Stat(filepath.Join(localDir, managedEnvProvenanceFile)); !os.IsNotExist(err) { + t.Fatal("managed env provenance leaked into the user's local directory") + } +} + +// TestPrepareNonIssueEnvWritesNoProvenance confirms non-issue envs (chat, +// autopilot, quick-create) get no provenance: reuse targets only issue tasks, +// so writing it elsewhere would be dead weight. +func TestPrepareNonIssueEnvWritesNoProvenance(t *testing.T) { + root := t.TempDir() + env, err := Prepare(PrepareParams{ + WorkspacesRoot: root, + WorkspaceID: "ws-prov-chat", + TaskID: "cccccccc-dddd-eeee-ffff-000000000000", + AgentName: "Chat Agent", + Task: TaskContextForEnv{ + ChatSessionID: "chat-1", + AgentID: "agent-chat", + }, + }, testLogger()) + if err != nil { + t.Fatalf("Prepare failed: %v", err) + } + defer env.Cleanup(true) + + if _, err := ReadManagedEnvProvenance(env.RootDir); !os.IsNotExist(err) { + t.Fatalf("non-issue Prepare must not write managed env provenance; got err = %v", err) + } +} diff --git a/server/internal/daemon/leader_workdir_reuse_test.go b/server/internal/daemon/leader_workdir_reuse_test.go new file mode 100644 index 0000000000..84d17af658 --- /dev/null +++ b/server/internal/daemon/leader_workdir_reuse_test.go @@ -0,0 +1,333 @@ +package daemon + +import ( + "context" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/multica-ai/multica/server/internal/daemon/execenv" +) + +// TestRunTaskSquadLeaderReusesWorkdirBeforeGCMetaWritten drives two real +// runTask calls and asserts the follow-up reuses the first workdir and provider +// session while NO .gc_meta.json exists. That absence is the whole point: the +// server marks the prior task completed, reconciles the follow-up, and wakes +// the runtime before the prior task's handler writes .gc_meta.json, so a +// successor can be claimed inside that window (MUL-4886). Reuse must therefore +// hinge on the Prepare-time .managed_env.json provenance, not the terminal GC +// file. runTask writes that provenance via execenv.Prepare; this test never +// writes .gc_meta.json, so it fails against the pre-fix GC-meta-keyed gate. +func TestRunTaskSquadLeaderReusesWorkdirBeforeGCMetaWritten(t *testing.T) { + t.Parallel() + + d, argsFile, cleanup := newLeaderReuseTestDaemon(t) + defer cleanup() + + first := leaderReuseTestTask("task-first") + firstResult, err := d.runTask(context.Background(), first, "claude", 0, d.logger) + if err != nil { + t.Fatalf("first runTask: %v", err) + } + if firstResult.SessionID == "" || firstResult.WorkDir == "" { + t.Fatalf("first result missing resume state: %+v", firstResult) + } + // Simulate the race window: the successor is claimed before the prior + // task's handler writes .gc_meta.json. The Prepare-time provenance is the + // only reuse signal available. + if _, err := os.Stat(filepath.Join(firstResult.EnvRoot, ".gc_meta.json")); !os.IsNotExist(err) { + t.Fatalf("expected no .gc_meta.json before the completion handler runs; stat err = %v", err) + } + + second := leaderReuseTestTask("task-second") + second.PriorSessionID = firstResult.SessionID + second.PriorWorkDir = firstResult.WorkDir + secondResult, err := d.runTask(context.Background(), second, "claude", 0, d.logger) + if err != nil { + t.Fatalf("second runTask: %v", err) + } + if secondResult.WorkDir != firstResult.WorkDir { + t.Fatalf("second WorkDir = %q, want reused leader workdir %q", secondResult.WorkDir, firstResult.WorkDir) + } + args, err := os.ReadFile(argsFile) + if err != nil { + t.Fatalf("read claude args: %v", err) + } + if !strings.Contains(string(args), "--resume\nsession-leader-reuse\n") { + t.Fatalf("second claude invocation did not resume prior session; args:\n%s", args) + } +} + +func TestRunTaskSquadLeaderDoesNotReuseExternalPriorWorkdir(t *testing.T) { + t.Parallel() + + d, _, cleanup := newLeaderReuseTestDaemon(t) + defer cleanup() + + externalWorkDir := t.TempDir() + task := leaderReuseTestTask("task-external") + task.PriorSessionID = "session-leader-reuse" + task.PriorWorkDir = externalWorkDir + + result, err := d.runTask(context.Background(), task, "claude", 0, d.logger) + if err != nil { + t.Fatalf("runTask: %v", err) + } + if result.WorkDir == externalWorkDir { + t.Fatalf("leader reused external workdir %q without a local-directory lock", externalWorkDir) + } +} + +// TestShouldReusePriorWorkdirNonLeaderReusesUnchanged locks the refactor's +// non-leader branch: the leader-only provenance/marker gate must not touch the +// pre-existing behavior where any non-local prior workdir is reused. +func TestShouldReusePriorWorkdirNonLeaderReusesUnchanged(t *testing.T) { + t.Parallel() + + root := t.TempDir() + task := leaderReuseTestTask("task-non-leader") + task.IsLeaderTask = false + task.PriorWorkDir = filepath.Join(root, "anything", "workdir") + if !shouldReusePriorWorkdir(task, nil, root) { + t.Fatal("non-leader task must reuse its prior workdir without any provenance requirement") + } +} + +// TestShouldReusePriorWorkdirSquadLeaderAcceptsManagedProvenance is the unit +// positive: managed shape + matching Prepare-time provenance + matching marker. +func TestShouldReusePriorWorkdirSquadLeaderAcceptsManagedProvenance(t *testing.T) { + t.Parallel() + + root := t.TempDir() + workDir := filepath.Join(root, "ws-leader", "12345678", "workdir") + writeLeaderTaskMarker(t, workDir, "agent-leader", "issue-leader") + writeLeaderManagedEnvProvenance(t, workDir, "ws-leader", "issue-leader", "agent-leader") + + task := leaderReuseTestTask("task-accept") + task.PriorWorkDir = workDir + if !shouldReusePriorWorkdir(task, nil, root) { + t.Fatalf("leader did not reuse a fully-provenanced managed workdir %q", workDir) + } +} + +func TestShouldReusePriorWorkdirSquadLeaderRejectsNonManagedPathUnderRoot(t *testing.T) { + t.Parallel() + + root := t.TempDir() + userDir := filepath.Join(root, "ws-leader", "user-project") + if err := os.MkdirAll(userDir, 0o755); err != nil { + t.Fatalf("mkdir user dir: %v", err) + } + + task := leaderReuseTestTask("task-contained-user-dir") + task.PriorWorkDir = userDir + if shouldReusePriorWorkdir(task, nil, root) { + t.Fatalf("leader reused non-managed path %q merely because it is under WorkspacesRoot", userDir) + } +} + +// TestShouldReusePriorWorkdirSquadLeaderRejectsManagedShapeWithoutProvenance +// covers the race-critical case and the local_directory fail-closed guarantee: +// a workdir with the right shape and a valid marker but NO .managed_env.json is +// rejected. Local_directory envs never get provenance (Prepare skips it), and a +// follow-up claimed before any provenance exists must start fresh rather than +// risk reusing a user path. +func TestShouldReusePriorWorkdirSquadLeaderRejectsManagedShapeWithoutProvenance(t *testing.T) { + t.Parallel() + + root := t.TempDir() + workDir := filepath.Join(root, "ws-leader", "12345678", "workdir") + writeLeaderTaskMarker(t, workDir, "agent-leader", "issue-leader") + + task := leaderReuseTestTask("task-without-provenance") + task.PriorWorkDir = workDir + if shouldReusePriorWorkdir(task, nil, root) { + t.Fatalf("leader reused marked workdir %q without managed-env provenance", workDir) + } +} + +// TestShouldReusePriorWorkdirSquadLeaderRejectsMismatchedProvenanceOwner +// rejects a provenance file whose workspace/issue/agent does not match the +// claiming task, even when the marker is otherwise well-formed. +func TestShouldReusePriorWorkdirSquadLeaderRejectsMismatchedProvenanceOwner(t *testing.T) { + t.Parallel() + + root := t.TempDir() + workDir := filepath.Join(root, "ws-leader", "12345678", "workdir") + writeLeaderTaskMarker(t, workDir, "agent-leader", "issue-leader") + writeLeaderManagedEnvProvenance(t, workDir, "ws-leader", "issue-leader", "other-agent") + + task := leaderReuseTestTask("task-mismatched-provenance") + task.PriorWorkDir = workDir + if shouldReusePriorWorkdir(task, nil, root) { + t.Fatalf("leader reused workdir %q with provenance owned by another agent", workDir) + } +} + +// TestShouldReusePriorWorkdirSquadLeaderRejectsMismatchedTaskMarker keeps its +// original intent — a marker for another agent must be refused — now with a +// matching provenance in place so the check reaches the marker comparison. +func TestShouldReusePriorWorkdirSquadLeaderRejectsMismatchedTaskMarker(t *testing.T) { + t.Parallel() + + root := t.TempDir() + workDir := filepath.Join(root, "ws-leader", "12345678", "workdir") + writeLeaderTaskMarker(t, workDir, "other-agent", "issue-leader") + writeLeaderManagedEnvProvenance(t, workDir, "ws-leader", "issue-leader", "agent-leader") + + task := leaderReuseTestTask("task-mismatched-marker") + task.PriorWorkDir = workDir + if shouldReusePriorWorkdir(task, nil, root) { + t.Fatalf("leader reused workdir %q with a marker for another agent", workDir) + } +} + +func TestShouldReusePriorWorkdirSquadLeaderRejectsRegularFile(t *testing.T) { + t.Parallel() + + root := t.TempDir() + workDir := filepath.Join(root, "ws-leader", "12345678", "workdir") + if err := os.MkdirAll(filepath.Dir(workDir), 0o755); err != nil { + t.Fatalf("mkdir workdir parent: %v", err) + } + if err := os.WriteFile(workDir, []byte("not a directory"), 0o644); err != nil { + t.Fatalf("write workdir file: %v", err) + } + + task := leaderReuseTestTask("task-file-workdir") + task.PriorWorkDir = workDir + if shouldReusePriorWorkdir(task, nil, root) { + t.Fatalf("leader reused regular file %q as a workdir", workDir) + } +} + +func TestShouldReusePriorWorkdirSquadLeaderRejectsEmptyAgentID(t *testing.T) { + t.Parallel() + + root := t.TempDir() + workDir := filepath.Join(root, "ws-leader", "12345678", "workdir") + writeLeaderTaskMarker(t, workDir, "agent-leader", "issue-leader") + writeLeaderManagedEnvProvenance(t, workDir, "ws-leader", "issue-leader", "agent-leader") + + task := leaderReuseTestTask("task-empty-agent") + task.AgentID = "" + task.PriorWorkDir = workDir + if shouldReusePriorWorkdir(task, nil, root) { + t.Fatal("leader with an empty AgentID must not reuse a prior workdir") + } +} + +func TestShouldReusePriorWorkdirSquadLeaderRejectsSymlinkEscape(t *testing.T) { + t.Parallel() + + root := t.TempDir() + external := t.TempDir() + parent := filepath.Join(root, "ws-leader", "12345678") + if err := os.MkdirAll(parent, 0o755); err != nil { + t.Fatalf("mkdir parent: %v", err) + } + // A managed-shape path whose final segment is a symlink escaping the root. + // EvalSymlinks + IsLocal must reject it so a symlink can't smuggle a user + // directory past the containment check. + workDir := filepath.Join(parent, "workdir") + if err := os.Symlink(external, workDir); err != nil { + t.Fatalf("symlink workdir -> external: %v", err) + } + + task := leaderReuseTestTask("task-symlink-escape") + task.PriorWorkDir = workDir + if shouldReusePriorWorkdir(task, nil, root) { + t.Fatalf("leader reused a workdir symlinked outside WorkspacesRoot (%q -> %q)", workDir, external) + } +} + +func newLeaderReuseTestDaemon(t *testing.T) (*Daemon, string, func()) { + t.Helper() + + testDir := t.TempDir() + fakeBin := filepath.Join(testDir, "claude") + argsFile := filepath.Join(testDir, "claude-args.txt") + script := `#!/bin/sh +printf '%s\n' "$@" >> "` + argsFile + `" +printf '%s\n' '--invocation-end--' >> "` + argsFile + `" +IFS= read -r _ +printf '%s\n' '{"type":"system","session_id":"session-leader-reuse"}' +printf '%s\n' '{"type":"result","subtype":"success","is_error":false,"session_id":"session-leader-reuse","result":"done"}' +` + if err := os.WriteFile(fakeBin, []byte(script), 0o755); err != nil { + t.Fatalf("write fake agent: %v", err) + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + d := &Daemon{ + client: NewClient(srv.URL), + logger: logger, + workspaces: make(map[string]*workspaceState), + runtimeIndex: map[string]Runtime{"rt-leader": {ID: "rt-leader", Provider: "claude"}}, + activeEnvRoots: make(map[string]int), + cfg: Config{ + WorkspacesRoot: t.TempDir(), + AgentTimeout: 5 * time.Second, + ServerBaseURL: srv.URL, + Agents: map[string]AgentEntry{ + "claude": {Path: fakeBin}, + }, + }, + } + return d, argsFile, srv.Close +} + +func writeLeaderTaskMarker(t *testing.T, workDir, agentID, issueID string) { + t.Helper() + + markerPath := filepath.Join(workDir, execenv.TaskContextMarkerRelPath) + if err := os.MkdirAll(filepath.Dir(markerPath), 0o755); err != nil { + t.Fatalf("mkdir marker dir: %v", err) + } + marker := []byte(`{"managed_by":"` + execenv.TaskContextMarkerManagedBy + `","agent_id":"` + agentID + `","issue_id":"` + issueID + `"}`) + if err := os.WriteFile(markerPath, marker, 0o644); err != nil { + t.Fatalf("write marker: %v", err) + } +} + +func writeLeaderManagedEnvProvenance(t *testing.T, workDir, workspaceID, issueID, agentID string) { + t.Helper() + + envRoot := filepath.Dir(workDir) + if err := os.MkdirAll(envRoot, 0o755); err != nil { + t.Fatalf("mkdir env root: %v", err) + } + if err := execenv.WriteManagedEnvProvenance(envRoot, execenv.ManagedEnvProvenance{ + WorkspaceID: workspaceID, + IssueID: issueID, + AgentID: agentID, + }); err != nil { + t.Fatalf("write managed env provenance: %v", err) + } +} + +func leaderReuseTestTask(id string) Task { + return Task{ + ID: id, + WorkspaceID: "ws-leader", + RuntimeID: "rt-leader", + IssueID: "issue-leader", + AgentID: "agent-leader", + AuthToken: "mat_leader_reuse", + IsLeaderTask: true, + Agent: &AgentData{ + ID: "agent-leader", + Name: "leader-agent", + }, + } +}