close
Skip to content

fix(dev-fleet): refuse the sync when pip cannot reinstall into the live venv - #2445

Merged
iamwhatever merged 1 commit into
mainfrom
fix/win-devfleet-pip-lock
Aug 10, 2026
Merged

fix(dev-fleet): refuse the sync when pip cannot reinstall into the live venv#2445
iamwhatever merged 1 commit into
mainfrom
fix/win-devfleet-pip-lock

Conversation

@chenmingwei23

@chenmingwei23 chenmingwei23 commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Fixes #2297.

Problem

On Windows, Dev Fleet's Pull + Build dies at the pip install step, and the failure leaves the venv broken rather than merely un-upgraded.

Installing collected packages: defusedxml, kirocrew
  Attempting uninstall: kirocrew
    Found existing installation: kirocrew 0.1.2
    Uninstalling kirocrew-0.1.2:
ERROR: Could not install packages due to an OSError: [WinError 32] The process cannot
access the file because it is being used by another process:
'c:\\users\\...\\.venv\\scripts\\kirocrew.exe'

_sync_start_locked() runs pip with the main checkout's own venv interpreter, deliberately — the existing comment explains it must not re-point a worktree venv's editable install at MAIN_REPO. That reasoning is right, but in the ordinary single-checkout setup that venv is the one running the gateway. Windows holds a mandatory lock on a running executable's image, so pip cannot rewrite Scripts\kirocrew.exe, and the step can never succeed. It is deterministic, not a race.

Why it matters

The failed step is the smaller half. pip's uninstall is not atomic — by the time it reaches the locked script it has already:

  1. renamed kirocrew-<ver>.dist-info to its ~irocrew-<ver>.dist-info backup, and
  2. deleted __editable__.kirocrew-<ver>.pth, the file that puts src on sys.path

and it rolls back neither. Observed aftermath on a real install:

check result
pip show kirocrew Package(s) not found: kirocrew
python -c "import kiro_crew" ModuleNotFoundError
any pip command WARNING: Ignoring invalid distribution ~irocrew

The running gateway survives on already-imported modules, so nothing surfaces at the time. But the kirocrew CLI is dead (its entry point can't import), which means kirocrew stop / restart no longer work, new subprocesses that import kiro_crew fail, and the gateway does not come back from its next restart. One click takes a working install to one that is fine right up until it is stopped.

Fix (symptom → root cause → change)

1. Probe before doing anything. Check the console scripts pip would have to rewrite, and refuse when one is locked. pip is never started, so it cannot begin an uninstall it can't finish.

The probe is an r+b open — non-destructive, and it discriminates correctly. Validated against a genuinely locked exe:

file probe
kirocrew.exe (running) PermissionError
pip.exe, idna.exe, f2py.exe, … (same dir) writable
os.remove("kirocrew.exe") winerror=32 — exactly pip's error

It runs on subprocess_executor() alongside the existing git/npm resolution, so it costs no extra round-trip and never blocks the event loop.

Deliberately bounded: the probe does not model every way a delete can fail — an opener that permits writes but denies deletes would pass it. A clean result therefore means "no known blocker", not a guarantee, and a miss just leaves the pre-fix behaviour. It is worth doing anyway because it removes the failure that actually occurs.

2. Refuse the whole sync, not just the reinstall. An earlier revision of this PR ran fetch/merge and omitted only the pip step. GPT 5.6 Review showed that is unsafe, and it was right — disposition. Merging without installing lands a revision on disk whose new dependencies are absent, the run exits 0, and the UI then offers "restart gateway to apply" (build_pending) — so the next restart imports the new code, fails on the missing import, and the gateway does not come back. Newly spawned subprocesses hit the same gap with no restart at all. That is the same unstartable-gateway outcome this guard exists to prevent, reached later and with a green run in front of it.

So the sync now refuses early, alongside the function's existing guards (wrong branch, no venv, no git, no npm), leaving the checkout on a revision whose dependencies are satisfied. The error names the blocking file and the exact remedy:

refusing to sync: cannot reinstall into the venv this gateway runs from.
...\Scripts\kirocrew.exe is locked by a running process, so a reinstall
cannot replace it — and pip's uninstall is not atomic, so attempting it would
strip the editable install on the way out and leave the venv unable to import
the package at all. Pulling without installing is refused too: a revision whose
new dependencies are missing crashes the gateway on its next restart. Stop the
gateway and sync from a terminal instead:
git -C "<repo>" pull --ff-only && "<venv-python>" -m pip install -e "<repo>"

Keeping fetch/merge and merely reporting failure was considered and rejected: the hazard is the merged-but-uninstalled revision itself, so a non-zero exit would not remove it.

That remedy line names an absolute, quoted repository path rather than -e ., which GPT 5.6 also flagged. Handing the user a recovery command is the refusal's entire purpose, so the line must not depend on where it is pasted: this project is normally checked out as several worktrees at once, and -e . copied into a feature worktree's terminal would install that tree into the primary venv and repoint its editable install away from the primary checkout -- the same wrong-tree-in-the-venv outcome the refusal exists to avoid. git -C already pinned the pull, which made an unpinned . actively misleading, since the line read as though it were cwd-independent. The surrounding prose no longer shows the bare -e . form either, so the message cannot teach the wrong command anywhere.

Consequence, stated plainly: on Windows, Pull + Build now refuses while the gateway runs from that venv, rather than half-working. That is the honest state of the configuration until a deeper fix exists (an out-of-process or restart-time installer, tracked in #2297) — a refusal that explains itself beats a success that breaks the next restart.

3. Pin the whole pipe to UTF-8. _start_run decodes that stream as UTF-8 (line.decode(errors="replace")), while a piped stdout on Windows encodes with the process locale codepage. That writer/reader mismatch is pre-existing: it mangles any non-ASCII output and can raise UnicodeEncodeError before the first step. errors="replace" additionally guarantees no print can be fatal.

Reconfiguring the runner's own stdout fixes only one of the two writers, which GPT 5.6 caught on the rebased revision and was right about again -- disposition. Each step is a separate process that inherits the same pipe and re-derives its encoding from the locale, and two steps are Python -- pip install -e, and the build_and_stage child -- so a non-ASCII checkout path would still be encoded with the codepage and die there, one process further down. The environment is the only channel that reaches a child, so every step is now spawned with PYTHONIOENCODING=utf-8:replace; non-Python steps (git, npm) ignore it and are unaffected. (Raised by GPT 5.6 on the first revision — disposition.)

POSIX is untouched — an executing binary can be unlinked there, which is why pip has always been able to replace it. The helper returns immediately on non-Windows.

Tests

  • test_write_locked_console_scripts_is_a_posix_noop — the platform gate.
  • test_write_locked_console_scripts_flags_a_locked_script — the real failure is detected.
  • test_write_locked_console_scripts_passes_a_writable_script — a venv the gateway is not running from still syncs normally.
  • test_write_locked_console_scripts_ignores_unrelated_executables — an unrelated locked exe in the same Scripts/ must not block the sync; that would turn any other process into a silent refusal.
  • test_write_locked_console_scripts_lets_pip_judge_other_errors — a non-lock OSError is not evidence of a lock; refusing on any error would block syncs that would have worked.
  • test_sync_refuses_entirely_when_a_console_script_is_lockedno run is started at all, asserted via _start_run never being called, so fetch/merge provably did not happen; and the message names both blocker and remedy.
  • test_sync_runs_every_step_when_nothing_is_locked — control: the full step list including pip install.
  • test_sync_runner_pins_utf8_stdout_before_its_first_print — ordering asserted, not just presence.
  • test_utf8_reconfigure_survives_a_legacy_codepage_pipe — runs the mechanism in a real subprocess with PYTHONIOENCODING=cp1252, asserting exit 0 and a clean UTF-8 round-trip, so the test proves the fix rather than only proving the line exists.

The step assertions parse the generated script's structured step list rather than substring-matching its text. Worth calling out: an early draft asserted '"pip install"' not in script, which is vacuous — the steps are embedded double-JSON-encoded, so a label reads as \"pip install\" and the quoted check passes whether or not the step is present. The parsed form fails if the gate is removed.

Manual verification

Local gates: isort clean, flake8 clean, mypy reports 0 errors in the changed file.

test_dev_fleet_app.py on this branch: 39 failed, 260 passed, 6 skipped. Clean origin/main in the same worktree: 39 failed, 251 passed, 6 skipped — the identical 39 pre-existing failures (Windows-host make_live / service-pointer / module-entry tests), plus the 9 new tests passing. None of the new test names appear in the failure list. (mypy run locally on Windows also reports 157 pre-existing errors across 34 untouched files for POSIX-only stubs — fcntl, resource, os.fork; CI's backend-lint runs on ubuntu-latest, where those resolve.)

Screenshots

N/A — no user-visible UI change; this is one backend module and its test.

@chenmingwei23
chenmingwei23 requested a review from a team as a code owner August 10, 2026 01:04
@github-actions github-actions Bot added the readiness: checking Automated validation is still running label Aug 10, 2026
@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review — ✅ no blocking findings

Reviewed 3ba147fc8e282a33c2b6cfcfadee693912bce268 — this comment is updated in place on each push.

Review details

No findings.

The change adds a Windows-only write-lock probe (_write_locked_console_scripts) executed on the executor alongside the existing binary lookups (so the loop is not blocked), refuses the whole sync when a console script is locked, and pins UTF-8 on both the runner's stdout and each child step's environment. The probe correctly narrows to PermissionError (letting pip judge other OSErrors), the refusal happens before any step runs (no fetch/merge), and the remedy string uses absolute, quoted paths. The consequence chains the PR describes are real and the guards are placed correctly; the POSIX path is a clean no-op.

[OPUS-REVIEWED] 3ba147f

Verdict parsed from the review's SHA-scoped output markers for commit 3ba147fc8e282a33c2b6cfcfadee693912bce268.

False positive or not applicable? A repository writer can comment:
/ai-review override fable 3ba147fc8e282a33c2b6cfcfadee693912bce268: <one-sentence reason>

@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — ✅ PASS

Advisory design-level review of 3ba147fc8e282a33c2b6cfcfadee693912bce268 — updated in place on each push; does not block merge.

Design-Verdict: PASS

A deterministic breakage is turned into a safe, self-explaining refusal; the bounded probe, whole-sync refusal, and tracked deeper fix (#2297) are the right proportionality.

Watch

[DESIGN-REVIEWED] 3ba147f

@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — ✅ no blocking findings

GPT 5.6 completed its review of 3ba147fc8e282a33c2b6cfcfadee693912bce268 and found no blocking issues.

This comment is updated in place on each push.

Review details

No findings.
[GPT-REVIEWED] 3ba147f

False positive or not applicable? A repository writer can comment:
/ai-review override gpt 3ba147fc8e282a33c2b6cfcfadee693912bce268: <one-sentence reason>

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 10, 2026
@chenmingwei23
chenmingwei23 force-pushed the fix/win-devfleet-pip-lock branch from c5c2cfa to 5d53ffc Compare August 10, 2026 01:17
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 10, 2026
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Disposition — GPT 5.6 Review on c5c2cfa5

fixed in 5d53ffc0.

Finding: BLOCKING — server.py:3099, Unicode checkout paths crash the sync runner. A non-ASCII Windows path → locked-script notice → locale-encoded redirected stdout → UnicodeEncodeError before fetch/merge.

Legitimate, and a regression this PR introduced. Verified rather than taken on trust:

  • The notice interpolates real filesystem paths (locked_scripts, target_py), so a CJK home directory lands in the printed string.
  • _start_run spawns the runner with stdout=asyncio.subprocess.PIPE, so the child's sys.stdout uses the process locale codepage on Windows, not UTF-8.
  • Notices print at the head of the script, before the step loop — so the failure would kill fetch, merge and the frontend build too. Strictly worse than the skipped reinstall it was reporting.
  • The pre-existing ::step:: markers only carry ASCII labels, which is why this hazard is new with the notices rather than latent.

Fix — pin the runner's stdout to UTF-8 rather than sanitising the path:

"sys.stdout.reconfigure(encoding='utf-8', errors='replace')\n"

Chosen over sys.stdout.buffer writes because the reader side already settles the question — _start_run does line.decode(errors="replace"), i.e. it expects UTF-8 and cannot itself crash. So UTF-8 makes non-ASCII paths round-trip intact, which matters for a message whose entire job is naming the locked file; errors="replace" on the writing side means no print can ever be fatal. Placing it on the first line also covers the step markers, so a future non-ASCII label (a worktree name, say) is safe too.

Stripping or ASCII-escaping the path was rejected: a notice that cannot name the locked file does not do its job.

Tests added:

  • test_sync_pins_utf8_stdout_before_printing_a_notice — a CJK locked path reaches the notice intact, and the reconfigure appears before the first print (ordering asserted, not just presence).
  • test_utf8_reconfigure_survives_a_legacy_codepage_pipe — executes the mechanism in a real subprocess with PYTHONIOENCODING=cp1252 forced, asserting exit 0 and a clean UTF-8 round-trip. This proves the fix rather than only proving the line exists in the source.

Gates after the amend: isort clean, flake8 clean, mypy 0 errors in the changed file. test_dev_fleet_app.py on this branch: 39 failed / 260 passed / 6 skipped; clean origin/main in the same worktree: 39 failed / 251 passed / 6 skipped — the identical 39 pre-existing Windows-host failures, plus the 9 new tests passing, none of which appear in the failure list.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 10, 2026
@chenmingwei23
chenmingwei23 force-pushed the fix/win-devfleet-pip-lock branch from 5d53ffc to e837325 Compare August 10, 2026 01:32
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 10, 2026
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Disposition — GPT 5.6 Review round 2 on 5d53ffc0

fixed in e8373256. The finding is correct and it invalidated my design, not just an edge of it.

Finding: BLOCKING — server.py:3005, Dependency-incomplete sync is reported as restartable success. Required dependency added upstream → sync merges but omits pip → run exits 0 and UI prompts restart → gateway crashes importing the missing dependency.

Checked the chain rather than taking it on trust:

  • The pip step was the only thing omitted, so fetch and merge still landed the new revision on disk.
  • _sync_start_locked returned {"ok": True} and _start_run exits 0 when every remaining step succeeds — so the run is reported as a success.
  • The UI then offers exactly the affordance the finding names: DevFleetPage surfaces build_pending as "restart gateway to apply" (pinned in DevFleetPage.test.tsx).
  • Restarting imports the merged code, hits the absent dependency, and the gateway does not come back.
  • It is not even restart-gated: any newly spawned subprocess (MCP stub, subagent) launched from the merged source hits the same missing import immediately.

So the previous revision could still produce an unstartable gateway — the precise outcome this PR exists to prevent — reached one step later and with a green run in front of it. My notice mitigated nothing that matters: a line in a run log does not outrank a success verdict plus a restart button.

This does not contradict round 1; both findings hold independently, and I treated them jointly rather than assuming the second was a re-litigation of the first.

Change: _write_locked_console_scripts is unchanged, but the gate moved from omit the step to refuse the whole sync before anything runs — returning {"ok": False, "error": ...} alongside the function's existing guards (wrong branch, no venv, no git, no npm). The checkout is left on a revision whose dependencies are satisfied, which is the only outcome that cannot brick the next restart. The error names the blocking file and the exact remedy:

refusing to sync: cannot reinstall into the venv this gateway runs from.
...\Scripts\kirocrew.exe is locked by a running process, so `pip install -e .`
cannot replace it — and pip's uninstall is not atomic, so attempting it would
strip the editable install on the way out and leave the venv unable to import
the package at all. Pulling without installing is refused too: a revision whose
new dependencies are missing crashes the gateway on its next restart. Stop the
gateway and sync from a terminal instead:
git -C <repo> pull --ff-only && <venv-python> -m pip install -e .

I considered keeping fetch/merge and merely reporting failure, and rejected it: the hazard is the merged-but-uninstalled revision itself, so a non-zero exit code would not remove it.

Consequence stated plainly: on Windows, Pull + Build now refuses while the gateway runs from that venv, instead of half-working. That is the honest state of the configuration until #2297's deeper fix (an out-of-process or restart-time installer) exists; a refusal that explains itself beats a success that breaks the next restart.

Round 1 fix retained — the UTF-8 stdout pin stays, now justified on its own merit rather than on the notice that motivated it: _start_run decodes the stream as UTF-8 while a piped stdout on Windows encodes with the process locale codepage, so the writer/reader mismatch is pre-existing and real. Its test now asserts the pin precedes the step loop.

Tests reworked to the new design (9 total, all passing): the refusal starts no run at all (asserted via _start_run never being called, so fetch/merge provably did not happen) and its message names both blocker and remedy; the control case still gets the full step list including pip install.

Gates: isort clean, flake8 clean, mypy 0 errors in the changed file. test_dev_fleet_app.py: 39 failed / 260 passed / 6 skipped on this branch vs 39 failed / 251 passed / 6 skipped on clean origin/main in the same worktree — identical 39 pre-existing Windows-host failures, plus the 9 new tests, none of which appear in the failure list.

@chenmingwei23 chenmingwei23 changed the title fix(dev-fleet): never hand pip a console script it cannot replace fix(dev-fleet): refuse the sync when pip cannot reinstall into the live venv Aug 10, 2026
@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 10, 2026
@chenmingwei23
chenmingwei23 force-pushed the fix/win-devfleet-pip-lock branch from e837325 to 26e7074 Compare August 10, 2026 01:47
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 10, 2026
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

CI triage — the two Windows shard failures on e8373256 are inherited, not from this diff

Rebased onto current main (bdd55fc7) and pushed as 26e70741, one commit. Both failures were attributed against a base with no part of this change in it, rather than assumed.

This diff touches exactly two files:

src/kiro_crew/apps/builtins/dev_fleet/server.py
test/test_dev_fleet_app.py

1. test_dashboard_chat_pins.py::test_load_transient_io_error_preserves_existing_stateFailed: DID NOT RAISE <class 'OSError'>

Pre-existing on Windows. The strongest evidence came for free: at the time CI ran it, that test file did not exist on this branch at all — it arrived on main after my branch point (edba4af5), and the pull_request build tests the merge result. A diff that has never seen a file cannot break it.

Confirmed directly after rebasing, with both of my files reverted to origin/main so nothing of mine was loaded:

git checkout origin/main -- src/kiro_crew/apps/builtins/dev_fleet/server.py test/test_dev_fleet_app.py
pytest test/test_dashboard_chat_pins.py::test_load_transient_io_error_preserves_existing_state
→ FAILED  (DID NOT RAISE <class 'OSError'>, test_dashboard_chat_pins.py:1945)

Same failure, same line, with zero of my code present. Nothing here to fix in this PR; it is a Windows-host issue in main and belongs in its own change.

2. test_mcp_gateway_transport.py::test_create_server_pipe_closes_the_handle_when_the_read_mode_flip_failsAssertionError: the orphaned pipe handle was not closed / assert 4 == 1

Shard-ordering pollution, not a defect this diff can reach. On the same Windows host it passes in isolation and with its whole file:

run result
that test alone 1 passed
whole test_mcp_gateway_transport.py 32 passed, 14 skipped
that test alone, with my files reverted to origin/main 1 passed

assert 4 == 1 is a counter that accumulated across earlier tests in the shard rather than a single-call assertion, which is the signature of leaked state under the parallel sharded run. My diff touches neither mcp_gateway.transport nor anything it imports.

Not filing these as flakes-to-retry: (1) is a real reproducible failure on main for Windows and deserves its own fix; (2) is a genuine determinism bug in the shard, and the repo's testing conventions say a confirmed flake is a bug with a root cause rather than something to paper over with a rerun. Both are out of scope here, and I have deliberately not touched either file.

Gates after the rebase: targeted tests 9 passed, isort clean, flake8 clean, mypy 0 errors in the changed file. Worktree clean, still a single commit.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 10, 2026
…ve venv

On Windows, Pull + Build's `pip install -e .` step can never succeed when the
gateway is served by the venv it reinstalls into -- the ordinary
single-checkout setup. Windows holds a mandatory lock on a running
executable's image, so pip cannot rewrite `Scripts\kirocrew.exe`:

    ERROR: Could not install packages due to an OSError: [WinError 32] The
    process cannot access the file because it is being used by another
    process: '...\.venv\scripts\kirocrew.exe'

The failed step is the smaller half of the problem. pip's uninstall is not
atomic: by the time it reaches the locked script it has already renamed the
dist-info aside and deleted the editable `.pth` that puts `src` on
`sys.path`, and it rolls back neither. The venv is left unable to import the
package at all, which also kills the console script the gateway is restarted
through. The running process survives on already-imported modules, so nothing
surfaces until the next restart fails -- one click turns a working install
into one that is fine right up until it is stopped.

Probe the console scripts pip would have to rewrite before doing anything, and
refuse the whole sync when one of them is write-locked. An `r+b` open is
non-destructive and discriminates correctly: a running executable refuses it
while every other script in the same directory opens fine. It cannot model
every way a delete can fail -- an opener that permits writes but denies
deletes would pass -- so a clean result means "no known blocker" rather than a
guarantee, and a miss simply leaves the previous behaviour.

Refusing the whole sync, rather than running fetch/merge and omitting only the
reinstall, is the part that matters. Merging without installing is not a safe
consolation prize: a revision that adds a dependency lands on disk with that
dependency absent, the run exits 0, and the UI then offers "restart gateway to
apply" -- so the next restart imports the new code, fails on the missing
import, and the gateway does not come back. Newly spawned subprocesses hit the
same gap without any restart. That is the same unstartable-gateway outcome the
guard exists to prevent, reached later and with a success report in front of
it. The checkout is left on a revision whose dependencies are satisfied, and
the error names both the blocking file and the exact commands to run with the
gateway stopped.

Those commands name an absolute, quoted repository path rather than `-e .`.
The refusal's whole purpose is to hand the user a recovery line, so that line
must not depend on where it is pasted: this project's normal working state is
several worktrees checked out at once, and `-e .` copied into a feature
worktree's terminal would install THAT tree into the primary venv and repoint
its editable install away from the primary checkout -- the same
wrong-tree-in-the-venv outcome the refusal exists to avoid. `git -C` already
pinned the pull, which made an unpinned `.` actively misleading, since the
line read as though it were cwd-independent. Quoting covers the spaces that
are routine in a Windows home directory.

Pin the whole pipe to UTF-8 while here. `_start_run` decodes that stream as
UTF-8, but a piped stdout on Windows encodes with the process locale
codepage -- a writer/reader mismatch that mangles non-ASCII output and can
raise UnicodeEncodeError before the first step. Reconfiguring the runner's own
stdout fixes only one of the two writers: each step is a separate process that
inherits the same pipe and re-derives its encoding from the locale, so the
Python steps -- pip, and the build-and-stage child -- would still encode a
non-ASCII checkout path with the codepage and die on it. The environment is
the only channel that reaches a child, so each step is spawned with
`PYTHONIOENCODING=utf-8:replace`; non-Python steps ignore it. errors="replace"
additionally guarantees no print can be fatal.

POSIX is untouched: an executing binary can be unlinked there, which is why
pip has always been able to replace it.

Tests cover the platform gate, a locked script, a writable script, an
unrelated locked executable that must not block the sync, a non-lock OSError
that must leave the judgement to pip, the refusal starting no run at all, the
full step list when nothing is locked, the recovery command carrying an
absolute quoted path in both its prose and its command, the encoding pinned
ahead of the first print, the per-step environment pin reaching the env
actually handed to `subprocess.run`, and both halves of the encoding mechanism
under a forced legacy codepage -- the runner's own print, and a child that
dies on a non-ASCII path without the variable and survives with it. The step
assertions parse the generated script's structured step list rather than
substring-matching its text, because the steps are embedded double-JSON-encoded
and a naive quoted match passes whether or not the step is present.
@chenmingwei23
chenmingwei23 force-pushed the fix/win-devfleet-pip-lock branch from 26e7074 to 3ba147f Compare August 10, 2026 03:15
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 10, 2026
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Round 3 disposition — both blocking findings accepted and fixed in 3ba147fc

Both are distinct from rounds 1 and 2, and both are correct. Verified against the code before changing anything.


1. BLOCKING — server.py:3024, recovery command installs the terminal's current directory. Accepted.

Confirmed. The line was:

git -C {MAIN_REPO} pull --ff-only && {target_py} -m pip install -e .

git -C pins the pull, but -e . resolves against whatever cwd the line is pasted into — and that asymmetry is what makes it dangerous rather than merely sloppy: the command reads as though it were cwd-independent, so there is no cue to check. This project's normal working state is several worktrees checked out side by side (this branch was developed in one), so the realistic paste site is a feature worktree's terminal, and the result is that worktree installed into the primary venv with its editable install repointed away from the primary checkout. That is the same wrong-tree-in-the-venv outcome the refusal exists to prevent — the refusal would have handed the user a different route into it.

Fixed: both paths absolute and quoted, install target named explicitly.

git -C "<MAIN_REPO>" pull --ff-only && "<target_py>" -m pip install -e "<MAIN_REPO>"

One thing your report did not name, which the new test caught: the surrounding prose also said pip install -e .. Quoting only the command would have left the misleading form visible in the same message, so the prose now says "a reinstall cannot replace it" and the assertion is "pip install -e ." not in err — the message can no longer show the cwd-dependent form anywhere.


2. BLOCKING — server.py:3100, UTF-8 pin does not reach step subprocesses. Accepted.

Correct, and it is the half of round 1 I left undone. sys.stdout.reconfigure(...) rebinds only the runner's own stdout object. Each step is a separate process that inherits the same pipe and re-derives its encoding from the locale, and two steps are Python:

step interpreter affected
git fetch / git merge not Python no — emits bytes, parent decodes with errors="replace"
pip install -e Python yes — echoes the checkout path
npm ci not Python no
sys.executable -c ... build_and_stage Python yes — the staging child you named

So on a CJK home directory the runner survives its own prints and then dies inside pip or the staging child, which is the same defect one process further down.

Fixed as suggested — set in the environment, the only channel that reaches a child:

env = dict(st['env'])
env['PYTHONIOENCODING'] = 'utf-8:replace'
r = subprocess.run(st['argv'], cwd=cwd, env=env)

Set in the script's step loop rather than on each wrapped_steps entry, so it covers every step including ones added later, and _build_env()'s allowlist stays untouched. Assigned rather than setdefault: the reader's encoding is fixed at UTF-8, so a divergent inherited value would be the defect, not a preference worth preserving. errors="replace" matches the reconfigure call and the reader's line.decode(errors="replace").

New tests: the per-step pin reaches the env actually handed to subprocess.run (plus env=st['env'] asserted absent, so the old form cannot creep back), and the mechanism proven end to end — the same non-ASCII print dies with UnicodeEncodeError under PYTHONIOENCODING=cp1252 and survives with utf-8:replace.


Gates: test_dev_fleet_app.py 39 failed / 263 passed — the 39 are the pre-existing Windows-host baseline (identical on clean main), and none of my 11 tests is among them; passed rose 260 → 263 with the new ones. isort clean, flake8 clean, mypy 0 errors in the changed file. One commit, worktree clean, commit message updated to cover both changes.

The remaining red Backend Tests (Windows) (2) is the inherited test_dashboard_chat_pins.py::test_load_transient_io_error_preserves_existing_state failure, unchanged and untouched — attribution in #2445 (comment).

@github-actions github-actions Bot added readiness: passed Eligible automated validation passed for the current revision and removed readiness: checking Automated validation is still running labels Aug 10, 2026
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Disposition — Design Review (Fable 5) PASS, Watch item on 3ba147fc

The verdict is PASS and blocks nothing, but the Watch item is a real consequence and deserves an answer rather than silence.

On Windows single-checkout, Pull + Build is now permanently unavailable (the gateway always holds its own lock) until the out-of-process installer lands — that's the stated intent, but make sure #2297 stays alive, since this refusal is the feature's only path forward on that platform.

Accepted, and correctly stated. The reading is exactly right, including the "permanently" — this is not a transient condition that clears on retry. In a single-checkout Windows install the gateway is always served by the venv the sync would reinstall into, so the probe will find the lock on every click for as long as the gateway is running. The button does not degrade on that platform; it stops.

I am taking that deliberately, because the alternative is worse in a way the user cannot see coming. The pre-fix behaviour was not "the button works" — it was pip getting far enough to rename the dist-info aside and delete the editable .pth, then failing on the locked exe and rolling back neither. The install was left unable to import the package at all, and because the running process survives on already-imported modules, nothing surfaced until the next restart failed. A refusal that explains itself is strictly better than a success report in front of a gateway that will not come back.

#2297 stays open and is the tracking issue (verified OPEN as of this comment). It carries the full aftermath and three ranked fix options, and it is the right home for the real fix: an installer that runs out of process or at restart time, so nothing is holding the lock at the moment pip needs to rewrite the script. That is the only shape that restores the button on Windows, and it is deliberately out of scope here — it is a new execution model for the sync, not a guard on the existing one, and shipping it inside this PR would mean landing an unreviewed installer alongside the fix that stops the data loss.

So the split is intentional: this PR removes the way the button breaks the install, #2297 owns making the button work again. The refusal message names the blocking file and gives an absolute, quoted, cwd-independent command that performs the same sync from a terminal with the gateway stopped, so the platform is not left without a path — just without a one-click one.

No code change from this disposition.

@iamwhatever
iamwhatever merged commit 30cf8d8 into main Aug 10, 2026
58 of 59 checks passed
@iamwhatever
iamwhatever deleted the fix/win-devfleet-pip-lock branch August 10, 2026 03:53
@github-actions github-actions Bot removed the readiness: passed Eligible automated validation passed for the current revision label Aug 10, 2026
encomjp pushed a commit to encomjp/kirocrew-customapi that referenced this pull request Aug 22, 2026
…ve venv (kirodotdev#2445)

On Windows, Pull + Build's `pip install -e .` step can never succeed when the
gateway is served by the venv it reinstalls into -- the ordinary
single-checkout setup. Windows holds a mandatory lock on a running
executable's image, so pip cannot rewrite `Scripts\kirocrew.exe`:

    ERROR: Could not install packages due to an OSError: [WinError 32] The
    process cannot access the file because it is being used by another
    process: '...\.venv\scripts\kirocrew.exe'

The failed step is the smaller half of the problem. pip's uninstall is not
atomic: by the time it reaches the locked script it has already renamed the
dist-info aside and deleted the editable `.pth` that puts `src` on
`sys.path`, and it rolls back neither. The venv is left unable to import the
package at all, which also kills the console script the gateway is restarted
through. The running process survives on already-imported modules, so nothing
surfaces until the next restart fails -- one click turns a working install
into one that is fine right up until it is stopped.

Probe the console scripts pip would have to rewrite before doing anything, and
refuse the whole sync when one of them is write-locked. An `r+b` open is
non-destructive and discriminates correctly: a running executable refuses it
while every other script in the same directory opens fine. It cannot model
every way a delete can fail -- an opener that permits writes but denies
deletes would pass -- so a clean result means "no known blocker" rather than a
guarantee, and a miss simply leaves the previous behaviour.

Refusing the whole sync, rather than running fetch/merge and omitting only the
reinstall, is the part that matters. Merging without installing is not a safe
consolation prize: a revision that adds a dependency lands on disk with that
dependency absent, the run exits 0, and the UI then offers "restart gateway to
apply" -- so the next restart imports the new code, fails on the missing
import, and the gateway does not come back. Newly spawned subprocesses hit the
same gap without any restart. That is the same unstartable-gateway outcome the
guard exists to prevent, reached later and with a success report in front of
it. The checkout is left on a revision whose dependencies are satisfied, and
the error names both the blocking file and the exact commands to run with the
gateway stopped.

Those commands name an absolute, quoted repository path rather than `-e .`.
The refusal's whole purpose is to hand the user a recovery line, so that line
must not depend on where it is pasted: this project's normal working state is
several worktrees checked out at once, and `-e .` copied into a feature
worktree's terminal would install THAT tree into the primary venv and repoint
its editable install away from the primary checkout -- the same
wrong-tree-in-the-venv outcome the refusal exists to avoid. `git -C` already
pinned the pull, which made an unpinned `.` actively misleading, since the
line read as though it were cwd-independent. Quoting covers the spaces that
are routine in a Windows home directory.

Pin the whole pipe to UTF-8 while here. `_start_run` decodes that stream as
UTF-8, but a piped stdout on Windows encodes with the process locale
codepage -- a writer/reader mismatch that mangles non-ASCII output and can
raise UnicodeEncodeError before the first step. Reconfiguring the runner's own
stdout fixes only one of the two writers: each step is a separate process that
inherits the same pipe and re-derives its encoding from the locale, so the
Python steps -- pip, and the build-and-stage child -- would still encode a
non-ASCII checkout path with the codepage and die on it. The environment is
the only channel that reaches a child, so each step is spawned with
`PYTHONIOENCODING=utf-8:replace`; non-Python steps ignore it. errors="replace"
additionally guarantees no print can be fatal.

POSIX is untouched: an executing binary can be unlinked there, which is why
pip has always been able to replace it.

Tests cover the platform gate, a locked script, a writable script, an
unrelated locked executable that must not block the sync, a non-lock OSError
that must leave the judgement to pip, the refusal starting no run at all, the
full step list when nothing is locked, the recovery command carrying an
absolute quoted path in both its prose and its command, the encoding pinned
ahead of the first print, the per-step environment pin reaching the env
actually handed to `subprocess.run`, and both halves of the encoding mechanism
under a forced legacy codepage -- the runner's own print, and a child that
dies on a non-ASCII path without the variable and survives with it. The step
assertions parse the generated script's structured step list rather than
substring-matching its text, because the steps are embedded double-JSON-encoded
and a naive quoted match passes whether or not the step is present.
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.

Dev Fleet Pull + Build breaks the venv on Windows: pip cannot replace the locked kirocrew.exe and leaves the editable install stripped

2 participants