Skip to content

Commit 4d43041

Browse files
jsbattigclaude
andcommitted
feat: Pass 1 canary file-write test for fast permission failure detection (v9.3.85)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e50fc6c commit 4d43041

5 files changed

Lines changed: 185 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## v9.3.85
9+
10+
### Enhancements
11+
12+
- feat: Add canary file-write test to Pass 1 dependency map synthesis prompt. Before Claude spends cycles analyzing 125+ repos, it now runs a quick write/delete test on the target directory as Step 0. If the canary fails (OS permission denied or Claude CLI permission restricted), the process bails immediately with a diagnostic RuntimeError instead of wasting 50+ turns attempting analysis and retrying different write methods. Saves significant time and API tokens when permission issues exist.
13+
814
## v9.3.84
915

1016
### Bug Fixes

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
AI-powered semantic code search for your codebase. Find code by meaning, not just keywords.
44

5-
**Version 9.3.84** - [Changelog](CHANGELOG.md) | [Migration Guide](docs/migration-to-v8.md) | [Architecture](docs/architecture.md)
5+
**Version 9.3.85** - [Changelog](CHANGELOG.md) | [Migration Guide](docs/migration-to-v8.md) | [Architecture](docs/architecture.md)
66

77
## Quick Navigation
88

src/code_indexer/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,5 @@
66
HNSW graph indexing (O(log N) complexity).
77
"""
88

9-
__version__ = "9.3.84"
9+
__version__ = "9.3.85"
1010
__author__ = "Seba Battig"

src/code_indexer/global_repos/dependency_map_analyzer.py

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ def run_pass_1_synthesis(
183183
# If staging_dir is not under golden_repos_root (e.g. in tests), use absolute
184184
pass1_file_rel = pass1_file
185185
pass1_file_abs = str(pass1_file)
186+
staging_dir_abs = str(staging_dir)
186187

187188
# Build synthesis prompt — output format + file instructions FIRST (primacy/recency)
188189
prompt = "# Domain Synthesis Task\n\n"
@@ -200,15 +201,25 @@ def run_pass_1_synthesis(
200201
prompt += '"evidence": "Brief justification referencing actual files/patterns observed"}\n'
201202
prompt += "]\n\n"
202203
prompt += "### File Output Instructions\n\n"
203-
prompt += "1. Write the JSON array to this file path:\n"
204+
prompt += "**STEP 0 — MANDATORY CANARY TEST (do this FIRST, before any analysis):**\n"
205+
prompt += "Before doing ANY analysis work, test that you can write files by running:\n"
206+
prompt += "```\n"
207+
prompt += f"echo 'canary' > {pass1_file_abs}.canary && rm {pass1_file_abs}.canary && echo 'CANARY_OK'\n"
208+
prompt += "```\n"
209+
prompt += "- If you see `CANARY_OK`: proceed with analysis and file writing normally.\n"
210+
prompt += "- If the write FAILS for ANY reason: STOP IMMEDIATELY. Do NOT attempt analysis. "
211+
prompt += "Output ONLY this exact line to stdout:\n"
212+
prompt += f" `CANARY_FAIL: Cannot write to {staging_dir_abs} — [reason: OS permission denied | Claude permission denied | other]`\n"
213+
prompt += " Then exit. Do NOT retry with other write methods. Do NOT proceed with analysis.\n\n"
214+
prompt += "**STEP 1** — Write the JSON array to this file path:\n"
204215
prompt += f" - Relative from your cwd: `./{pass1_file_rel}`\n"
205216
prompt += f" - Absolute path: `{pass1_file_abs}`\n\n"
206-
prompt += "2. Validate the file with:\n"
217+
prompt += "**STEP 2** — Validate the file with:\n"
207218
prompt += " ```\n"
208219
prompt += f" python3 -m json.tool {pass1_file_abs}\n"
209220
prompt += " ```\n\n"
210-
prompt += "3. If validation fails, fix the JSON errors and re-validate until it passes.\n\n"
211-
prompt += "4. PREFERRED: Write to the file above. FALLBACK: If file writing is blocked by permissions, output ONLY the raw JSON array to stdout (no explanation, no commentary).\n\n"
221+
prompt += "**STEP 3** — If validation fails, fix the JSON errors and re-validate until it passes.\n\n"
222+
prompt += "**FALLBACK**: If file writing worked in the canary test but fails later during the actual write, output ONLY the raw JSON array to stdout (no explanation, no commentary).\n\n"
212223

213224
# ── REPOSITORY DESCRIPTIONS (after file instructions) ──
214225
prompt += "## Repository Descriptions\n\n"
@@ -307,6 +318,20 @@ def run_pass_1_synthesis(
307318
) # Pass 1 uses full timeout (heaviest phase: explores all repos)
308319
result = self._invoke_claude_cli(prompt, timeout, max_turns, allowed_tools=None, dangerously_skip_permissions=True)
309320

321+
# ── Canary failure fast-path ──
322+
if "CANARY_FAIL" in result:
323+
canary_msg = result.strip()
324+
# Extract just the CANARY_FAIL line
325+
for line in canary_msg.split("\n"):
326+
if "CANARY_FAIL" in line:
327+
canary_msg = line.strip()
328+
break
329+
raise RuntimeError(
330+
f"Pass 1 file-write canary test failed: {canary_msg}. "
331+
f"Claude CLI cannot write to {staging_dir_abs}. "
332+
f"Check --dangerously-skip-permissions flag and OS file permissions."
333+
)
334+
310335
# ── File-based output: primary path (Story #349) ──
311336
logger.debug(f"Pass 1 raw output length: {len(result)} chars")
312337
domain_list = None

tests/unit/global_repos/test_pass1_file_output.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -555,3 +555,151 @@ def fake_invoke(prompt, timeout, max_turns, allowed_tools=None, **kwargs):
555555

556556
assert call_count[0] == 2
557557
assert len(result) >= 1
558+
559+
560+
# ─── Canary file write test ───────────────────────────────────────────────────
561+
562+
563+
class TestPass1CanaryFileWriteTest:
564+
"""Tests for the STEP 0 mandatory canary file write test added to the Pass 1 prompt."""
565+
566+
def _capture_prompt(self, analyzer, staging_dir, repo_descriptions, repo_list, valid_domain_json):
567+
"""Helper: run pass 1 and capture the prompt sent to Claude CLI."""
568+
captured_prompt = {}
569+
570+
def fake_invoke(prompt, timeout, max_turns, allowed_tools=None, **kwargs):
571+
captured_prompt["value"] = prompt
572+
return valid_domain_json
573+
574+
analyzer._invoke_claude_cli = fake_invoke
575+
analyzer.run_pass_1_synthesis(staging_dir, repo_descriptions, repo_list, max_turns=10)
576+
return captured_prompt["value"]
577+
578+
def test_prompt_contains_canary_test_command(
579+
self, analyzer, staging_dir, repo_descriptions, repo_list, valid_domain_json
580+
):
581+
"""Prompt must include the canary echo command to test file writing."""
582+
prompt = self._capture_prompt(analyzer, staging_dir, repo_descriptions, repo_list, valid_domain_json)
583+
584+
assert "CANARY_OK" in prompt
585+
assert ".canary" in prompt
586+
assert "echo 'canary'" in prompt
587+
588+
def test_prompt_contains_canary_fail_instruction(
589+
self, analyzer, staging_dir, repo_descriptions, repo_list, valid_domain_json
590+
):
591+
"""Prompt must include the CANARY_FAIL output instruction."""
592+
prompt = self._capture_prompt(analyzer, staging_dir, repo_descriptions, repo_list, valid_domain_json)
593+
594+
assert "CANARY_FAIL" in prompt
595+
assert "STOP IMMEDIATELY" in prompt
596+
597+
def test_prompt_canary_includes_staging_dir_path(
598+
self, analyzer, staging_dir, repo_descriptions, repo_list, valid_domain_json
599+
):
600+
"""Prompt CANARY_FAIL line must include the staging directory path."""
601+
prompt = self._capture_prompt(analyzer, staging_dir, repo_descriptions, repo_list, valid_domain_json)
602+
603+
staging_dir_abs = str(staging_dir)
604+
assert staging_dir_abs in prompt
605+
606+
def test_canary_fail_in_output_raises_runtime_error(
607+
self, analyzer, staging_dir, repo_descriptions, repo_list
608+
):
609+
"""When CANARY_FAIL appears in output, RuntimeError is raised immediately."""
610+
canary_fail_output = (
611+
"CANARY_FAIL: Cannot write to /some/dir — [reason: Claude permission denied]"
612+
)
613+
614+
def fake_invoke(prompt, timeout, max_turns, allowed_tools=None, **kwargs):
615+
return canary_fail_output
616+
617+
analyzer._invoke_claude_cli = fake_invoke
618+
619+
with pytest.raises(RuntimeError) as exc_info:
620+
analyzer.run_pass_1_synthesis(staging_dir, repo_descriptions, repo_list, max_turns=10)
621+
622+
error_msg = str(exc_info.value)
623+
assert "canary" in error_msg.lower()
624+
assert "CANARY_FAIL" in error_msg
625+
626+
def test_canary_fail_error_message_includes_staging_dir(
627+
self, analyzer, staging_dir, repo_descriptions, repo_list
628+
):
629+
"""RuntimeError from CANARY_FAIL must include the staging directory path."""
630+
canary_fail_output = (
631+
"CANARY_FAIL: Cannot write to /some/dir — [reason: OS permission denied]"
632+
)
633+
634+
def fake_invoke(prompt, timeout, max_turns, allowed_tools=None, **kwargs):
635+
return canary_fail_output
636+
637+
analyzer._invoke_claude_cli = fake_invoke
638+
639+
with pytest.raises(RuntimeError) as exc_info:
640+
analyzer.run_pass_1_synthesis(staging_dir, repo_descriptions, repo_list, max_turns=10)
641+
642+
error_msg = str(exc_info.value)
643+
staging_dir_abs = str(staging_dir)
644+
assert staging_dir_abs in error_msg, (
645+
f"RuntimeError must include staging dir path. Got: {error_msg}"
646+
)
647+
648+
def test_canary_fail_does_not_retry(
649+
self, analyzer, staging_dir, repo_descriptions, repo_list
650+
):
651+
"""CANARY_FAIL must raise immediately — no retry attempt."""
652+
call_count = [0]
653+
654+
def fake_invoke(prompt, timeout, max_turns, allowed_tools=None, **kwargs):
655+
call_count[0] += 1
656+
return "CANARY_FAIL: Cannot write — permission denied"
657+
658+
analyzer._invoke_claude_cli = fake_invoke
659+
660+
with pytest.raises(RuntimeError):
661+
analyzer.run_pass_1_synthesis(staging_dir, repo_descriptions, repo_list, max_turns=10)
662+
663+
assert call_count[0] == 1, (
664+
f"CANARY_FAIL must not trigger retry. Expected 1 call, got {call_count[0]}"
665+
)
666+
667+
def test_canary_fail_multiline_output_extracts_fail_line(
668+
self, analyzer, staging_dir, repo_descriptions, repo_list
669+
):
670+
"""CANARY_FAIL detection works when the fail line is embedded in multiline output."""
671+
multiline_output = (
672+
"Running canary test...\n"
673+
"echo 'canary' > /path/to/file.canary\n"
674+
"CANARY_FAIL: Cannot write to /path — [reason: OS permission denied]\n"
675+
"Exiting as instructed."
676+
)
677+
678+
def fake_invoke(prompt, timeout, max_turns, allowed_tools=None, **kwargs):
679+
return multiline_output
680+
681+
analyzer._invoke_claude_cli = fake_invoke
682+
683+
with pytest.raises(RuntimeError) as exc_info:
684+
analyzer.run_pass_1_synthesis(staging_dir, repo_descriptions, repo_list, max_turns=10)
685+
686+
error_msg = str(exc_info.value)
687+
# Should extract just the CANARY_FAIL line
688+
assert "CANARY_FAIL" in error_msg
689+
690+
def test_normal_output_without_canary_fail_proceeds_normally(
691+
self, analyzer, staging_dir, repo_descriptions, repo_list, valid_domain_json
692+
):
693+
"""Output without CANARY_FAIL proceeds through normal file/stdout parsing."""
694+
pass1_file = staging_dir / "pass1_domains.json"
695+
696+
def fake_invoke(prompt, timeout, max_turns, allowed_tools=None, **kwargs):
697+
# Normal output with CANARY_OK in it (but no CANARY_FAIL)
698+
pass1_file.write_text(valid_domain_json)
699+
return "CANARY_OK\nFile written successfully."
700+
701+
analyzer._invoke_claude_cli = fake_invoke
702+
result = analyzer.run_pass_1_synthesis(staging_dir, repo_descriptions, repo_list, max_turns=10)
703+
704+
assert len(result) == 1
705+
assert result[0]["name"] == "authentication"

0 commit comments

Comments
 (0)