Files
multica/server/internal/daemon/execenv/prepare_rollback_test.go
T
ceb6c6845c MUL-6132 fix: stop stranding daemon task markers in user directories (#6917)
* fix(daemon): roll back local_directory sidecars when prepare fails (MUL-6132)

Prepare writes the daemon task marker and the rest of the sidecar tree into
the workdir early, but only persists the manifest that records them at the
very end. Every error return in between left that tree on disk with no record
of what to remove, and since the caller gets no Environment on those paths,
none of the teardown defers that normally undo it were ever registered.

For a local_directory task the workdir is the user's own repository, so the
leftover marker disabled every human-local multica command for that whole
directory tree — silently, and until someone deleted the file by hand. No
crash was needed to reach this state: a failed provider setup was enough, and
writeSidecarManifest failing was only a warning, so a disk or permission
hiccup produced the same permanent residue.

Roll back in place from the in-memory manifest whenever Prepare fails after
writing context files, and treat a manifest write failure as fatal in place so
that rollback runs while the record still exists. Worktree and cloud envs are
untouched: the former discards the whole worktree, the latter is wiped by GC.

Co-authored-by: multica-agent <[email protected]>

* fix(cli): retire stale daemon task markers instead of refusing forever (MUL-6132)

A daemon task marker left behind in a user's own directory made every
human-local command (daemon start/stop/restart/logs, login, logout, setup,
workspace switch, runtime profile set-path/unset-path) fail anywhere in that
directory tree, permanently and with a message that named neither the file nor
a way out. The reported instance sat undetected for five weeks.

Retire the marker automatically, but only on positive evidence that nothing is
running: every profile's daemon must answer and report zero active tasks.
Silence is not absence — an unreachable daemon is what a crashed one looks
like, and a task orphaned by that crash keeps running with this marker as its
only remaining guard, so any unreachable or unparseable profile keeps the
command refused. A minimum marker age closes the window where a daemon that
reports zero is claiming a task and about to write this very marker.

Two things stay out of scope by construction: markers without an agent and
issue id, which is the permanent node-wide guard at the workspaces root rather
than a task leftover, and any invocation carrying MULTICA_* task identity,
where the daemon really did launch this process and no file may override that.

When staleness cannot be proven the command still fails closed, but the error
now names the marker path, matching what newAPIClient has reported since
MUL-3922 and replacing a message that sent the user to the source.

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): arm the in-place rollback before the first sidecar write (MUL-6132)

The rollback was registered after writeContextFiles returned, but that function
writes the daemon task marker as its very first act and can then fail on any
later step — .agent_context, skill files, project resources. Those failures
returned before the defer existed, so the case the fix was written for still
stranded a marker in the user's repository with nothing beside it.

Arm the defer immediately after the manifest is allocated, before any write.
Rolling back an empty manifest is a no-op, which is what makes arming it that
early safe.

Adds a regression test whose induced failure lands inside writeContextFiles
(a plain file where .agent_context must be a directory), one step after the
marker is written. It fails on the previous commit and passes here.

Co-authored-by: multica-agent <[email protected]>

* fix(cli): drop CLI-side marker deletion, keep the actionable message (MUL-6132)

The self-heal could delete the fail-closed guard of a running task. The daemon
increments active_task_count only after a claim returns, so a probe can read
zero for a task that is already claimed and about to write the marker at this
very path; the marker's age says nothing about a file written after the probe.
Serial probing across profiles widened the window further. Separately, the
"every profile answered" proof did not hold: profiles were matched by health
port, and that port is a hash that can collide, so one idle daemon could be
counted as two profiles while a crashed one kept an orphan task running.

Both are properties of doing this from outside the daemon. Removing a guard
needs synchronization with the claim path that only the daemon has, so
retirement moves there and the CLI keeps the half it can do soundly.

What remains is the message, now shared by both refusal paths. Previously only
newAPIClient named the marker file, so whether a user could act on the refusal
depended on which command they happened to run first; requireHumanLocalCommand
sent them to the source instead. Both now resolve the leftover through one
helper and report it identically.

Co-authored-by: multica-agent <[email protected]>

* fix(cli): report leftover markers on every refusal path, never the root marker (MUL-6132)

Two gaps from the previous round's review.

