Conversation
Design Review (Fable 5, fork) — ✅ PASSDesign-level review of All premises verified: the Design-Verdict: PASS Root-cause fix at the right seam: both deliberate copies move together, the predicate gap is closed, and Windows CI plus measured red-before evidence back it. [DESIGN-REVIEWED] 8121d07 |
GPT 5.6 Review (fork) — ✅ no blocking findingsReviewed Review detailsFINDING -- src/kiro_crew/apps/builtins/dev_fleet/sync_runner.py:66 -- |
First Principles Review (Fable 5, fork) — 🟡 CONCERNSPremise-level review of All claims verified against the base tree: First-Principles-Verdict: CONCERNS The 31-line Not justified as shipped
What this change shipsIntent: stop a junctioned
WatchThe junction branch exists only to route to SubtractionsDrop [FIRST-PRINCIPLES-REVIEWED] 8121d07 |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed |
Both halves of the node_modules transaction removed a stashed tree with "unlink it if os.path.islink, else rmtree". That guard exists because rmtree REFUSES a symlink and ignore_errors=True swallows the refusal, which once left a symlinked tree's backup undeletable: every later run then saw both paths and stopped as ambiguous, a permanent wedge escapable only by hand. os.path.islink reports False for a Windows directory junction, so a junctioned tree took the rmtree branch -- and rmtree refuses a junction exactly as it refuses a symlink. Same swallowed refusal, same surviving backup, same wedge. A junction is the ordinary Windows spelling of the shared-store layout the symlink branch was written for, because a directory symlink there needs a privilege a junction does not. Measured on unpatched main: commit() left the backup in place and the next begin() refused with "remove one by hand", while the store behind the junction stayed intact -- so this is availability, not data loss. node_modules_txn goes through platform_compat.is_link_or_junction and unlink_link_or_junction; its only caller, frontend, already imports that module and already uses both helpers for the static/dist link, so nothing new is pulled onto the import path. dev_fleet/sync_runner cannot import kiro_crew -- the stdlib-only rule is a documented invariant because the file is snapshotted and run by path while the repo is merged underneath it -- so it spells the predicate out locally, with the reparse-tag fallback os.path.isjunction lacks before 3.12. Both copies are changed together because they share semantics by design; fixing one would leave the identical wedge on the other flow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3e98e15 to
8121d07
Compare
|
Rebased onto main Clean rebase, no conflicts. Your commit applied unchanged on top of current main; the diff is byte-identical to what you had ( Gates run locally on the four changed files only: Please review the new head. Note that a maintainer push makes the maintainer the last pusher, so under this repo's last-push rule a second approver is needed before merge. Reply here if anything looks wrong. |
|
🤖 Kiro Crew [operator: chenmingwei23#de330d0c]: This PR has been inactive for 7+ days with failing CI. I've assessed the blockers and they appear resolvable — I'll push fixes directly to this branch as a co-author. Assessment: All blockers mechanical. (1) The branch is behind main ( If you'd prefer I don't touch this PR, add the |
Problem / Motivation
The
node_modulestransaction removes a stashed tree with one rule, spelled outin both copies of it:
os.path.islinkreports False for a Windows directory junction, so ajunctioned tree skipped the unlink branch and went to
rmtree— which refuses ajunction exactly as it refuses a symlink. Same swallowed refusal, same surviving
backup, same wedge.
Measured end-to-end on unpatched
main(Windows,_winapi.CreateJunction, ajunctioned
node_modulespointing at a shared store):os.path.islink(tree)/os.path.isdir(tree)False/Truebegin()True— the junction is renamed to the backupcommit()begin()False— logs "remove one by hand"A junction is not an exotic layout here: it is the ordinary Windows spelling of
the shared-store
node_modulesthe symlink branch was written for, because adirectory symlink on Windows needs
SeCreateSymbolicLinkPrivilegeand a junctionneeds nothing.
Why it matters
Availability, not data loss — and the PR does not claim more. The store behind
the junction is never touched (
rmtreerefuses before it descends; verified in thetable above). What breaks is the flow:
EXIT_TREE_AMBIGUOUS, naming two paths and touching neither.present. It never retries — the next boot sees the commit already applied — so
its only self-heal is Dev Fleet's reconciliation, which is exactly what is now
refusing.
Neither clears on its own. The transaction is deliberately built to refuse rather
than guess, so this state persists until a human deletes one of the two paths.
What changed (motivation → approach → change)
Symptom: a junctioned
node_moduleswedges the sync. Root cause: the link guarduses a predicate that does not recognise a junction. Change: recognise it, and
remove it as a link rather than trying to walk it.
Two files, because the transaction has two copies by design and they share
semantics rather than code:
src/kiro_crew/node_modules_txn.py(the in-process half) usesplatform_compat.is_link_or_junctionandunlink_link_or_junction— thelatter unlinks a symlink and
rmdirs a junction's reparse point, never thetarget. Its only consumer,
frontend.py, already importsplatform_compatand already uses both helpers for the
static/distlink, so this addsnothing to any import path.
dev_fleet/sync_runner.py(the snapshot half) cannot importkiro_crew: the module header states the stdlib-only rule is "an invariant,not a convenience", because the file is copied out and run by path while
the repository is being merged underneath it, and a test asserts it. So the
predicate is spelled out locally, with the reparse-tag fallback that
os.path.isjunction(3.12+) does not provide on the 3.10/3.11 floor thisproject still supports — without it the guard would be a silent no-op on
exactly the older Windows installs most likely to carry a junction. Confirmed
locally:
hasattr(os.path, "isjunction")isFalseon the 3.10 interpreter.Fixing only one would leave the identical wedge on the other flow, so both move
together.
Not changed: the transaction's shape, its refusal policy, the ambiguous-state
branch, and the shared
BACKUP_SUFFIX. The dev-fleet and cli specs describe thetransaction at that level and are unaffected, so neither needed a same-commit
update.
Tests
Three tests, in the files that already own these functions:
test_node_modules_txn.py::test_a_junctioned_tree_does_not_wedge_the_next_run— drives
begin()→ fresh install →commit(), then asserts the backup isgone, the store behind the junction still holds its sentinel, and the next
run's
begin()returnsTrue. That last assertion is the operator-visibleconsequence, not a proxy for it.
test_dev_fleet_sync_runner.py::TestGone::test_a_linked_tree_is_unlinked_and_its_target_survives—
gone()on the link reportsTrue, the link is gone, the target survives.…::test_a_real_tree_is_still_removed— the new branch does not shadow theordinary directory case.
Both link tests use
conftest.make_dir_link, which yields a junction onWindows and a symlink elsewhere, and both carry guard-the-guard assertions
(
not os.path.islink/os.path.isdiron Windows) through an oracle outsidethe module under test — so a red-before fails on behaviour, never on a missing
name.
Red-before, measured against unpatched
origin/mainwith the two productionfiles restored in-tree:
Green after: the three new tests pass;
test_node_modules_txn.py+test_dev_fleet_sync_runner.py+test_dev_fleet_app.pyshow 13 failed / 362passed on
test_dev_fleet_app.pyboth with and without the patch — anidentical control, so that pre-existing Windows set is not a regression. Those
failures are
WinError 1314(no symlink privilege in an unelevated shell), whichis precisely why the pre-existing
test_a_symlinked_tree_is_still_protectedcannot run on Windows — the platform this defect lives on.
Platform honesty: on Linux CI
make_dir_linkproduces a symlink, which theoriginal guard already handled, so these are no-regression checks there. Only
Windows exercises the fix.
Gates:
flake8clean,isort --check-onlyclean,scripts/check_black_formatting.pyandscripts/check_subprocess_encoding.pyboth PASS scoped
origin/main...HEAD(4 files).mypy --platform linuxreportsnothing for either production file; its 4 findings are in
dashboard/state.pyand
apps/routes.pyand are Python-3.10-only artifacts of the local interpreter,absent on CI's 3.12.
Manual verification
N/A — unit coverage sufficient. The behaviour is a pure function of a directory
tree, and the tests build the exact tree (junction included) the defect needs,
including the second run that exposes the wedge.
Related Issues
No issue. Found by auditing link guards for the junction blind spot, then
measured against current
main.Pattern harvest
Rule candidate:
review-promptPattern: a link guard whose failure mode is a refused destructive call, not a
destructive one. The reflex on
os.path.islinkmisses is "does something getdeleted through the link?" — here nothing does, and that is why it is a bug:
rmtreerefuses, the refusal is swallowed byignore_errors=True, and a callerthat reads the removal's success as a gate is left permanently stuck. A guard
miss whose consequence is availability is easy to grade as harmless and is not.
That grading matters at the seam level: a sibling
rmtree-behind-islinksite(
auto_improvement/pr_watchers.py:1417) was measured and rejected preciselybecause nothing there reads the outcome — worst case a directory is not
reclaimed. Same predicate, same platform, opposite verdict. These sites are
decided one at a time on consequence, never swept.
Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)Contribution License Agreement
🤖 Generated with Claude Code