Skip to content

fix: refuse an unplaceable hunk, and resize a pool without stopping it - #139

Merged
thedancingdeveloper merged 2 commits into
mainfrom
fix/apply-safety-and-pool-resize
Aug 3, 2026
Merged

fix: refuse an unplaceable hunk, and resize a pool without stopping it#139
thedancingdeveloper merged 2 commits into
mainfrom
fix/apply-safety-and-pool-resize

Conversation

@thedancingdeveloper

Copy link
Copy Markdown
Contributor

Two defects, both about a gate telling the truth.

A hunk with no placement is refused before the ladder runs

@@ -0,0 +... says the old file has no lines at this point. Against a file
that exists with content in it, that is false about the tree — and the second
rung acted on it anyway: git apply refuses the header, --unidiff-zero
takes the absent context at face value and inserts at line 1.

Live, that put def multiply above calc.py's module docstring, which then
stopped being a docstring at all, and put two tests above their imports.
pytest -q passed throughout, because Python does not care where a string
literal sits, so the item reached the reviewer as a success carrying damage
the harness introduced rather than the model.

apply_diff's docstring already carried the principle — fuzzy is off by
default "because a misplaced hunk that reports success is worse than a
failure". This is the same trade one rung higher, refused before anything
runs because the patch is checkable against the tree without applying it.

The rescue the ladder exists for is untouched. A header that understates
its context still names a position and --unidiff-zero still forgives it;
test_a_hand_written_hunk_header_is_rescued and its strict-apply guard pass
unchanged. What is refused is the one header that names no position at all.

It costs one honest case, measured: git diff -U0 emits exactly this header
for a line prepended to a non-empty file. Nothing in the patch tells that
apart from a model that meant "somewhere in this file", and one of the two is
damage no gate can see — so both are refused, the failure says what a usable
header looks like, and a prepend with one line of context applies as before.

The two tests from #134 that used a -0,0 diff as their fixture now use a
context-free hunk that names a real position — still rescued by
--unidiff-zero, still proposed-text ≠ applied-change, which is what they
were about.

A running pool can be resized

max_workers was persisted and then ignored: a pool kept its original thread
count until the project was stopped and started again. The obstacle was one
stop event per pool — the only answers available were "keep every worker" and
"stop the project" — so each worker now carries its own switch.

Fleet.resize computes the delta from the workers not already leaving:
growing starts only the shortfall; shrinking sets the excess workers'
switches and joins them off the caller's thread, so an in-flight item
finishes at its own boundary and nothing is killed. running() keeps
reporting threads that are alive, which after a shrink is still the old
number until those items land, and the project stays running throughout.
Concurrent resizes serialise on the fleet lock and are idempotent; a worker
the OS refuses to start is recorded as a worker failure and leaves its
siblings working. A draining or stopped pool is not resized.

Registering a project update reconciles its live pool — the surface operators
already use for max_workers — and docs/USAGE.md says so.

Verification

Every new test was run against the unfixed code first and fails there:

  • with the pre-apply check removed, both -0,0 tests fail with
    applied == True, how == "git apply --unidiff-zero" — the bug, reproduced;
  • with resize reduced to "return the current size" (the old behaviour), the
    four fleet tests and the API integration test fail.

Threading tests use events and predicates, no bare sleeps: a HoldingExecutor
holds an item until released, so the shrink demonstrably lands mid-item.

uv run pytest -q, ruff check ., ruff format --check . and mypy . all
pass.

Closes #133. Closes #102.

sprooty added 2 commits August 3, 2026 23:15
`@@ -0,0 +...` says the old file has no lines at this point. Against a file
that exists with content in it that is false about the tree, and the second
rung of the ladder acted on it anyway: `git apply` refuses the header,
`--unidiff-zero` takes the absent context at face value and inserts the lines
at line 1.

Live, that put `def multiply` above `calc.py`'s module docstring, which then
stopped being a docstring at all -- `calc.__doc__` was None -- and put two
tests above `from calc import add, subtract`. `pytest -q` passed throughout,
because Python does not care where a string literal sits, so the item reached
the reviewer as a success carrying damage the harness introduced rather than
the model.

`apply_diff`'s own docstring already had the principle: fuzzy matching is off
by default "because a misplaced hunk that reports success is worse than a
failure". This is the same trade one rung higher up, and it is now refused
before anything runs rather than after the ladder, because the patch is
checkable against the tree without applying it.

The rescue the ladder exists for is untouched. A hunk header that understates
its context still names a position, and `--unidiff-zero` still forgives it --
the existing tests for that rung pass unchanged. What is refused is the one
header that names no position at all.

It costs one honest case, measured: `git diff -U0` emits exactly this header
for a line prepended to a file that has content. Nothing in the patch tells
that apart from a model that meant "somewhere in this file" and wrote the
emptiest header it knew, and one of the two is damage no gate can see. Both
are refused, the failure says what a usable header would look like, and a
prepend carrying one line of context is applied by the first rung as before.
`max_workers` was persisted per project and then ignored: a pool kept the
thread count it was started with until the project was stopped and started
again. So buying capacity for a busy project cost a drain/restart cycle --
tearing down agents that are mid-item, to apply an integer -- and reducing it
could not be done at all without coordinating around in-flight work.

The obstacle was one stop event per pool. With a single switch the only
answers available were "keep every worker" and "stop the project", so each
worker now carries its own, and the pool-wide halt sets all of them.

`Fleet.resize` computes the delta from the workers that are not already
leaving:

- growing starts only the shortfall;
- shrinking sets the excess workers' switches and joins them off the caller's
  thread, so an in-flight item finishes at its own boundary and nothing is
  killed -- the same reason `stop` drains rather than interrupts;
- the counts stay truthful: `running()` reports threads that are alive, which
  after a shrink is still the old number until those items land, and the
  project stays `running` throughout;
- concurrent resizes serialise on the fleet lock and are idempotent, so two
  callers asking for three workers get three, not six;
- a worker the OS refuses to start is recorded as a worker failure and leaves
  its healthy siblings working, rather than failing the whole call.

A draining or stopped pool is not resized. Adding a thread underneath a drain
outlives the stop that was supposed to have finished, because the finalizer
joining the pool was handed the old list.

Registering a project update reconciles its live pool, which is the surface
operators already use for `max_workers`; on a stopped project it changes
nothing until the next start, as registering anything does.
@thedancingdeveloper
thedancingdeveloper force-pushed the fix/apply-safety-and-pool-resize branch from f0a4c63 to 0ee17c6 Compare August 3, 2026 23:23
@thedancingdeveloper
thedancingdeveloper merged commit 66e92ac into main Aug 3, 2026
2 checks passed
@thedancingdeveloper
thedancingdeveloper deleted the fix/apply-safety-and-pool-resize branch August 3, 2026 23:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant