Files
multica/server/internal/daemon/execenv/git.go
T
d6d1996210 refactor(daemon): retire the generic no-reply rule — ordinary agents always post one comment (MUL-5442) (#6493)
* refactor(daemon): retire the generic no-reply rule — ordinary agents always post one comment (MUL-5442)

Owner decision (MUL-5442, 2026-08-06): the no-reply / silent-exit
behavior is squad-leader-only by design. Mechanically it always was —
computeCommentAgentTriggers routes agent-authored comments only via
explicit @mentions (the sole implicit wake is the squad-leader path), so
loop prevention lives in the Mentions discipline, and the generic rule
only suppressed one terminal noise comment on an already-enqueued run
while giving every ordinary agent a legal silent-exit path that risks
silently dropped results.

- prompt.go: delete the per-turn agent-trigger warning block entirely
  (−728B per agent-triggered turn; supersedes #6484's compression).
- runtime_config_sections.go: delete the Reply-mode reply-warranted
  bullet and step 5's conditional pointer; the reply bullets become
  unconditional (−472B one-time in the brief). Squad-leader no_action
  bullets and the Mentions section are untouched.
- reply_instructions.go: 'If you decide to reply, post it' → 'Post your
  reply' (−16B per Reply turn); the leader's no_action block states its
  own exception.
- Tests: retired pins are converted to negative guards with ledger
  comments (brief and per-turn both must NOT carry the retired
  apparatus); attribution, member-leak, and no-MUST-respond guards stay.

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

* fix(daemon): scope the reply imperative with the leader's no_action carve-out, close the pin-ledger gaps (MUL-5442)

Review catches by Elon on #6493, both accepted:

1. The leader's only silent path was contradicted later in the same
   prompt: the no_action rule said EXIT without commenting, then the
   cookbook and the brief's mandatory bullet said post unconditionally.
   Both now branch on squad leadership — agent configuration, never
   per-run state, so each agent's brief stays byte-identical across
   turns and issues (cache-safe): leaders get 'Unless your outcome is
   no_action, post your reply…' in the cookbook and 'Unless your outcome
   is no_action (Squad leader rule above), posting your reply as a
   comment is mandatory' in the brief; ordinary agents keep the
   unconditional wording byte-for-byte. New tests render the COMPLETE
   leader prompt and leader brief and assert the carve-out is present
   AND the unconditional capital-P imperative is absent — the scope
   relation, not two isolated Contains.

2. The retirement ledger named five brief pins but guarded only two,
   and the per-turn 'If you decide to reply' had no guard. Added the
   four missing negative guards in ordinary-agent scope (the leader's
   'DO NOT post any comment' matches none of them) plus a leader-leak
   guard on the ordinary brief.

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

* fix(daemon): scope the multi-thread fan-out under the leader's no_action carve-out (MUL-5442)

Ports the per-turn half of #6493 review rounds 2-3 into the slimmed PR
(the brief role-independence refactor moves to its own sub-issue/PR per
owner decision): BuildMultiThreadCommentReplyInstructions takes the
leader flag, and the leader lead sentence scopes the ENTIRE fan-out
block — every later obligation sits under the 'Otherwise' — with
index-order test guards. Ordinary fan-out output is byte-unchanged.

Also repairs a truncated TestFreshSessionMayHelp from the earlier
rebase conflict resolution (missing closing braces).

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

* docs: correct the taskIsSquadLeader comment — leadership is a per-task role (MUL-5442)

Review nit on #6493: the helper's comment still claimed leadership is
'agent configuration, never per-run state' — the disproven premise that
drove the earlier review rounds. State the per-task-role fact and point
at MUL-5811 for the role-independence work.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-06 16:12:22 +08:00

211 lines
6.2 KiB
Go

package execenv
import (
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
)
// detectGitRepo checks if dir is inside a git repository (regular or bare).
// Returns the git root path and true if found.
func detectGitRepo(dir string) (string, bool) {
// Try regular repo first.
cmd := exec.Command("git", "-C", dir, "rev-parse", "--show-toplevel")
if out, err := cmd.Output(); err == nil {
return strings.TrimSpace(string(out)), true
}
// Try bare repo: git-dir is "." for bare repos when -C points at the repo.
cmd = exec.Command("git", "-C", dir, "rev-parse", "--is-bare-repository")
if out, err := cmd.Output(); err == nil && strings.TrimSpace(string(out)) == "true" {
return dir, true
}
return "", false
}
// fetchOrigin runs `git fetch origin` to ensure the local repo has the latest remote refs.
func fetchOrigin(gitRoot string) error {
cmd := exec.Command("git", "-C", gitRoot, "fetch", "origin")
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("git fetch origin: %s: %w", strings.TrimSpace(string(out)), err)
}
return nil
}
// getRemoteDefaultBranch returns "origin/<branch>" for the remote's default branch.
// Falls back to "origin/main", then "HEAD".
func getRemoteDefaultBranch(gitRoot string) string {
// Try symbolic-ref of origin/HEAD (set by `git clone` or `git remote set-head`).
cmd := exec.Command("git", "-C", gitRoot, "symbolic-ref", "refs/remotes/origin/HEAD")
if out, err := cmd.Output(); err == nil {
ref := strings.TrimSpace(string(out))
// ref looks like "refs/remotes/origin/main" — return "origin/main".
if strings.HasPrefix(ref, "refs/remotes/") {
return strings.TrimPrefix(ref, "refs/remotes/")
}
return ref
}
// Fallback: check if origin/main exists.
cmd = exec.Command("git", "-C", gitRoot, "rev-parse", "--verify", "origin/main")
if err := cmd.Run(); err == nil {
return "origin/main"
}
// Fallback: check if origin/master exists.
cmd = exec.Command("git", "-C", gitRoot, "rev-parse", "--verify", "origin/master")
if err := cmd.Run(); err == nil {
return "origin/master"
}
return "HEAD"
}
// setupGitWorktree creates a git worktree at worktreePath with a new branch.
func setupGitWorktree(gitRoot, worktreePath, branchName, baseRef string) error {
// Remove the workdir created by caller — git worktree add needs to create it.
if err := os.Remove(worktreePath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove placeholder workdir: %w", err)
}
err := runGitWorktreeAdd(gitRoot, worktreePath, branchName, baseRef)
if err != nil && strings.Contains(err.Error(), "already exists") {
// Branch name collision: append timestamp and retry once.
branchName = fmt.Sprintf("%s-%d", branchName, time.Now().Unix())
err = runGitWorktreeAdd(gitRoot, worktreePath, branchName, baseRef)
}
return err
}
func runGitWorktreeAdd(gitRoot, worktreePath, branchName, baseRef string) error {
cmd := exec.Command("git", "-C", gitRoot, "worktree", "add", "-b", branchName, worktreePath, baseRef)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("git worktree add: %s: %w", strings.TrimSpace(string(out)), err)
}
return nil
}
// removeGitWorktree removes a worktree and its branch. Best-effort: logs errors.
func removeGitWorktree(gitRoot, worktreePath, branchName string, logger *slog.Logger) {
// Remove the worktree.
cmd := exec.Command("git", "-C", gitRoot, "worktree", "remove", "--force", worktreePath)
if out, err := cmd.CombinedOutput(); err != nil {
logger.Warn("execenv: git worktree remove failed", "output", strings.TrimSpace(string(out)), "error", err)
}
// Delete the branch (best-effort).
if branchName != "" {
cmd = exec.Command("git", "-C", gitRoot, "branch", "-D", branchName)
if out, err := cmd.CombinedOutput(); err != nil {
logger.Warn("execenv: git branch delete failed", "branch", branchName, "output", strings.TrimSpace(string(out)), "error", err)
}
}
}
// excludeFromGit adds a pattern to the worktree's .git/info/exclude file.
func excludeFromGit(worktreePath, pattern string) error {
// Resolve the actual git dir for this worktree.
cmd := exec.Command("git", "-C", worktreePath, "rev-parse", "--git-dir")
out, err := cmd.Output()
if err != nil {
return fmt.Errorf("resolve git dir: %w", err)
}
gitDir := strings.TrimSpace(string(out))
if !filepath.IsAbs(gitDir) {
gitDir = filepath.Join(worktreePath, gitDir)
}
excludePath := filepath.Join(gitDir, "info", "exclude")
// Ensure the info directory exists.
if err := os.MkdirAll(filepath.Dir(excludePath), 0o755); err != nil {
return fmt.Errorf("create info dir: %w", err)
}
// Check if pattern is already present.
existing, _ := os.ReadFile(excludePath)
if strings.Contains(string(existing), pattern) {
return nil
}
f, err := os.OpenFile(excludePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return fmt.Errorf("open exclude file: %w", err)
}
defer f.Close()
if _, err := fmt.Fprintf(f, "\n%s\n", pattern); err != nil {
return fmt.Errorf("write exclude pattern: %w", err)
}
return nil
}
// repoNameFromURL extracts a short directory name from a git remote URL.
// e.g. "https://github.com/org/my-repo.git" → "my-repo"
func repoNameFromURL(url string) string {
// Strip trailing slashes and .git suffix.
url = strings.TrimRight(url, "/")
url = strings.TrimSuffix(url, ".git")
// Take the last path segment.
if i := strings.LastIndex(url, "/"); i >= 0 {
url = url[i+1:]
}
// Also handle SSH-style "host:org/repo".
if i := strings.LastIndex(url, ":"); i >= 0 {
url = url[i+1:]
if j := strings.LastIndex(url, "/"); j >= 0 {
url = url[j+1:]
}
}
name := strings.TrimSpace(url)
if name == "" {
return "repo"
}
return name
}
// shortID returns the first 8 characters of a UUID string (dashes stripped).
func shortID(uuid string) string {
s := strings.ReplaceAll(uuid, "-", "")
if len(s) > 8 {
return s[:8]
}
return s
}
var nonAlphanumeric = regexp.MustCompile(`[^a-z0-9]+`)
// sanitizeName produces a git-branch-safe name from a human-readable string.
func sanitizeName(name string) string {
s := strings.ToLower(strings.TrimSpace(name))
s = nonAlphanumeric.ReplaceAllString(s, "-")
s = strings.Trim(s, "-")
if len(s) > 30 {
s = s[:30]
s = strings.TrimRight(s, "-")
}
if s == "" {
s = "agent"
}
return s
}