requireTaskLocalConfigRoot is the third path a leftover marker can refuse
through — config show, config set and auth status all call it — and it still
returned a message that named no file. Whether the error was actionable
depended on which command the user happened to run first, which is exactly what
unifying the other two paths was meant to end. It now appends the same suffix
when a marker is the only daemon signal.

The leftover helper also could not tell the two markers apart, because
daemonTaskContextMarkerPath matches on managed_by alone. Running from the
daemon workspaces root therefore described the permanent node-wide guard as a
leftover to delete — advice that closes the hole it exists to cover, aimed at
whoever reads the error, including an agent subprocess that lost its
environment. The helper now requires task identity, defined as agent_id OR
issue_id: a chat task has no issue, so requiring both would misfile its marker
as the root marker, while the root marker has neither field.

chdirWithDaemonTaskMarker was writing the bare managed_by form, which under
this rule models the root marker rather than the workdir marker its tests are
about; it now writes a task-scoped one.

Also corrects two comments the earlier commits left stale: cmd_agent.go still
described an automatic retirement that no longer exists, and
writeSidecarManifest still claimed every caller treats a write failure as
non-fatal, which stopped being true when the in-place path was made fatal.

Co-authored-by: multica-agent <[email protected]>

* docs(cli): correct the refusal-path count in leftover-marker comments (MUL-6132)

Both comments still said "both refusal paths" from when the suffix was shared by
newAPIClient and requireHumanLocalCommand only. requireTaskLocalConfigRoot
joined them in e02a393, so the count is three; list them by name so the next
addition has somewhere obvious to register.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-14 02:23:44 +08:00

160 lines
6.6 KiB
Go

