Skip to content

[fsconnect] - Bound the indexer walk depth and clean up the skip-cache temp file on failure - #1648

Merged
cgfixit merged 1 commit into
mainfrom
claude/cyclaw-optimize-fsindex-depth-cap
Oct 13, 2026
Merged

cgfixit merged 1 commit into
mainfrom
claude/cyclaw-optimize-fsindex-depth-cap

Conversation

@cgfixit

@cgfixit cgfixit commented Oct 13, 2026 •

Copy link
Copy Markdown
Owner

Proposed changes

FsIndexer._walk (agentic/fsconnect/indexer.py) recursed once per directory level with no ceiling. fs_glob got _MAX_GLOB_DEPTH = 64 for exactly this bug (tests/test_fsconnect_glob_depth.py), but the indexer's walk never did.

Reproduced on main: a 1100-level tree makes FsIndexer.scan() raise RecursionError. I built it with relative mkdir/chdir; its paths are about 2.2k characters, under Linux PATH_MAX. RecursionError is not an FsConnectError, so the fsconnect CLI exits outside its 0/2/3/4 contract. In apply() the same crash fires mid-walk: some files are already staged, but _save_cache has not run yet. Those staged copies then have no ownership record, and ownership-bounded pruning can never remove them.

Changes:

  1. Depth cap. _walk takes a depth argument and stops at _MAX_INDEX_DEPTH = 64, the same value as fs_glob's cap. Each un-walked subtree becomes a {"path": …, "size": 0, "skipped": "too_deep"} manifest row, so it is reported rather than hidden. Siblings at that level are still enumerated.
  2. Skip-cache temp file. _save_cache now writes to a per-PID temp name (.fsindex_cache.json.<pid>.tmp) and unlinks it on OSError. Before, a disk-full or permission error left .fsindex_cache.json.tmp in the corpus staging dir, and two concurrent applies shared one temp name. The new code mirrors harness_optimizer.patching._atomic_json.
  3. Comment. The sync/filters.py comment now names the new temp form. The .fsindex_cache.json* rule already covers it, so the filter itself does not change.

Invariant / Governance Impact: none of the six invariants is touched.

  • No core-six edits and no graph changes.
  • I6 isolation is preserved: indexer.py still imports nothing from retrieval, gate, graph or mcp.
  • check_invariants.py: 46 passed, 0 failed.
  • Staging paths still go through split_components and the pathsafe guard.
  • The commit hook's "new file write" question: the new temp path is built only from the fixed _CACHE_NAME and os.getpid(), never from share input.

Types of changes

  • Bugfix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Security (auth, privacy, injection, egress, redaction, or secrets)
  • Invariant / Governance refinement (use this for changes that strengthen or evolve the 6 invariants, I6 isolation, or harness phases)
  • Dependencies (added, removed, or bumped)
  • CI / infrastructure (workflows, hooks, templates, or checkers)
  • Refactor or tests (behavior-preserving, no new capability)
  • Documentation update
  • Other (none of the boxes above fit)

Scope: Out-of-band agentic/fsconnect layer.

Benefits / why

  • index-scan and index-apply on a pathologically deep share now finish with a typed, reported result instead of a traceback. Such a share can be hostile or accidental, and building one needs no symlink.
  • apply() always reaches _save_cache, so pruning ownership stays consistent.
  • A failed cache write no longer leaves debris in the corpus staging dir. That dir is the Dropbox sync root, which is why the sync filter needs the .fsindex_cache.json* rule at all.

Risks to monitor

  • Trees deeper than 64 levels now behave differently. A file staged from below level 64 by an earlier run is now outside the eligible set, so the next apply prunes it. This matches how too_large and read_error rows already drop out ("staging mirrors the ELIGIBLE share"). Shares that deep are not realistic for a corpus.
  • Detection: new skipped: too_deep rows in index-scan output, plus three new tests in tests/test_fsconnect_indexer.py:
    • descent is bounded and siblings are kept;
    • a tree inside the cap is walked completely and the cache is saved;
    • a failed write leaves the staging dir empty.

Checklist

  • I have read docs/THREAT_MODEL.md, INVARIANTS.md and SECURITY.md (and any relevant Phase docs)
  • This change preserves all 6 security invariants and I6 module isolation (explicit evidence or invariant matrix included for core changes)
  • Full sandbox validation has been run (GROK_API_KEY=dummy pytest tests/ -q --tb=short, and bash .claude/skills/CyClaw-Sandbox/verify.sh for core RAG/agentic paths) and passes with no regressions. Not run locally, per the CLAUDE.md §5 verification policy; CI runs the suites.
  • No new external network dependencies or mandatory online LLM assumptions were introduced without explicit justification + offline fallback path
  • For any agentic/fsconnect change: two-phase audit, quota enforcement, governed delete/trash, and write guards have been verified. No write-path, quota or trash code changed, and staging still goes through split_components.
  • Relevant architecture docs or threat model notes have been updated if core behavior or topology changed (n/a)
  • Commit messages follow the title prefix convention above
  • For large or complex changes: before/after invariant matrix + sandbox evidence is included in "Further comments" or linked (n/a: small change)

