Compare commits

...

4 Commits

Author SHA1 Message Date
J
a597f49b79 Merge remote-tracking branch 'origin/main' into fix/local-dir-runtime-config-marker-append
# Conflicts:
#	server/internal/daemon/execenv/runtime_config.go
2026-05-28 16:12:56 +08:00
J
c1c268c647 fix(daemon): byte-exact inject/cleanup round trip for runtime config (MUL-2753)
Address Elon's second-round review on PR #3438. The previous cleanup
relied on `TrimRight + "\n"` for trailing newlines and `TrimSpace == ""`
for file removal — both compensated for the inject path's "normalise
trailing newlines so there's always exactly `\n\n` before the block"
step, but they did so by mutating the user's bytes. The result was a
real diff on three boundary cases:

  - file ended without a newline (`rules`) → cleanup added one;
  - file ended with two or more newlines (`rules\n\n`) → cleanup
    collapsed to a single newline;
  - file pre-existed but was empty / whitespace-only → cleanup
    deleted it.

Reshape the contract so the bytes inject adds are the exact bytes
cleanup removes, with no user-byte mutation in between:

  - Define `runtimeManagedSeparator = "\n\n"` as a fixed managed
    separator that inject always inserts (unconditionally — including
    for files that already end in two or more newlines) between
    pre-existing user content and the marker block.
  - Inject's missing-file branch still writes the block alone (no
    separator); that absence is the marker Cleanup uses to identify
    "we created this file from scratch" and is the only condition
    under which Cleanup is allowed to `os.Remove` the file.
  - Cleanup detects `HasSuffix(pre, runtimeManagedSeparator)` and
    strips exactly those bytes; whatever remains is written back
    verbatim with no `TrimRight` / `TrimSpace`, so the pre-injection
    bytes survive exactly.

The replace-in-place branch is untouched — the managed separator
established by the first inject lives in pre and survives across
subsequent runs, so byte-exactness is preserved through arbitrary
inject→inject→cleanup chains.

Tests:

  - `TestInjectThenCleanupRoundTripByteExactBoundaries` parameterises
    9 seed shapes (missing file, empty, whitespace-only, no trailing
    newline, one trailing newline, two trailing newlines, many
    trailing newlines, CRLF line endings, no final newline with
    embedded blank lines) and asserts byte-identical round trip
    across two full cycles.
  - `TestInjectReplaceThenCleanupRestoresByteExact` covers the
    replace-in-place branch for the same boundary seeds.
  - `TestWriteRuntimeConfigFileAlwaysInsertsFixedManagedSeparator`
    pins the new invariant at the source: regardless of seed shape,
    inject emits `<seed><\n\n><marker block>` with no normalisation.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-28 16:09:01 +08:00
J
44a4d3da60 fix(daemon): cleanup runtime config marker block after local_directory tasks (MUL-2753)
Address Elon's review on PR #3438:

1. Add `CleanupRuntimeConfig` and wire it into the daemon's task path so
   `local_directory` runs excise the marker block on the way out. Without
   it, a user's subsequent manual `claude` / `codex` / `gemini` run in
   the same directory picks up the previous task's stale brief (issue
   id, trigger comment id, reply rules) and acts on the wrong context.
   Cloud workspace runs skip the cleanup — their scratch workdir is
   wiped by the GC loop anyway.

2. If excising the block would leave the file empty / whitespace-only,
   the file is removed so we don't leave behind a stub the user has to
   delete by hand. Surviving user content is preserved byte-for-byte.

3. Harden the marker parser: search for the end marker strictly after
   the begin marker. The previous `strings.Index` pair mishandled two
   malformed cases —
     - a stray end marker before any begin (e.g. user pasted a
       documentation snippet showing the wire format) would cause
       every run to stack another block, growing the file unboundedly;
     - a half-block left by a previous crashed run would cause every
       subsequent run to append a fresh block beneath the half-block.
   The `locateMarkerBlock` helper now anchors the end search past the
   begin offset, and treats "begin found, no end after" as "block runs
   to EOF" so the next write replaces it cleanly.

Centralised the provider→filename mapping in `runtimeConfigPath` so
Inject and Cleanup can't drift past each other when a new provider is
added.

Tests cover: parser hardening (stray-end-before-begin idempotency,
half-block recovery), Cleanup happy path / file removal / no-op cases /
malformed half-block / per-provider mapping, and an end-to-end
inject→cleanup round trip that locks in byte-identical restoration of
the user's pre-injection file.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-28 15:54:37 +08:00
J
e13ea9c687 fix(daemon): preserve user CLAUDE.md / AGENTS.md / GEMINI.md in local_directory runs (MUL-2753)
InjectRuntimeConfig previously called os.WriteFile unconditionally, which
truncated whatever file lived at the same path. For the local_directory
project_resource flow the workdir is the user's own repo, so the agent
silently destroyed any repo-level CLAUDE.md / AGENTS.md / GEMINI.md the
first time it ran in that directory, and the daemon's local-directory
cleanup explicitly skips the user's path so the file was never restored.

Write the brief inside a marker block instead:

  <!-- BEGIN MULTICA-RUNTIME (auto-managed; do not edit) -->
  ...brief...
  <!-- END MULTICA-RUNTIME -->

writeRuntimeConfigFile handles three states:

- file missing  -> create with just the marker block,
- file present, no marker block -> append the marker block at the end
  (preserves user-authored content above), and
- file present, marker block already there -> replace the block body in
  place so repeated runs don't grow the file unboundedly.

This is the short-term fix called out on MUL-2753. The sidecar question
(.agent_context/, .claude/skills/, .multica/project/resources.json) is
left for a follow-up — those files don't overwrite user content, just
litter the workdir.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-28 15:37:35 +08:00
3 changed files with 1044 additions and 12 deletions

