mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 17:36:45 +08:00
* refactor(prompt): give each duplicated instruction one owner (MUL-6984) Four cases where the same data or rule was rendered two or three times into one context window, following the sibling-block removal in #7937. Each is resolved by picking the surface that owns it and pointing the others there — no behavior change beyond what an agent stops reading twice. The rule used to pick the owner is the one MUL-5377 already set for issue tasks: the runtime brief holds what is true of this KIND of task (stable, and it is the prompt-cache prefix), the per-turn user message holds what is true of this RUN, and the workdir sidecar holds nothing. Autopilot, quick-create and squad-leader paths had never been cleaned up against it. Autopilot run data (run id, autopilot id, title, source, trigger payload, instructions) was rendered in the brief, the per-turn message, and the sidecar. Now per-turn only; the brief keeps the run-only framing and the shared issue-command guard. This also settles a contract violation: those are per-run values, and the brief promises not to carry any. MUL-5696 already caught the two copies drifting on the guard sentence and fixed that one line with a shared constant. The payload is now capped at 8 KB with an explicit truncation notice — it is written by whatever system fired the trigger, so nothing here bounds it, and a clipped JSON object read as a whole one is worse than a short one. The squad leader no_action rule was written four times: the Squad Operating Protocol, the per-turn prompt, brief workflow step 4, and brief ## Output. The four had already drifted — only some carried the MUL-6622 escape hatch that keeps a FAILED `squad activity` call from ending the turn in silence. The protocol is now the single statement (it is the only document sent exclusively to leaders); the two brief sites and the per-turn reply imperative carry the EXCEPTION to their own imperative and point back at it. Quick-create's five run/output rules were in the brief's Workflow, the brief's ## Output, and the per-turn "Output format" block; the --description-file /tmp guidance was in the brief's Available Commands and per-turn. The brief owns both now — its own comment says these guardrails must hold "even if the user message is missing" — and per-turn renders only the field values the modal picked. .agent_context/issue_context.md is deleted outright. Nothing read it: the rendered CLAUDE.md never mentioned .agent_context, no per-turn message pointed at it, and no provider discovers it. Its writer's own comment said "the runtime brief already carries every fact this file would". With it gone the directory is created only when the default provider needs .agent_context/skills, so a local_directory task no longer leaves an empty managed directory in the user's checkout. Compatibility: prompt text only. No wire field, CLI flag, or DB column changes, so no server/daemon version coupling. Tests follow the text to its new owner rather than being deleted: the protocol's no_action rule is now pinned in internal/handler, the quick-create rules in the brief's own suite, and the payload cap has its own coverage including the rune-boundary cut. Co-authored-by: multica-agent <[email protected]> * fix(prompt): drop the autopilot payload cap, pin the moved invariant (MUL-6984) Addresses review on #7971. The 8 KB trigger-payload cap is removed. Its rationale was wrong twice over: webhook ingress already bounds a body at 256 KiB (handler.maxWebhookBodyBytes), so the payload was never unbounded from Multica's side; and because de-duplication made the per-turn message the payload's ONLY rendering, truncating there dropped task input with no way back — GET /api/autopilots/{id}/runs/{runId} returns the full payload but no CLI command exposes it, and `autopilot runs` lists runs without payloads. A legitimate 9 KB webhook would have silently lost fields the autopilot instructions might name, and the "JSON is incomplete" notice only prevents misreading, it does not recover anything. The cap belongs with an agent-facing read-back command and a threshold taken from real payload sizes, not as a side effect of removing duplicates. Rendering it once instead of twice is still a strict improvement over the pre-PR behavior. TestBriefByteIdenticalAcrossRunsForEveryKind could not see the invariant this PR actually moves: its autopilot row pins run-1 / ap-1 and every variant mutates only the generic resume / initiator / connected-app fields, so putting the title, source, payload or description back in the brief left it green. TestAutopilotBriefByteIdenticalAcrossRunScopedFields adds a second and third run that vary all of them — including a different autopilot id — asserts the brief is byte-identical, and asserts the brief contains none of those values. That second half matters: byte-identity alone also holds if the values reach no surface at all. Verified against a deliberately reintroduced field, where it fails as intended. TestBuildPromptAutopilotRunOnly gains the source and payload assertions so the per-turn message is pinned as their sole owner. Contract comments that still described the deleted sidecar are corrected: agent.MinHandoffCLIVersion (the gate always measured the opening prompt — no provider read the sidecar), the handoff_note field docs on both sides of the claim wire, the claim handler's note, TaskContextForEnv.HandoffNote, and errPathPreExists's namespace list. The Reuse path keeps its issue_context.md mentions, now labelled as cleanup of manifests written by older builds rather than of anything this code still writes. Co-authored-by: multica-agent <[email protected]> * refactor(squad): keep the protocol's ownership note out of the prompt (MUL-6984) Review nit on #7971. The Squad Operating Protocol ended with a paragraph saying it is the single statement of the no_action rule and that the rule changes here — true, and worth writing down, but addressed to whoever maintains the text. That const is sent to the model on every leader turn, so the leader paid ~40 tokens per turn for a sentence it cannot act on. Moved to the const's doc comment, next to the other constraints a maintainer reads before editing it. The skill side already records the same ownership in multica-squads/references/squad-source-map.md. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: J <[email protected]> Co-authored-by: multica-agent <[email protected]>
163 lines
6.8 KiB
Go
163 lines
6.8 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 writes skills and project resources — either 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;
|
|
// the task carries a skill because the default provider's skills tree
|
|
// (.agent_context/skills) is now the only thing that opens that 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",
|
|
AgentSkills: []SkillContextForEnv{{Name: "Go Conventions", Content: "Follow Go conventions."}},
|
|
},
|
|
}, 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)
|
|
}
|
|
}
|