Further comments

Verification I ran directly, following CLAUDE.md §5:

  • Code probe (a scratch script driving the real scan() and apply()):

    Depth Result
    63 everything walked, 3 staged, cache saved
    70 capped: a too_deep row, siblings found, cache saved, no .tmp left
    1100 same as 70 on this branch; on main it raises RecursionError
  • One targeted test file: pytest tests/test_fsconnect_indexer.py gives 31 passed. With indexer.py reverted to main, the 3 new tests fail.

  • Lint:

    • ruff check --select E,F,I,B,C4,UP,S --ignore E501 is clean.
    • ruff format drift in these files predates this PR; this diff adds none (the drift count matches main).
    • git diff --numstat shows the same counts with and without --ignore-cr-at-eol, so there is no line-ending churn.
  • Invariants: check_invariants.py gives 46/0.

  • Not run locally: the full suite and the coverage gate. GitHub Actions runs those.

  • Not fixed here: write_filter_file in sync/filters.py uses the same fixed-name temp pattern. It runs under the sync lock and is regenerated on every run, so I judged it not worth the extra diff.

Suggested merge order of open PRs

Trial merges verified (git merge --no-ff, all three branches in sequence onto a throwaway checkout) against main @ 6396152 on 2026-10-13. No file is touched by more than one of the three PRs.

#1647 · Blocked ⛔ (draft, CI pending)
Impact: agentic/harness_optimizer/ only; independent.
Action: Merge first (lowest number). Trial merge clean vs main.

#1648 (this PR) · Blocked ⛔ (draft, CI pending)
Impact: agentic/fsconnect/indexer.py + a sync/filters.py comment; independent.
Action: Merge second; no shared files.

#1649 · Blocked ⛔ (draft, CI pending)
Impact: memory/store.py, memory/policy.py + tests; independent.
Action: Merge third; no shared files.

ELI5

The file-share indexer walks folders like nested boxes: open a box, and if there's a box inside, open that one too. Python can keep only about a thousand boxes open at once, so a share nested deeper than that crashed the whole walk with RecursionError. Worse, in apply() the crash came after some files were already copied into the corpus but before the indexer wrote down which ones it had copied. It could never clean those copies up later.

The walk now stops at 64 levels, the same limit the fs_glob search tool already uses. A subtree past the limit shows up as a skipped: too_deep row instead of being silently ignored, and the other folders at that level are still read. Separately, the small cache file is now written through a temp file with a unique name, and that temp file is deleted if the write fails. No leftovers end up in the synced folder.

What could still go wrong: the next apply will prune any file deeper than 64 levels that an earlier run copied. That is intended, and the too_deep rows show it. I did not run the full suite locally; CI will run it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BJXTdWRbDpkynPYbpL2pxd


Generated by Claude Code

Last updated: 2026-10-13 09:43 ET

…e temp file on failure

FsIndexer._walk recursed once per directory level with no ceiling (fs_glob got
_MAX_GLOB_DEPTH, the indexer never did). A tree deeper than the frame limit
raised RecursionError -- outside the CLI's 0/2/3/4 contract -- and in apply()
it fires after files are staged but before the ownership cache is saved, so
those staged copies could never be pruned. The walk now stops at 64 levels,
records the un-walked subtree as a `skipped: too_deep` manifest row, and keeps
enumerating siblings. _save_cache now writes a per-PID temp file and unlinks
it on OSError, matching harness_optimizer's _atomic_json.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJXTdWRbDpkynPYbpL2pxd
@github-actions

github-actions Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

PR is a complete fill of .github/PULL_REQUEST_TEMPLATE.md (every section, ticked boxes, merge order, ELI5 last, last-updated stamp, title, branch).

@cgfixit

cgfixit commented Oct 13, 2026

Copy link
Copy Markdown
Owner Author

@codex review (for bugs to fix and performance/security/other regressions potentially introduced in this pr)

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-13T14:39:47.135079Z bbfc421 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Bravo.

Reviewed commit: bbfc4214ed

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@cgfixit
cgfixit marked this pull request as ready for review October 13, 2026 14:37
@cgfixit
cgfixit merged commit 2b1d8d4 into main Oct 13, 2026
68 of 69 checks passed
@cgfixit
cgfixit deleted the claude/cyclaw-optimize-fsindex-depth-cap branch October 13, 2026 14:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants