Hauling/JobRunner: fail unreachable walks; pre-check haul reachability

Two compounding bugs made hauling appear broken when targets were behind
walls. User report: 'i set a stockpile and there is stuff to move' — items
sat indefinitely.

JobRunner._tick_walk treated 'path is empty' (unreachable) by marking the
walk toil done and silently advancing to the next toil. Pickup/deposit
then ran at the pawn's CURRENT tile instead of the intended target —
'Bram pickup: no item at (44, 25)' for an item that lived at (45, 21).
The job 'completed' wrongly. Now an unreachable walk cancel_job()'s,
letting Decision pick something else next tick.

HaulingProvider didn't pre-check reachability before handing out a job.
With the JobRunner fix alone, Decision would have re-picked the same
unreachable haul every tick (busy-spin at 20 Hz). Now the item loop and
corpse loop both skip targets where find_path is empty from pawn.tile.
Cost: ~10 us pathfind per candidate; trivial at MVP scale.

Verified MCP runtime: bread at (45, 21) (reachable) hauled end-to-end to
the stockpile at (15, 62). Bread at (50, 21) (unreachable behind the
cabin wall arrangement) correctly skipped — no job assigned, no busy
spin in the log. Bram completed the haul and picked up his next job
(Harvest wheat) naturally.

Note: the JobRunner unreachable-cancel fix also helps any other provider
whose walk_to leg fails — chop/mine/construction were silently 'finishing'
the same way when targets walled off. They now cancel cleanly too. Their
providers don't yet pre-check reachability, so they could cancel-loop on
unreachable targets if nothing else is queued — left for a followup once
a real case surfaces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-05-12 13:58:47 +01:00
parent e7a2407af2
commit f435c3c467
2 changed files with 16 additions and 2 deletions

View file

@ -65,6 +65,12 @@ func find_best_for(pawn) -> Job:
# Skip items another pawn has already claimed (target_node = item node). # Skip items another pawn has already claimed (target_node = item node).
if Job.is_target_taken_by_other(item, pawn): if Job.is_target_taken_by_other(item, pawn):
continue continue
# Reachability gate — don't hand out a haul this pawn can't path to.
# Without this, JobRunner cancels the job on the walk toil and Decision
# reassigns it next tick, busy-spinning at 20 Hz with no progress.
# Pre-check is O(pathfinder.find_path) ~10 μs; cheap at MVP scale.
if pawn.tile != item.tile and World.pathfinder.find_path(pawn.tile, item.tile).is_empty():
continue
# Find the best destination for this item type + priority. # Find the best destination for this item type + priority.
var dest = _find_best_destination_for(item) var dest = _find_best_destination_for(item)
@ -111,6 +117,9 @@ func find_best_for(pawn) -> Job:
# Skip corpses another pawn has already claimed. # Skip corpses another pawn has already claimed.
if Job.is_target_taken_by_other(corpse, pawn): if Job.is_target_taken_by_other(corpse, pawn):
continue continue
# Reachability gate — same rationale as the item loop above.
if pawn.tile != corpse.tile and World.pathfinder.find_path(pawn.tile, corpse.tile).is_empty():
continue
var dest = _find_best_destination_for(corpse) var dest = _find_best_destination_for(corpse)
if dest == null: if dest == null:

View file

@ -179,11 +179,16 @@ func _tick_walk(t) -> void:
return return
var path: Array[Vector2i] = pathfinder.find_path(pawn.tile, dest) var path: Array[Vector2i] = pathfinder.find_path(pawn.tile, dest)
if path.is_empty(): if path.is_empty():
# Unreachable — abort the entire job so Decision can pick something
# else next tick. Previously this marked the walk toil done and the
# subsequent toils (pickup, deposit, etc.) ran at the wrong tile,
# making haul/pickup silently fail with "no item at <pawn.tile>".
Audit.log( Audit.log(
"job_runner", "job_runner",
"%s unreachable: %s%s" % [pawn.pawn_name, pawn.tile, dest] "%s unreachable: %s%s (canceling job '%s')" %
[pawn.pawn_name, pawn.tile, dest, job.label]
) )
t.done = true cancel_job()
return return
pawn.walk_along_path(path) pawn.walk_along_path(path)
t.data["started"] = true t.data["started"] = true