Skip to content

Commit 0cc0619

Browse files
lwgrayclaude
andauthored
fix(#696): git-delta scoping + file manifest for validation evidence (#697)
* fix(#696): scope validation evidence to agent git delta instead of full worktree Validation was loading the entire merged codebase (47+ files, 211k tokens) for each agent completion check. As parallel agents merged their work the token cost grew unboundedly, eventually exceeding the model context limit and blocking agent completions. Fix: at task assignment time, snapshot `git rev-parse HEAD` into `TaskAssignment.baseline_commit`. At validation time, `WorkAnalyzer` runs `git diff <baseline>..HEAD --name-only` in the agent's worktree to get only the files that agent changed, then passes them as an allowlist to `_discover_source_files`. Falls back to the full-scan path when git is unavailable or the baseline is missing. Measured reduction: 122k tokens → ~3k tokens per validation call (~25×). Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> * fix(#696): add file manifest to validation prompt — fixes false missing-file reports The git-delta approach (scoped source file content) caused the LLM to report packaging files like pyproject.toml as missing because .toml is excluded from SOURCE_EXTENSIONS. The files existed but were invisible. Fix: collect a full file manifest (names only, no content reads) from the agent's worktree via os.walk and inject it into the validation prompt before the source file content. The LLM can now verify file existence from the manifest even when a file's extension is excluded from the content scan. The prompt instructs the LLM: files in the manifest exist; only report a file as missing if it appears in neither the manifest nor the source files section. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> * fix(#696): address Codex P2s — empty-delta full-scan fallback + symlink guard Two P2 review findings on PR #697's validation git-delta scoping: - _get_git_delta_files: a successful-but-empty git diff now returns [] instead of None, so an agent that committed nothing surfaces the "no source files" failure instead of falling back to a full merged- worktree scan (the exact #696 mis-scoping this PR removes). - _discover_source_files: resolve each delta path and skip anything outside project_root, preventing a source-named symlink (leak.py -> external secret) from leaking external file content into the prompt. Adds 4 regression tests. Full work_analyzer module (54) + mypy + pre-commit clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
1 parent f7b338d commit 0cc0619

5 files changed

Lines changed: 762 additions & 9 deletions

File tree

src/ai/validation/validation_models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ class WorkEvidence:
8181
design_artifacts: list[dict[str, Any]]
8282
decisions: list[dict[str, Any]]
8383
project_root: str
84+
file_manifest: list[str] = field(default_factory=list)
8485
collection_time: datetime = field(default_factory=lambda: datetime.utcnow())
8586

8687
def has_source_files(self) -> bool:

src/ai/validation/work_analyzer.py

Lines changed: 238 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import logging
1111
import os
1212
import re
13+
import subprocess
1314
from datetime import datetime
1415
from pathlib import Path
1516
from typing import Any, Dict, List, Optional
@@ -147,18 +148,33 @@ async def gather_evidence(
147148
project_root = self._get_project_root(task, state, agent_id=agent_id)
148149
logger.debug(f"Project root: {project_root}")
149150

150-
# 2. Discover source files by scanning project_root
151-
source_files = self._discover_source_files(project_root)
151+
# 2. Scope evidence to git delta when a baseline_commit is available.
152+
# This prevents the token explosion caused by loading all merged-agent
153+
# files when validating a single agent's work (issue #696).
154+
allowed_files = self._get_git_delta_files(project_root, state, agent_id)
155+
156+
# 3. Discover source files — scoped to delta or full scan as fallback
157+
source_files = self._discover_source_files(
158+
project_root, allowed_files=allowed_files
159+
)
152160
logger.info(
153161
f"Discovered {len(source_files)} source files "
154162
f"({sum(f.size_bytes for f in source_files)} total bytes)"
155163
)
156164

157-
# 3. Get design artifacts from state
165+
# 4. Collect full file manifest (names only, no content reads).
166+
# Injected into the prompt so the LLM knows which files exist
167+
# even when their extension is excluded from SOURCE_EXTENSIONS
168+
# (e.g. pyproject.toml, Makefile). Prevents false "file missing"
169+
# reports when the delta scan limits content to .py/.js files.
170+
file_manifest = self._collect_file_manifest(project_root)
171+
logger.info(f"File manifest: {len(file_manifest)} files in project")
172+
173+
# 5. Get design artifacts from state
158174
design_artifacts = state.task_artifacts.get(task.id, []).copy()
159175
logger.debug(f"Found {len(design_artifacts)} design artifacts")
160176

161-
# 4. Get decisions from get_task_context
177+
# 6. Get decisions from get_task_context
162178
decisions = await self._get_decisions(task, state)
163179
logger.debug(f"Retrieved {len(decisions)} architectural decisions")
164180

@@ -167,6 +183,7 @@ async def gather_evidence(
167183
design_artifacts=design_artifacts,
168184
decisions=decisions,
169185
project_root=project_root,
186+
file_manifest=file_manifest,
170187
collection_time=datetime.utcnow(),
171188
)
172189

@@ -451,29 +468,223 @@ def _get_project_root(
451468
),
452469
)
453470

454-
def _discover_source_files(self, project_root: str) -> list[SourceFile]:
471+
def _get_git_delta_files(
472+
self, project_root: str, state: Any, agent_id: str | None
473+
) -> list[str] | None:
474+
"""Return the relative file paths changed by this agent since their baseline.
475+
476+
Uses ``git diff <baseline_commit>..HEAD --name-only`` to isolate only
477+
the files the agent wrote, preventing the entire merged codebase from
478+
being loaded into the validation prompt (issue #696).
479+
480+
Parameters
481+
----------
482+
project_root : str
483+
Absolute path to the agent's worktree (or main repo as fallback)
484+
state : Any
485+
Marcus server state — used to look up the agent's TaskAssignment
486+
agent_id : str | None
487+
Agent performing the task
488+
489+
Returns
490+
-------
491+
list[str] | None
492+
Relative file paths in the delta. An empty list means the diff
493+
succeeded but the agent changed no files — the caller must treat
494+
this as "no source-file evidence" (a correct completion failure),
495+
NOT as a reason to fall back to the full worktree scan. None is
496+
returned only when the delta genuinely cannot be computed (no
497+
baseline, git error), where the caller falls back to a full scan.
498+
"""
499+
baseline_commit: str | None = None
500+
if agent_id and hasattr(state, "agent_tasks"):
501+
assignment = state.agent_tasks.get(agent_id)
502+
if assignment:
503+
baseline_commit = getattr(assignment, "baseline_commit", None)
504+
505+
if not baseline_commit:
506+
logger.debug(
507+
f"No baseline_commit for agent {agent_id} — using full worktree scan"
508+
)
509+
return None
510+
511+
try:
512+
result = subprocess.run(
513+
["git", "diff", f"{baseline_commit}..HEAD", "--name-only"],
514+
capture_output=True,
515+
text=True,
516+
cwd=project_root,
517+
timeout=10,
518+
)
519+
if result.returncode != 0:
520+
logger.warning(
521+
f"git diff failed (exit {result.returncode}): "
522+
f"{result.stderr.strip()} — falling back to full worktree scan"
523+
)
524+
return None
525+
526+
files = [f.strip() for f in result.stdout.splitlines() if f.strip()]
527+
logger.info(
528+
f"Git delta for agent {agent_id}: {len(files)} files changed "
529+
f"since {baseline_commit[:8]}"
530+
)
531+
# A successful-but-empty diff means the agent committed no changes.
532+
# Return the empty list (not None) so validation surfaces the
533+
# "no source files" failure instead of scanning the entire merged
534+
# worktree and grading the agent against other agents' work.
535+
return files
536+
537+
except Exception as exc:
538+
logger.warning(
539+
f"Failed to compute git delta for agent {agent_id}: {exc}"
540+
" — falling back to full worktree scan"
541+
)
542+
return None
543+
544+
def _collect_file_manifest(self, project_root: str) -> list[str]:
545+
"""Return relative paths of every file in project_root (names only).
546+
547+
This is the cheap half of the git-delta approach (issue #696): we
548+
scan the full worktree for filenames — no content reads — and inject
549+
the list into the validation prompt so the LLM knows which files
550+
*exist* even when their extension is excluded from SOURCE_EXTENSIONS
551+
(e.g. ``pyproject.toml``, ``Makefile``, ``.flake8``).
552+
553+
Parameters
554+
----------
555+
project_root : str
556+
Absolute path to the project directory to scan
557+
558+
Returns
559+
-------
560+
list[str]
561+
Sorted relative file paths (POSIX separators)
562+
"""
563+
manifest: list[str] = []
564+
project_path = Path(project_root).resolve()
565+
566+
for root, dirs, files in os.walk(project_root):
567+
dirs[:] = [d for d in dirs if d not in self.EXCLUDE_DIRS]
568+
for file in files:
569+
file_path = Path(root) / file
570+
try:
571+
resolved = file_path.resolve()
572+
if not resolved.is_relative_to(project_path):
573+
continue
574+
manifest.append(str(resolved.relative_to(project_path)))
575+
except (ValueError, OSError):
576+
continue
577+
578+
return sorted(manifest)
579+
580+
def _discover_source_files(
581+
self, project_root: str, allowed_files: list[str] | None = None
582+
) -> list[SourceFile]:
455583
"""Discover source files by scanning project_root directory.
456584
457585
Parameters
458586
----------
459587
project_root : str
460588
Root directory to scan
589+
allowed_files : list[str] | None
590+
When provided, only load these relative paths (git-delta scoping).
591+
When None, walk the entire project_root (legacy behaviour).
461592
462593
Returns
463594
-------
464595
list[SourceFile]
465596
Discovered source files with content
466597
"""
467-
logger.debug(
468-
f"Scanning {project_root} for source files (excluding {self.EXCLUDE_DIRS})"
469-
)
470598
source_files: list[SourceFile] = []
471-
project_path = Path(project_root).resolve() # Resolve to absolute path
599+
project_path = Path(project_root).resolve()
472600

473601
# Track total content size to prevent memory exhaustion
474602
total_content_bytes = 0
475603
MAX_TOTAL_CONTENT = 10_000_000 # 10MB total limit across all files
476604

605+
if allowed_files is not None:
606+
# Git-delta mode: only load the specific files the agent changed
607+
logger.debug(
608+
f"Git-delta mode: loading {len(allowed_files)} files "
609+
f"from {project_root}"
610+
)
611+
delta_pairs = []
612+
for rel in allowed_files:
613+
abs_p = project_path / rel
614+
try:
615+
resolved = abs_p.resolve()
616+
except (OSError, RuntimeError):
617+
logger.debug(f"Skipping unresolvable delta file: {rel}")
618+
continue
619+
# Guard against a source-named symlink escaping the worktree
620+
# (e.g. leak.py -> /etc/secret). Without this, validation would
621+
# follow the link and send external file content to the LLM.
622+
# Mirrors the resolve + containment check in
623+
# _collect_file_manifest / the legacy full scan.
624+
if not resolved.is_relative_to(project_path):
625+
logger.warning(
626+
"Skipping delta file outside project root "
627+
f"(possible symlink escape): {rel}"
628+
)
629+
continue
630+
if resolved.exists():
631+
delta_pairs.append((resolved, resolved))
632+
else:
633+
logger.debug(f"Skipping missing delta file: {rel}")
634+
635+
for file_path, resolved_path in delta_pairs:
636+
if resolved_path.suffix not in self.SOURCE_EXTENSIONS:
637+
continue
638+
try:
639+
stat = resolved_path.stat()
640+
size_bytes = stat.st_size
641+
modified_time = datetime.fromtimestamp(stat.st_mtime)
642+
643+
if size_bytes == 0:
644+
content = ""
645+
elif total_content_bytes + size_bytes > MAX_TOTAL_CONTENT:
646+
logger.warning(
647+
f"Skipping {resolved_path} — total content limit "
648+
f"({MAX_TOTAL_CONTENT} bytes) would be exceeded"
649+
)
650+
continue
651+
elif size_bytes > 1_000_000:
652+
content = resolved_path.read_text(errors="ignore")[:100000]
653+
content += "\n\n[FILE TRUNCATED - Too large for validation]"
654+
total_content_bytes += len(content)
655+
else:
656+
content = resolved_path.read_text(errors="ignore")
657+
total_content_bytes += len(content)
658+
659+
has_placeholders = any(
660+
pattern in content for pattern in self.PLACEHOLDER_PATTERNS
661+
)
662+
source_files.append(
663+
SourceFile(
664+
path=str(resolved_path),
665+
relative_path=str(resolved_path.relative_to(project_path)),
666+
size_bytes=size_bytes,
667+
content=content,
668+
has_placeholders=has_placeholders,
669+
extension=resolved_path.suffix,
670+
modified_time=modified_time,
671+
)
672+
)
673+
except Exception as exc:
674+
logger.warning(f"Failed to read {resolved_path}: {exc}")
675+
continue
676+
677+
logger.info(
678+
f"Loaded {len(source_files)} delta files, "
679+
f"total {total_content_bytes:,} bytes"
680+
)
681+
return source_files
682+
683+
# Legacy full-worktree mode (no baseline_commit available)
684+
logger.debug(
685+
f"Full-scan mode: walking {project_root} "
686+
f"(excluding {self.EXCLUDE_DIRS})"
687+
)
477688
for root, dirs, files in os.walk(project_root):
478689
# Filter out excluded directories (in-place modification)
479690
dirs[:] = [d for d in dirs if d not in self.EXCLUDE_DIRS]
@@ -1183,6 +1394,24 @@ def _build_validation_prompt(self, task: Any, evidence: WorkEvidence) -> str:
11831394
for i, criterion in enumerate(criteria, 1):
11841395
prompt_parts.append(f"{i}. {criterion}")
11851396

1397+
# Add file manifest — existence proof for every file in the project.
1398+
# This lets the LLM verify "does pyproject.toml exist?" without
1399+
# needing its content. Files in the manifest but absent from the
1400+
# source file section below exist but aren't readable (wrong
1401+
# extension or excluded by SOURCE_EXTENSIONS) — the LLM must NOT
1402+
# report them as missing. Only files absent from BOTH the manifest
1403+
# AND the source files section are truly missing.
1404+
if evidence.file_manifest:
1405+
prompt_parts.append("\n\nPROJECT FILE MANIFEST (every file that exists):")
1406+
for path in evidence.file_manifest:
1407+
prompt_parts.append(f" {path}")
1408+
prompt_parts.append(
1409+
"\nNOTE: Files listed above exist in the project. "
1410+
"If a file appears in the manifest but not in the source "
1411+
"files section below, it exists but its content is not "
1412+
"shown. Do NOT report manifest-listed files as missing."
1413+
)
1414+
11861415
# Add discovered source files with full content (no truncation).
11871416
# Previously content was truncated to 8KB per file which caused
11881417
# LLM hallucinations — the validator had to infer code it

src/core/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,7 @@ class TaskAssignment:
448448
due_date: Optional[datetime]
449449
workspace_path: Optional[str] = None
450450
forbidden_paths: List[str] = field(default_factory=list)
451+
baseline_commit: Optional[str] = None
451452

452453

453454
@dataclass

0 commit comments

Comments
 (0)