package execenv
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
// TestPrepareRollsBackSidecarsWhenPrepareFailsInPlace is the regression test for
// MUL-6132: a local_directory Prepare that fails partway must not leave the
// daemon task marker (or any other sidecar) in the user's own repository.
//
// Before the fix, Prepare wrote the marker early and persisted the manifest that
// records it last, and the caller receives no Environment on a failure — so a
// failed Prepare left a marker no code path knew how to remove, which disabled
// every human-local multica command in that directory tree until someone deleted
// the file by hand.
//
// The induced failure is a pre-existing .cursor/mcp.json, which the managed
// cursor MCP path refuses to overwrite. It is a pure filesystem failure that
// needs no agent CLI on the machine, and it lands after the marker is written.
func TestPrepareRollsBackSidecarsWhenPrepareFailsInPlace(t *testing.T) {
workspacesRoot := t.TempDir()
userDir := t.TempDir()
// The user's own content: the file that makes Prepare fail, plus a plain
// file that must survive the rollback untouched.
cursorDir := filepath.Join(userDir, ".cursor")
if err := os.MkdirAll(cursorDir, 0o755); err != nil {
t.Fatalf("create .cursor: %v", err)
}
if err := os.WriteFile(filepath.Join(cursorDir, "mcp.json"), []byte(`{"mine":true}`), 0o644); err != nil {
t.Fatalf("seed user mcp.json: %v", err)
}
userFile := filepath.Join(userDir, "README.md")
if err := os.WriteFile(userFile, []byte("user content"), 0o644); err != nil {
t.Fatalf("seed user file: %v", err)
}
_, err := Prepare(PrepareParams{
WorkspacesRoot: workspacesRoot,
WorkspaceID: "ws-rollback-001",
TaskID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
AgentName: "Test Agent",
Provider: "cursor",
LocalWorkDir: userDir,
McpConfig: json.RawMessage(`{"fetch":{"command":"uvx","args":["mcp-server-fetch"]}}`),
Task: TaskContextForEnv{
IssueID: "11111111-2222-3333-4444-555555555555",
AgentID: "99999999-8888-7777-6666-555555555555",
},
}, testLogger())
if err == nil {
t.Fatal("Prepare succeeded; expected the pre-existing .cursor/mcp.json to fail it")
}
markerPath := filepath.Join(userDir, TaskContextMarkerRelPath)
if _, statErr := os.Stat(markerPath); !os.IsNotExist(statErr) {
t.Fatalf("daemon task marker survived a failed Prepare at %s (stat err: %v)", markerPath, statErr)
}
if _, statErr := os.Stat(filepath.Join(userDir, ".agent_context")); !os.IsNotExist(statErr) {
t.Fatalf(".agent_context survived a failed Prepare (stat err: %v)", statErr)
}
// The rollback must be scoped to what Prepare created: the user's own
// files, including the one that caused the failure, stay exactly as they
// were.
if data, readErr := os.ReadFile(userFile); readErr != nil || string(data) != "user content" {
t.Fatalf("rollback touched user content: data=%q err=%v", string(data), readErr)
}
if data, readErr := os.ReadFile(filepath.Join(cursorDir, "mcp.json")); readErr != nil || string(data) != `{"mine":true}` {
t.Fatalf("rollback touched the user's mcp.json: data=%q err=%v", string(data), readErr)
}
}
// TestPrepareRollsBackWhenWriteContextFilesFailsAfterMarker covers the earliest
// failure window there is: writeContextFiles lays the marker down as its very
// first act, then creates .agent_context, writes skills and writes project
// resources — any of which can fail with the marker already on disk.
//
// The first version of the MUL-6132 fix armed the rollback only after
// writeContextFiles returned, so exactly these failures still stranded a marker
// in the user's repository with nothing else beside it (MUL-6132 review). The
// induced failure is a plain file where .agent_context must be a directory.
func TestPrepareRollsBackWhenWriteContextFilesFailsAfterMarker(t *testing.T) {
workspacesRoot := t.TempDir()
userDir := t.TempDir()
// A regular file at the path writeContextFiles needs as a directory, one
// step after it writes the marker.
blocker := filepath.Join(userDir, ".agent_context")
if err := os.WriteFile(blocker, []byte("user file"), 0o644); err != nil {
t.Fatalf("seed .agent_context blocker: %v", err)
}
_, err := Prepare(PrepareParams{
WorkspacesRoot: workspacesRoot,
WorkspaceID: "ws-rollback-003",
TaskID: "cccccccc-dddd-eeee-ffff-000000000000",
AgentName: "Test Agent",
LocalWorkDir: userDir,
Task: TaskContextForEnv{
IssueID: "33333333-4444-5555-6666-777777777777",
AgentID: "77777777-6666-5555-4444-333333333333",
},
}, testLogger())
if err == nil {
t.Fatal("Prepare succeeded; expected the .agent_context blocker to fail it")
}
markerPath := filepath.Join(userDir, TaskContextMarkerRelPath)
if _, statErr := os.Stat(markerPath); !os.IsNotExist(statErr) {
t.Fatalf("daemon task marker survived a failure inside writeContextFiles at %s (stat err: %v)", markerPath, statErr)
}
if data, readErr := os.ReadFile(blocker); readErr != nil || string(data) != "user file" {
t.Fatalf("rollback touched the user's own file: data=%q err=%v", string(data), readErr)
}
}
// TestPrepareSucceedsInPlaceAndLeavesMarker guards the other half of the
// contract: the rollback fires only on failure. A Prepare that completes must
// still leave the marker in place — it is the guard that makes a CLI invocation
// from inside the task fail closed — and must persist the manifest that the
// task teardown later uses to remove it.
func TestPrepareSucceedsInPlaceAndLeavesMarker(t *testing.T) {
workspacesRoot := t.TempDir()
userDir := t.TempDir()
env, err := Prepare(PrepareParams{
WorkspacesRoot: workspacesRoot,
WorkspaceID: "ws-rollback-002",
TaskID: "bbbbbbbb-cccc-dddd-eeee-ffffffffffff",
AgentName: "Test Agent",
LocalWorkDir: userDir,
Task: TaskContextForEnv{
IssueID: "22222222-3333-4444-5555-666666666666",
AgentID: "88888888-7777-6666-5555-444444444444",
},
}, testLogger())
if err != nil {
t.Fatalf("Prepare failed: %v", err)
}
if _, statErr := os.Stat(filepath.Join(userDir, TaskContextMarkerRelPath)); statErr != nil {
t.Fatalf("successful Prepare did not leave the daemon task marker: %v", statErr)
}
if _, statErr := os.Stat(filepath.Join(env.RootDir, sidecarManifestFile)); statErr != nil {
t.Fatalf("successful Prepare did not persist the sidecar manifest: %v", statErr)
}
// And the recorded manifest is what makes the marker removable later.
if err := CleanupSidecars(env.RootDir); err != nil {
t.Fatalf("CleanupSidecars: %v", err)
}
if _, statErr := os.Stat(filepath.Join(userDir, TaskContextMarkerRelPath)); !os.IsNotExist(statErr) {
t.Fatalf("marker survived CleanupSidecars (stat err: %v)", statErr)
}
}