View File

@@ -2566,9 +2566,25 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i
if err != nil {
d.logger.Warn("execenv: inject runtime config failed (non-fatal)", "error", err)
}
// NOTE: No cleanup — workdir is preserved for reuse by future tasks on
// the same (agent, issue) pair. The work_dir path is stored in DB on
// task completion and passed back via PriorWorkDir on the next claim.
// Workdir is preserved for reuse by future tasks on the same (agent,
// issue) pair in cloud mode; the work_dir path is stored in DB on task
// completion and passed back via PriorWorkDir on the next claim, so
// rewriting the marker block in place is the right behavior.
//
// In local_directory mode the workdir is the user's own repo, reuse is
// already disabled above (see localAssignment == nil), and the brief
// would otherwise live on inside the user's repository — a subsequent
// manual `claude` / `codex` / `gemini` run in that directory would pick
// up stale Multica instructions (issue id, trigger comment id, reply
// rules) and start acting on the previous task's context. Excise the
// marker block on the way out instead.
if env.LocalDirectory {
defer func() {
if cerr := execenv.CleanupRuntimeConfig(env.WorkDir, provider); cerr != nil {
d.logger.Warn("execenv: cleanup runtime config failed (non-fatal)", "error", cerr)
}
}()
}
prompt := BuildPrompt(task, provider)

View File

@@ -2,13 +2,53 @@ package execenv
import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"runtime"
"strings"
)
// runtimeMarkerBegin and runtimeMarkerEnd delimit the Multica-managed brief
// inside the runtime config file (CLAUDE.md / AGENTS.md / GEMINI.md). The
// markers exist so writeRuntimeConfigFile can:
//
// - preserve user-authored content in the same file (the user's repo may
// already ship a CLAUDE.md / AGENTS.md when the agent is pointed at a
// local_directory project resource),
// - replace the brief idempotently on subsequent runs in the same workdir
// instead of appending duplicate copies, and
// - leave a precise excision target for a future cleanup pass.
//
// HTML comments are used so the markers are inert in every Markdown renderer
// and harmless when fed to the agent as instructions. Changing the marker
// text is a breaking change for any file that already carries the previous
// markers — bump deliberately.
const (
runtimeMarkerBegin = "<!-- BEGIN MULTICA-RUNTIME (auto-managed; do not edit) -->"
runtimeMarkerEnd = "<!-- END MULTICA-RUNTIME -->"
// runtimeManagedSeparator is the fixed separator inserted between any
// pre-existing user content and the marker block whenever Inject
// appends to a file that already exists. The separator is considered
// part of the managed region: Cleanup strips it together with the
// block, so the file rolls back to its exact pre-injection bytes
// regardless of whether the user file ended with no newline, one
// newline, or multiple trailing newlines. Without a fixed-width
// separator the cleanup path would have to renormalise the user's
// trailing bytes and would leave a subtle but real diff every run
// (see MUL-2753 review on PR #3438).
//
// Cleanup distinguishes "file we created" (no managed separator
// precedes the block — write a missing file from scratch) from "file
// that pre-existed" (managed separator precedes the block) so the
// file's existence is preserved exactly across the inject→cleanup
// cycle, including empty / whitespace-only pre-existing files.
runtimeManagedSeparator = "\n\n"
)
// runtimeGOOS is the host-platform string used by buildMetaSkillContent and
// BuildCommentReplyInstructions to emit Windows-specific guidance. Defaults
// to runtime.GOOS; tests override it to exercise the cross-platform branches
@@ -101,18 +141,201 @@ func formatProjectResource(r ProjectResourceForEnv) string {
// For Antigravity: writes {workDir}/AGENTS.md (agy CLI reads AGENTS.md natively; skills discovered natively from .agents/skills/ — see https://antigravity.google/docs/gcli-migration)
func InjectRuntimeConfig(workDir, provider string, ctx TaskContextForEnv) (string, error) {
content := buildMetaSkillContent(provider, ctx)
switch provider {
case "claude":
return content, os.WriteFile(filepath.Join(workDir, "CLAUDE.md"), []byte(content), 0o644)
case "codex", "copilot", "opencode", "openclaw", "hermes", "pi", "cursor", "kimi", "kiro", "antigravity":
return content, os.WriteFile(filepath.Join(workDir, "AGENTS.md"), []byte(content), 0o644)
case "gemini":
return content, os.WriteFile(filepath.Join(workDir, "GEMINI.md"), []byte(content), 0o644)
default:
path := runtimeConfigPath(workDir, provider)
if path == "" {
// Unknown provider — skip config injection, prompt-only mode.
return content, nil
}
return content, writeRuntimeConfigFile(path, content)
}
// runtimeConfigPath returns the absolute path to the runtime config file that
// InjectRuntimeConfig writes for the given provider, or "" when the provider
// has no file-based config target. Centralising the mapping keeps Inject /
// Cleanup in lockstep — both paths consult the same table so a new provider
// added to one side cannot drift past the other.
func runtimeConfigPath(workDir, provider string) string {
switch provider {
case "claude":
return filepath.Join(workDir, "CLAUDE.md")
case "codex", "copilot", "opencode", "openclaw", "hermes", "pi", "cursor", "kimi", "kiro", "antigravity":
return filepath.Join(workDir, "AGENTS.md")
case "gemini":
return filepath.Join(workDir, "GEMINI.md")
default:
return ""
}
}
// writeRuntimeConfigFile writes the Multica runtime brief to path without
// clobbering any user-authored content already present. Behaviour by file
// state:
//
// - file missing → create the file containing only the marker block, no
// leading separator. Cleanup detects the absence of the separator and
// restores the missing-file state by removing the file outright.
// - file present (any content, including empty), no marker block →
// append `<runtimeManagedSeparator>` + the marker block. The
// separator's bytes are part of the managed region so Cleanup can
// restore the user's pre-injection bytes exactly (no trailing-newline
// normalisation, no surprises for files that ended without a newline
// or with extra trailing newlines).
// - file present, marker block already there → replace the body between
// the markers in place so repeated runs in the same workdir don't grow
// the file unboundedly. The pre-block content (including any managed
// separator established by the first inject) is preserved verbatim.
//
// The previous implementation called os.WriteFile unconditionally, which
// silently truncated a repository's CLAUDE.md / AGENTS.md / GEMINI.md the
// first time the agent was pointed at the user's own directory via the
// local_directory project resource flow. See MUL-2753.
func writeRuntimeConfigFile(path, brief string) error {
block := runtimeMarkerBegin + "\n" + strings.TrimRight(brief, "\n") + "\n" + runtimeMarkerEnd + "\n"
existing, err := os.ReadFile(path)
if errors.Is(err, fs.ErrNotExist) {
return os.WriteFile(path, []byte(block), 0o644)
}
if err != nil {
return fmt.Errorf("read existing runtime config %s: %w", path, err)
}
existingStr := string(existing)
if start, end, ok := locateMarkerBlock(existingStr); ok {
// Replace the existing block in place. locateMarkerBlock already
// consumes the trailing newline that closed the previous block, so
// successive runs don't accumulate blank lines around the block.
// The managed separator (if any) lives in existingStr[:start] and
// is preserved untouched.
newContent := existingStr[:start] + block + existingStr[end:]
return os.WriteFile(path, []byte(newContent), 0o644)
}
// No marker block present. Append the fixed managed separator followed
// by the block. The separator is unconditional — including for files
// that already end in two or more newlines — so the byte boundary
// between user content and the managed region is deterministic, which
// is what lets Cleanup roll back to the user's exact original bytes.
return os.WriteFile(path, []byte(existingStr+runtimeManagedSeparator+block), 0o644)
}
// locateMarkerBlock finds the [start, end) byte range of the Multica marker
// block inside content. The returned `end` is one past the block's trailing
// newline (if any) so callers can splice the block out without leaving an
// orphan blank line behind.
//
// The end marker is searched for strictly after the begin marker. This
// matters for two malformed cases that the previous naive `strings.Index`
// pair would mishandle:
//
// - User content carries a stray `<!-- END MULTICA-RUNTIME -->` (e.g. a
// documentation snippet showing what the wire format looks like) before
// any begin marker. The naive parser would find that end and reject the
// block (`endIdx > startIdx` false), then append a fresh block — and
// since the stray end stays in place, every subsequent run would append
// yet another block, growing the file unboundedly.
// - A previous run crashed between writing begin and end and left the file
// with a half-block. The naive parser would not find an end, fall
// through to the append branch, and stack a new block after the
// half-block. Treating "begin found, no end after" as "the block ends
// at EOF" makes the next write replace the half-block in place.
func locateMarkerBlock(content string) (start, end int, found bool) {
start = strings.Index(content, runtimeMarkerBegin)
if start < 0 {
return 0, 0, false
}
afterBegin := start + len(runtimeMarkerBegin)
endRel := strings.Index(content[afterBegin:], runtimeMarkerEnd)
if endRel < 0 {
// Malformed — no end marker after begin. Treat the rest of the file
// as the block so the next write replaces it cleanly instead of
// stacking another block beneath the half-block.
return start, len(content), true
}
end = afterBegin + endRel + len(runtimeMarkerEnd)
if end < len(content) && content[end] == '\n' {
end++
}
return start, end, true
}
// CleanupRuntimeConfig excises the Multica marker block from the runtime
// config file for the given provider and restores the file to its exact
// pre-injection state, byte for byte. The cleanup is the second half of
// the contract `writeRuntimeConfigFile` establishes: together they must
// round-trip a user's local repository config across an arbitrary number
// of Multica runs without ever touching a single non-managed byte.
//
// Behaviour, mirroring the three Inject states:
//
// - file has no marker block → no-op (nothing was ever injected here);
// - block is at the start of the file with no preceding managed
// separator → the file was created by Inject from a missing-file
// state. Remove the file outright so the post-cleanup directory
// listing is byte-identical to the pre-Inject one.
// - block is preceded by the fixed managed separator → strip the
// separator together with the block; whatever remains (which may be
// an empty pre-existing file, a whitespace-only file, or arbitrary
// user content) is the user's original file, written back verbatim
// with NO trailing-newline normalisation and NO TrimSpace-based file
// removal heuristic. Both of those were sources of subtle diff in
// PR #3438 review feedback.
//
// Required for the local_directory flow (WorkDir is the user's own repo):
// without this pass, a manual `claude` / `codex` / `gemini` run started by
// the user inside the same directory after a Multica task would pick up
// the stale brief and act on the previous task's issue id, trigger
// comment id, and reply rules. Cloud workspace runs never trigger this
// pollution because their workdir is daemon scratch that the GC loop
// deletes wholesale; the daemon skips this Cleanup on those workdirs.
//
// Missing files, unknown providers, and files without a marker block are
// no-ops — Cleanup is safe to call defensively.
func CleanupRuntimeConfig(workDir, provider string) error {
path := runtimeConfigPath(workDir, provider)
if path == "" {
return nil
}
existing, err := os.ReadFile(path)
if errors.Is(err, fs.ErrNotExist) {
return nil
}
if err != nil {
return fmt.Errorf("read runtime config %s: %w", path, err)
}
existingStr := string(existing)
start, end, ok := locateMarkerBlock(existingStr)
if !ok {
return nil
}
pre := existingStr[:start]
post := existingStr[end:]
// Detect — and strip — the fixed managed separator that Inject puts
// immediately before the block whenever it appended to a file that
// pre-existed. The absence of the separator is the marker that says
// "Inject created this file from scratch", which is the only case
// where Cleanup is allowed to delete the file.
hadManagedSeparator := strings.HasSuffix(pre, runtimeManagedSeparator)
if hadManagedSeparator {
pre = pre[:len(pre)-len(runtimeManagedSeparator)]
}
remainder := pre + post
if !hadManagedSeparator && remainder == "" {
// Inject created the file (no managed separator → block was the
// only content). Restore the missing-file state.
if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("remove runtime config %s: %w", path, err)
}
return nil
}
// File pre-existed (possibly empty, possibly whitespace-only,
// possibly with user content) — write the remainder back exactly,
// without any normalisation. An empty `remainder` here means the
// user's original file was empty; we still write it (zero-byte file)
// so the file's existence is preserved.
return os.WriteFile(path, []byte(remainder), 0o644)
}
// buildMetaSkillContent generates the meta skill markdown that teaches the agent

View File

@@ -1,6 +1,9 @@
package execenv
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
@@ -382,3 +385,793 @@ func TestSubIssueCreationSectionSkippedForNonIssueModes(t *testing.T) {
})
}
}
// writeRuntimeConfigFile is the safe replacement for the previous
// unconditional os.WriteFile of CLAUDE.md / AGENTS.md / GEMINI.md. The three
// states it must handle correctly are: file missing, file present without
// markers (user-authored content already there — the regression case from
// MUL-2753), and file present with markers (idempotent second-run replace).
func TestWriteRuntimeConfigFileCreatesMissingFile(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "CLAUDE.md")
const brief = "# Multica Agent Runtime\n\nbrief body line"
if err := writeRuntimeConfigFile(path, brief); err != nil {
t.Fatalf("writeRuntimeConfigFile returned error: %v", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read back file: %v", err)
}
s := string(got)
if !strings.HasPrefix(s, runtimeMarkerBegin+"\n") {
t.Errorf("output should start with begin marker, got:\n%s", s)
}
if !strings.Contains(s, brief) {
t.Errorf("output should contain brief body, got:\n%s", s)
}
if !strings.Contains(s, "\n"+runtimeMarkerEnd+"\n") {
t.Errorf("output should contain end marker followed by newline, got:\n%s", s)
}
}
func TestWriteRuntimeConfigFilePreservesUserContent(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "CLAUDE.md")
const userContent = "# User repo CLAUDE.md\n\n- rule one\n- rule two\n"
if err := os.WriteFile(path, []byte(userContent), 0o644); err != nil {
t.Fatalf("seed user file: %v", err)
}
const brief = "## Multica brief\n\ninjected body"
if err := writeRuntimeConfigFile(path, brief); err != nil {
t.Fatalf("writeRuntimeConfigFile returned error: %v", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read back file: %v", err)
}
s := string(got)
// The user's original content must be untouched and appear before the
// injected marker block; this is the core regression case from MUL-2753.
if !strings.HasPrefix(s, userContent) {
t.Errorf("user content must be preserved verbatim at the top of the file, got:\n%s", s)
}
beginIdx := strings.Index(s, runtimeMarkerBegin)
endIdx := strings.Index(s, runtimeMarkerEnd)
if beginIdx < 0 || endIdx <= beginIdx {
t.Fatalf("expected a well-formed marker block in:\n%s", s)
}
if beginIdx < len(userContent) {
t.Errorf("begin marker must appear after user content, beginIdx=%d userLen=%d", beginIdx, len(userContent))
}
if !strings.Contains(s, brief) {
t.Errorf("brief body missing from output:\n%s", s)
}
}
func TestWriteRuntimeConfigFileReplacesExistingBlock(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "AGENTS.md")
const userBefore = "# User AGENTS.md\n\nuser line above\n"
const userAfter = "\nuser line below the block\n"
original := userBefore +
runtimeMarkerBegin + "\n" +
"OLD BRIEF CONTENT THAT MUST GO AWAY\n" +
runtimeMarkerEnd + "\n" +
userAfter
if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
t.Fatalf("seed: %v", err)
}
const newBrief = "## New Multica brief\n\nfresh body"
if err := writeRuntimeConfigFile(path, newBrief); err != nil {
t.Fatalf("writeRuntimeConfigFile returned error: %v", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read back file: %v", err)
}
s := string(got)
if !strings.HasPrefix(s, userBefore) {
t.Errorf("content above the marker block must be preserved, got:\n%s", s)
}
if !strings.HasSuffix(s, userAfter) {
t.Errorf("content below the marker block must be preserved, got:\n%s", s)
}
if strings.Contains(s, "OLD BRIEF CONTENT THAT MUST GO AWAY") {
t.Errorf("previous block body must be replaced, got:\n%s", s)
}
if !strings.Contains(s, newBrief) {
t.Errorf("new brief body missing from output:\n%s", s)
}
if strings.Count(s, runtimeMarkerBegin) != 1 || strings.Count(s, runtimeMarkerEnd) != 1 {
t.Errorf("there must be exactly one begin/end marker pair, got:\n%s", s)
}
}
func TestWriteRuntimeConfigFileIsIdempotent(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "CLAUDE.md")
const userContent = "# User CLAUDE.md\n\nimportant rules\n"
if err := os.WriteFile(path, []byte(userContent), 0o644); err != nil {
t.Fatalf("seed user file: %v", err)
}
const brief = "## Multica brief\n\nbody"
for i := 0; i < 5; i++ {
if err := writeRuntimeConfigFile(path, brief); err != nil {
t.Fatalf("iteration %d: %v", i, err)
}
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read back file: %v", err)
}
s := string(got)
if strings.Count(s, runtimeMarkerBegin) != 1 {
t.Errorf("repeated runs must not duplicate the begin marker, count=%d, file:\n%s", strings.Count(s, runtimeMarkerBegin), s)
}
if strings.Count(s, runtimeMarkerEnd) != 1 {
t.Errorf("repeated runs must not duplicate the end marker, count=%d, file:\n%s", strings.Count(s, runtimeMarkerEnd), s)
}
if strings.Count(s, brief) != 1 {
t.Errorf("repeated runs must not duplicate the brief body, count=%d, file:\n%s", strings.Count(s, brief), s)
}
if !strings.HasPrefix(s, userContent) {
t.Errorf("user content must remain intact at the top of the file, got:\n%s", s)
}
}
// InjectRuntimeConfig is the production entry point — verify the marker
// semantics propagate through it for each provider's target filename.
func TestInjectRuntimeConfigPreservesUserContent(t *testing.T) {
t.Parallel()
cases := []struct {
provider string
filename string
}{
{"claude", "CLAUDE.md"},
{"codex", "AGENTS.md"},
{"copilot", "AGENTS.md"},
{"opencode", "AGENTS.md"},
{"openclaw", "AGENTS.md"},
{"hermes", "AGENTS.md"},
{"pi", "AGENTS.md"},
{"cursor", "AGENTS.md"},
{"kimi", "AGENTS.md"},
{"kiro", "AGENTS.md"},
{"antigravity", "AGENTS.md"},
{"gemini", "GEMINI.md"},
}
for _, tc := range cases {
tc := tc
t.Run(tc.provider, func(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, tc.filename)
const userContent = "# User-authored file\n\ndon't touch this\n"
if err := os.WriteFile(path, []byte(userContent), 0o644); err != nil {
t.Fatalf("seed: %v", err)
}
content, err := InjectRuntimeConfig(dir, tc.provider, TaskContextForEnv{
IssueID: "11111111-2222-3333-4444-555555555555",
})
if err != nil {
t.Fatalf("InjectRuntimeConfig: %v", err)
}
if content == "" {
t.Fatalf("returned brief content must be non-empty")
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read back: %v", err)
}
s := string(got)
if !strings.HasPrefix(s, userContent) {
t.Errorf("[%s] user content must be preserved verbatim at the top of %s, got:\n%s", tc.provider, tc.filename, s)
}
if !strings.Contains(s, runtimeMarkerBegin) || !strings.Contains(s, runtimeMarkerEnd) {
t.Errorf("[%s] %s must contain the runtime marker block, got:\n%s", tc.provider, tc.filename, s)
}
})
}
}
func TestInjectRuntimeConfigUnknownProviderSkipsWrite(t *testing.T) {
t.Parallel()
dir := t.TempDir()
// Seed all three candidate filenames so we can verify none of them get
// written when the provider is unknown.
for _, name := range []string{"CLAUDE.md", "AGENTS.md", "GEMINI.md"} {
if err := os.WriteFile(filepath.Join(dir, name), []byte("untouched\n"), 0o644); err != nil {
t.Fatalf("seed %s: %v", name, err)
}
}
if _, err := InjectRuntimeConfig(dir, "totally-unknown-provider", TaskContextForEnv{
IssueID: "11111111-2222-3333-4444-555555555555",
}); err != nil {
t.Fatalf("InjectRuntimeConfig: %v", err)
}
for _, name := range []string{"CLAUDE.md", "AGENTS.md", "GEMINI.md"} {
got, err := os.ReadFile(filepath.Join(dir, name))
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
if string(got) != "untouched\n" {
t.Errorf("unknown provider must not write %s; got:\n%s", name, string(got))
}
}
}
// Parser hardening: the end marker must be found strictly after the begin
// marker so a stray end marker that appears earlier in user content (e.g.
// a documentation snippet showing what the wire format looks like) doesn't
// trick writeRuntimeConfigFile into thinking the file is malformed and
// appending another block on every run.
func TestWriteRuntimeConfigFileIgnoresStrayEndMarkerBeforeBegin(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "CLAUDE.md")
// Seed a file whose user-authored portion documents the marker format
// (so the *end* marker appears before any *begin* marker), then has a
// real block authored by an earlier Multica run below.
const userDoc = "# Repo CLAUDE.md\n\nExample of what Multica writes:\n" +
runtimeMarkerEnd + "\n\n# Real config below\n"
original := userDoc +
runtimeMarkerBegin + "\nFIRST BRIEF\n" + runtimeMarkerEnd + "\n"
if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
t.Fatalf("seed: %v", err)
}
const newBrief = "SECOND BRIEF"
if err := writeRuntimeConfigFile(path, newBrief); err != nil {
t.Fatalf("writeRuntimeConfigFile: %v", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read back: %v", err)
}
s := string(got)
// The user's stray end marker line plus surrounding doc text must still
// be present, and the file must contain exactly one begin marker and
// one *additional* end marker (so two end markers total — the stray
// one and the one closing our block).
if !strings.Contains(s, userDoc) {
t.Errorf("user doc with stray end marker must be preserved verbatim, got:\n%s", s)
}
if got, want := strings.Count(s, runtimeMarkerBegin), 1; got != want {
t.Errorf("expected exactly %d begin markers, got %d:\n%s", want, got, s)
}
if got, want := strings.Count(s, runtimeMarkerEnd), 2; got != want {
t.Errorf("expected exactly %d end markers (1 user stray + 1 closing our block), got %d:\n%s", want, got, s)
}
if strings.Contains(s, "FIRST BRIEF") {
t.Errorf("previous brief body must be replaced, got:\n%s", s)
}
if !strings.Contains(s, newBrief) {
t.Errorf("new brief body missing from output:\n%s", s)
}
// Idempotency under the stray-end pattern: a second write must not
// stack another block.
if err := writeRuntimeConfigFile(path, newBrief); err != nil {
t.Fatalf("second writeRuntimeConfigFile: %v", err)
}
got2, _ := os.ReadFile(path)
s2 := string(got2)
if got, want := strings.Count(s2, runtimeMarkerBegin), 1; got != want {
t.Errorf("repeat write must not grow begin markers, got %d, want %d:\n%s", got, want, s2)
}
}
// Parser hardening: a file containing only a begin marker (e.g. a previous
// run that crashed mid-write) must not cause every subsequent run to stack
// another block beneath the half-block.
func TestWriteRuntimeConfigFileReplacesMalformedHalfBlock(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "AGENTS.md")
const userTop = "# Repo AGENTS.md\n\nrules above\n"
const halfBlock = "leftover from crashed write\nsecond line\n"
original := userTop + runtimeMarkerBegin + "\n" + halfBlock
if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
t.Fatalf("seed: %v", err)
}
const newBrief = "recovered brief"
if err := writeRuntimeConfigFile(path, newBrief); err != nil {
t.Fatalf("writeRuntimeConfigFile: %v", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read back: %v", err)
}
s := string(got)
if !strings.HasPrefix(s, userTop) {
t.Errorf("user content above the half-block must be preserved, got:\n%s", s)
}
if strings.Contains(s, "leftover from crashed write") {
t.Errorf("half-block contents must be replaced, got:\n%s", s)
}
if got, want := strings.Count(s, runtimeMarkerBegin), 1; got != want {
t.Errorf("expected exactly %d begin marker, got %d:\n%s", want, got, s)
}
if got, want := strings.Count(s, runtimeMarkerEnd), 1; got != want {
t.Errorf("expected exactly %d end marker after recovery, got %d:\n%s", want, got, s)
}
if !strings.Contains(s, newBrief) {
t.Errorf("new brief body missing from output:\n%s", s)
}
}
// Cleanup excises the marker block, preserving every byte of surrounding
// user content. This is the local_directory invariant: a `claude` /
// `codex` run started by the user after a Multica task must see the same
// file the user wrote.
func TestCleanupRuntimeConfigPreservesUserContent(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "CLAUDE.md")
const userBefore = "# Repo CLAUDE.md\n\nuser line above\n"
const userAfter = "\nuser line below the block\n"
const userExpected = "# Repo CLAUDE.md\n\nuser line above\n\nuser line below the block\n"
// Inject via the production write path so we exercise the actual
// marker block format, not a hand-rolled approximation.
if err := os.WriteFile(path, []byte(userBefore+userAfter), 0o644); err != nil {
t.Fatalf("seed: %v", err)
}
if err := writeRuntimeConfigFile(path, "brief body"); err != nil {
t.Fatalf("seed brief: %v", err)
}
if err := CleanupRuntimeConfig(dir, "claude"); err != nil {
t.Fatalf("CleanupRuntimeConfig: %v", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read back: %v", err)
}
s := string(got)
if strings.Contains(s, runtimeMarkerBegin) || strings.Contains(s, runtimeMarkerEnd) {
t.Errorf("marker block must be removed, got:\n%s", s)
}
if strings.Contains(s, "brief body") {
t.Errorf("brief body must be removed, got:\n%s", s)
}
if s != userExpected {
t.Errorf("user content must be preserved byte-for-byte\n got:\n%q\nwant:\n%q", s, userExpected)
}
}
// Cleanup removes the file entirely when the marker block was the only
// content — i.e. we created the file from scratch in a directory that had
// no pre-existing CLAUDE.md / AGENTS.md / GEMINI.md.
func TestCleanupRuntimeConfigRemovesFileWhenOnlyBlockRemained(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "CLAUDE.md")
// No seed — writeRuntimeConfigFile creates the file with only the
// marker block inside.
if err := writeRuntimeConfigFile(path, "brief body"); err != nil {
t.Fatalf("seed brief: %v", err)
}
if err := CleanupRuntimeConfig(dir, "claude"); err != nil {
t.Fatalf("CleanupRuntimeConfig: %v", err)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Errorf("expected file to be removed, stat err=%v", err)
}
}
// Cleanup is a no-op when no marker block exists or when the file is
// missing — Cleanup is safe to call defensively from the daemon's defer.
func TestCleanupRuntimeConfigNoOpCases(t *testing.T) {
t.Parallel()
t.Run("missing file", func(t *testing.T) {
t.Parallel()
dir := t.TempDir()
if err := CleanupRuntimeConfig(dir, "claude"); err != nil {
t.Errorf("missing file must be no-op, got: %v", err)
}
// And the directory must remain untouched.
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("readdir: %v", err)
}
if len(entries) != 0 {
t.Errorf("expected dir to remain empty, got: %v", entries)
}
})
t.Run("file without marker block", func(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "CLAUDE.md")
const userContent = "# Repo CLAUDE.md\n\nrules\n"
if err := os.WriteFile(path, []byte(userContent), 0o644); err != nil {
t.Fatalf("seed: %v", err)
}
if err := CleanupRuntimeConfig(dir, "claude"); err != nil {
t.Errorf("no-marker-block file must be no-op, got: %v", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read back: %v", err)
}
if string(got) != userContent {
t.Errorf("file must be untouched\n got:\n%q\nwant:\n%q", string(got), userContent)
}
})
t.Run("unknown provider", func(t *testing.T) {
t.Parallel()
dir := t.TempDir()
// Seed every candidate filename to verify none of them get touched.
for _, name := range []string{"CLAUDE.md", "AGENTS.md", "GEMINI.md"} {
if err := os.WriteFile(filepath.Join(dir, name), []byte("untouched\n"), 0o644); err != nil {
t.Fatalf("seed %s: %v", name, err)
}
}
if err := CleanupRuntimeConfig(dir, "totally-unknown-provider"); err != nil {
t.Errorf("unknown provider must be no-op, got: %v", err)
}
for _, name := range []string{"CLAUDE.md", "AGENTS.md", "GEMINI.md"} {
got, err := os.ReadFile(filepath.Join(dir, name))
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
if string(got) != "untouched\n" {
t.Errorf("unknown provider must not touch %s; got:\n%s", name, string(got))
}
}
})
}
// Cleanup must handle a half-block left by a previous crashed run: begin
// marker present but no end. Otherwise the half-block would survive
// cleanup and pollute the next manual CLI invocation in the same dir.
func TestCleanupRuntimeConfigRemovesMalformedHalfBlock(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "AGENTS.md")
const userTop = "# Repo AGENTS.md\n\nrules\n"
original := userTop + runtimeMarkerBegin + "\nhalf-written brief no end\n"
if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
t.Fatalf("seed: %v", err)
}
if err := CleanupRuntimeConfig(dir, "codex"); err != nil {
t.Fatalf("CleanupRuntimeConfig: %v", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read back: %v", err)
}
s := string(got)
if strings.Contains(s, runtimeMarkerBegin) {
t.Errorf("half-block begin marker must be excised, got:\n%s", s)
}
if strings.Contains(s, "half-written brief no end") {
t.Errorf("half-block body must be excised, got:\n%s", s)
}
if !strings.HasPrefix(s, userTop) {
t.Errorf("user content above the half-block must remain, got:\n%s", s)
}
}
// Cleanup must remove the marker block for every provider's target file,
// using the same provider→filename mapping as InjectRuntimeConfig — so a
// new provider added to one side cannot drift past the other.
func TestCleanupRuntimeConfigByProvider(t *testing.T) {
t.Parallel()
cases := []struct {
provider string
filename string
}{
{"claude", "CLAUDE.md"},
{"codex", "AGENTS.md"},
{"copilot", "AGENTS.md"},
{"opencode", "AGENTS.md"},
{"openclaw", "AGENTS.md"},
{"hermes", "AGENTS.md"},
{"pi", "AGENTS.md"},
{"cursor", "AGENTS.md"},
{"kimi", "AGENTS.md"},
{"kiro", "AGENTS.md"},
{"antigravity", "AGENTS.md"},
{"gemini", "GEMINI.md"},
}
for _, tc := range cases {
tc := tc
t.Run(tc.provider, func(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, tc.filename)
const userContent = "# User file\n\ndon't touch this\n"
if err := os.WriteFile(path, []byte(userContent), 0o644); err != nil {
t.Fatalf("seed: %v", err)
}
// Inject through the production path so cleanup runs against
// the same wire format the agent saw.
if _, err := InjectRuntimeConfig(dir, tc.provider, TaskContextForEnv{
IssueID: "11111111-2222-3333-4444-555555555555",
}); err != nil {
t.Fatalf("InjectRuntimeConfig: %v", err)
}
if err := CleanupRuntimeConfig(dir, tc.provider); err != nil {
t.Fatalf("CleanupRuntimeConfig: %v", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read back: %v", err)
}
s := string(got)
if strings.Contains(s, runtimeMarkerBegin) || strings.Contains(s, runtimeMarkerEnd) {
t.Errorf("[%s] marker block must be removed from %s, got:\n%s", tc.provider, tc.filename, s)
}
if s != userContent {
t.Errorf("[%s] user content in %s must be preserved byte-for-byte\n got:\n%q\nwant:\n%q", tc.provider, tc.filename, s, userContent)
}
})
}
}
// Inject → Cleanup → manual edit → Inject must converge back to the
// pre-injection state on the next Cleanup. This is the end-to-end
// regression that locks in: the user's repo is byte-identical to what
// they had before the task, every task cycle.
func TestInjectThenCleanupRoundTrip(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "CLAUDE.md")
const userContent = "# User-authored CLAUDE.md\n\n- rule A\n- rule B\n"
if err := os.WriteFile(path, []byte(userContent), 0o644); err != nil {
t.Fatalf("seed: %v", err)
}
// Two full inject→cleanup cycles — covers both the "first task on a
// fresh user file" path and the "subsequent task hits a clean file
// again" path.
for i := 0; i < 2; i++ {
if _, err := InjectRuntimeConfig(dir, "claude", TaskContextForEnv{
IssueID: "11111111-2222-3333-4444-555555555555",
}); err != nil {
t.Fatalf("iter %d inject: %v", i, err)
}
if err := CleanupRuntimeConfig(dir, "claude"); err != nil {
t.Fatalf("iter %d cleanup: %v", i, err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("iter %d read back: %v", i, err)
}
if string(got) != userContent {
t.Errorf("iter %d: user file must be byte-identical to pre-injection state\n got:\n%q\nwant:\n%q", i, string(got), userContent)
}
}
}
// Byte-exact boundary coverage flagged in PR #3438 review (Elon): the
// previous cleanup used TrimRight + "\n" and TrimSpace-based file removal,
// which created a real diff in three boundary cases. The table walks each
// one through a full inject→cleanup cycle and asserts the file ends up
// byte-identical (or, for missing-file, that it stays missing).
func TestInjectThenCleanupRoundTripByteExactBoundaries(t *testing.T) {
t.Parallel()
cases := []struct {
name string
// seed describes the pre-inject filesystem state. When seedExists
// is false the file is absent; when true the file is created with
// seedContent (which may be empty / whitespace-only / arbitrary
// bytes).
seedExists bool
seedContent string
}{
{
name: "file missing — Inject creates, Cleanup removes",
seedExists: false,
seedContent: "",
},
{
name: "pre-existing empty file (zero bytes)",
seedExists: true,
seedContent: "",
},
{
name: "pre-existing whitespace-only file",
seedExists: true,
seedContent: " \n",
},
{
name: "no trailing newline",
seedExists: true,
seedContent: "rules",
},
{
name: "one trailing newline (the common markdown shape)",
seedExists: true,
seedContent: "# Rules\n\nbody\n",
},
{
name: "two trailing newlines",
seedExists: true,
seedContent: "rules\n\n",
},
{
name: "many trailing newlines",
seedExists: true,
seedContent: "rules\n\n\n\n",
},
{
name: "CRLF line endings",
seedExists: true,
seedContent: "rule A\r\nrule B\r\n",
},
{
name: "no final newline AND embedded blank lines",
seedExists: true,
seedContent: "para 1\n\npara 2\n\npara 3",
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "CLAUDE.md")
if tc.seedExists {
if err := os.WriteFile(path, []byte(tc.seedContent), 0o644); err != nil {
t.Fatalf("seed: %v", err)
}
}
// Two cycles to cover both "first inject hits user file" and
// "subsequent inject hits a cleaned file" paths.
for i := 0; i < 2; i++ {
if _, err := InjectRuntimeConfig(dir, "claude", TaskContextForEnv{
IssueID: "11111111-2222-3333-4444-555555555555",
}); err != nil {
t.Fatalf("iter %d inject: %v", i, err)
}
if err := CleanupRuntimeConfig(dir, "claude"); err != nil {
t.Fatalf("iter %d cleanup: %v", i, err)
}
if !tc.seedExists {
// Missing file must remain missing after the cycle so
// the user's directory listing is also byte-identical
// (no zero-byte stub left behind).
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Errorf("iter %d: file must remain missing, stat err=%v", i, err)
}
continue
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("iter %d read back: %v", i, err)
}
if string(got) != tc.seedContent {
t.Errorf("iter %d: file must be byte-identical to seed\n got: %q\n want: %q", i, string(got), tc.seedContent)
}
}
})
}
}
// Idempotency across the byte-exact boundaries: when a second Inject runs
// against a file that already carries a marker block (the "replace in
// place" branch), the surrounding bytes must stay untouched and the
// subsequent Cleanup must still restore the user's original file
// byte-exactly. This guards against a regression where the replace path
// would re-normalise pre/post bytes the way the old cleanup did.
func TestInjectReplaceThenCleanupRestoresByteExact(t *testing.T) {
t.Parallel()
cases := []struct {
name string
seedContent string
}{
{name: "no trailing newline", seedContent: "rules"},
{name: "two trailing newlines", seedContent: "rules\n\n"},
{name: "empty file", seedContent: ""},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "CLAUDE.md")
if err := os.WriteFile(path, []byte(tc.seedContent), 0o644); err != nil {
t.Fatalf("seed: %v", err)
}
// First inject — append path.
if _, err := InjectRuntimeConfig(dir, "claude", TaskContextForEnv{
IssueID: "11111111-2222-3333-4444-555555555555",
}); err != nil {
t.Fatalf("first inject: %v", err)
}
// Second inject — replace-in-place path.
if _, err := InjectRuntimeConfig(dir, "claude", TaskContextForEnv{
IssueID: "11111111-2222-3333-4444-555555555555",
}); err != nil {
t.Fatalf("second inject: %v", err)
}
if err := CleanupRuntimeConfig(dir, "claude"); err != nil {
t.Fatalf("cleanup: %v", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read back: %v", err)
}
if string(got) != tc.seedContent {
t.Errorf("file must be byte-identical to seed after replace+cleanup\n got: %q\n want: %q", string(got), tc.seedContent)
}
})
}
}
// The fixed managed separator is the invariant that makes byte-exact
// cleanup possible. This test pins it: writeRuntimeConfigFile must
// produce exactly `<user-bytes><\n\n><marker-block>` for ANY non-empty
// or empty pre-existing file, with no trailing-newline normalisation.
func TestWriteRuntimeConfigFileAlwaysInsertsFixedManagedSeparator(t *testing.T) {
t.Parallel()
for _, seed := range []string{"", "rules", "rules\n", "rules\n\n", "rules\n\n\n\n"} {
seed := seed
t.Run(fmt.Sprintf("seed=%q", seed), func(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "CLAUDE.md")
if err := os.WriteFile(path, []byte(seed), 0o644); err != nil {
t.Fatalf("seed: %v", err)
}
if err := writeRuntimeConfigFile(path, "brief body"); err != nil {
t.Fatalf("write: %v", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read back: %v", err)
}
s := string(got)
// The seed must appear verbatim at the start of the file —
// no extra newline appended, no trailing newline trimmed.
if !strings.HasPrefix(s, seed) {
t.Errorf("seed bytes must survive verbatim at the start of the file\n got: %q\n seed: %q", s, seed)
}
// Immediately after the seed we must see the fixed managed
// separator, then the begin marker.
markerStart := len(seed) + len(runtimeManagedSeparator)
if len(s) < markerStart+len(runtimeMarkerBegin) {
t.Fatalf("file shorter than expected layout\n got: %q", s)
}
if got, want := s[len(seed):markerStart], runtimeManagedSeparator; got != want {
t.Errorf("expected managed separator %q immediately after seed, got %q", want, got)
}
if got, want := s[markerStart:markerStart+len(runtimeMarkerBegin)], runtimeMarkerBegin; got != want {
t.Errorf("expected begin marker after managed separator, got %q", got)
}
})
}
}