Skip to content

Commit 35f0c3e

Browse files
committed
fix(core): sweep before the batch starts, and check GitHub for blocked tasks (#1032)
Seventh codex pass. Before-first-tick: the reconciliation loop waits a full interval before its first pass, so 'the issue was already closed when the batch started' — the likeliest case there is — was missed for every task the serial loop reached inside the first 30 seconds, i.e. the first one. _start_reconciliation_thread now runs one sweep synchronously before spawning the thread, so the executor's skip check cannot race it. Costs one call per linked task, nothing when no task carries an issue. Blocked tasks: the GitHub check hung off an elif after the blocker branch, so a BLOCKED task with UNANSWERED blockers fell through both and was never asked about — despite being exactly the task worth asking about, since the human may have resolved the work on GitHub instead of answering. Now guarded on 'no local signal already fired and not DONE' rather than chained, with a test that a resolved blocker still wins and costs no API call.
1 parent 511cd82 commit 35f0c3e

4 files changed

Lines changed: 157 additions & 44 deletions

File tree

codeframe/core/conductor.py

Lines changed: 51 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -2205,46 +2205,59 @@ def _start_reconciliation_thread(
22052205
stop_event = threading.Event()
22062206
engine = ReconciliationEngine(workspace)
22072207

2208+
def _pass() -> None:
2209+
"""One reconciliation sweep. Raises nothing the caller must handle."""
2210+
try:
2211+
# Get currently active task IDs from the batch
2212+
active_ids = [
2213+
tid for tid in batch.task_ids
2214+
if batch.results.get(tid) is None
2215+
or batch.results.get(tid) == "RUNNING"
2216+
]
2217+
if not active_ids:
2218+
return
2219+
2220+
result = engine.check_all_active(active_ids)
2221+
if result.changes_detected:
2222+
with _active_processes_lock:
2223+
procs = _active_processes.get(batch.id, {})
2224+
engine.apply_changes(result, batch, procs)
2225+
2226+
# Emit events for changes
2227+
for tid in result.tasks_skipped:
2228+
events.emit_for_workspace(
2229+
workspace,
2230+
events.EventType.RECONCILIATION_TASK_SKIPPED,
2231+
{"batch_id": batch.id, "task_id": tid},
2232+
)
2233+
for tid in result.tasks_requeued:
2234+
events.emit_for_workspace(
2235+
workspace,
2236+
events.EventType.RECONCILIATION_TASK_REQUEUED,
2237+
{"batch_id": batch.id, "task_id": tid},
2238+
)
2239+
if result.errors:
2240+
for err in result.errors:
2241+
events.emit_for_workspace(
2242+
workspace,
2243+
events.EventType.RECONCILIATION_ERROR,
2244+
{"batch_id": batch.id, "error": err},
2245+
)
2246+
except Exception as exc:
2247+
logger.warning("Reconciliation loop error: %s", exc)
2248+
22082249
def _loop() -> None:
22092250
while not stop_event.wait(timeout=interval_seconds):
2210-
try:
2211-
# Get currently active task IDs from the batch
2212-
active_ids = [
2213-
tid for tid in batch.task_ids
2214-
if batch.results.get(tid) is None
2215-
or batch.results.get(tid) == "RUNNING"
2216-
]
2217-
if not active_ids:
2218-
continue
2219-
2220-
result = engine.check_all_active(active_ids)
2221-
if result.changes_detected:
2222-
with _active_processes_lock:
2223-
procs = _active_processes.get(batch.id, {})
2224-
engine.apply_changes(result, batch, procs)
2225-
2226-
# Emit events for changes
2227-
for tid in result.tasks_skipped:
2228-
events.emit_for_workspace(
2229-
workspace,
2230-
events.EventType.RECONCILIATION_TASK_SKIPPED,
2231-
{"batch_id": batch.id, "task_id": tid},
2232-
)
2233-
for tid in result.tasks_requeued:
2234-
events.emit_for_workspace(
2235-
workspace,
2236-
events.EventType.RECONCILIATION_TASK_REQUEUED,
2237-
{"batch_id": batch.id, "task_id": tid},
2238-
)
2239-
if result.errors:
2240-
for err in result.errors:
2241-
events.emit_for_workspace(
2242-
workspace,
2243-
events.EventType.RECONCILIATION_ERROR,
2244-
{"batch_id": batch.id, "error": err},
2245-
)
2246-
except Exception as exc:
2247-
logger.warning("Reconciliation loop error: %s", exc)
2251+
_pass()
2252+
2253+
# One sweep before the executor launches anything (#1032). The loop waits a
2254+
# full interval before its first pass, so without this the likeliest case
2255+
# of all — the linked issue was ALREADY closed when the batch started —
2256+
# was missed for every task reached inside the first 30 seconds, which in a
2257+
# serial batch means the first one. Synchronous rather than an early tick
2258+
# on the thread, so the executor's skip check cannot race it. Costs one
2259+
# call per linked task, and nothing at all when no task carries an issue.
2260+
_pass()
22482261

22492262
thread = threading.Thread(target=_loop, daemon=True, name=f"reconcile-{batch.id[:8]}")
22502263
thread.start()

codeframe/core/reconciliation.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -256,12 +256,19 @@ def check_task(self, task_id: str) -> list[ExternalStateChange]:
256256

257257
# The task's linked GitHub issue was closed by someone outside this
258258
# batch — an external completion, exactly like a task marked DONE in
259-
# the UI (#1032). Only asked for tasks that are not already finished
260-
# locally: the DONE branch above has already fired for those, so a
261-
# lookup would be a wasted call on every tick for the rest of the run.
262-
# GitHubIssueState never raises and answers False when it does not
263-
# know, so a GitHub outage leaves the batch exactly as it was.
264-
elif self._issue_state.is_closed(task):
259+
# the UI (#1032).
260+
#
261+
# Deliberately NOT an `elif` off the blocker branch: a BLOCKED task
262+
# whose blockers are still unanswered falls through both branches
263+
# above, and that task is exactly the one worth asking about — the
264+
# human may have resolved the work on GitHub instead of answering.
265+
#
266+
# Guarded on `not changes` so it costs nothing when a local signal
267+
# already fired, and on `not DONE` because the first branch has already
268+
# handled those — otherwise it would be a wasted call every tick for
269+
# the rest of the run. GitHubIssueState never raises and answers False
270+
# when it does not know, so an outage leaves the batch as it was.
271+
if not changes and task.status != TaskStatus.DONE and self._issue_state.is_closed(task):
265272
changes.append(ExternalStateChange(
266273
task_id=task_id,
267274
change_type="completed",

tests/core/test_external_completion_honored_1032.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,62 @@ def fake_subprocess(ws, task_id, batch_id, **kwargs):
290290
)
291291

292292

293+
class TestTheFirstTaskIsCheckedBeforeItStarts:
294+
"""The reconciliation thread waits a full interval before its first pass.
295+
296+
So "the issue was already closed when the batch started" — the likeliest
297+
case of all — was missed for every task the serial loop reached inside the
298+
first 30 seconds, which in practice means the first one.
299+
"""
300+
301+
def test_a_pass_runs_before_the_loop_launches_anything(
302+
self, workspace, three_tasks, monkeypatch
303+
):
304+
executed = []
305+
monkeypatch.setattr(
306+
conductor, "_execute_task_subprocess",
307+
lambda ws, tid, bid, **kw: executed.append(tid) or "COMPLETED",
308+
)
309+
310+
# A reconciler that would find the first task's issue closed. Real
311+
# thread start is left intact except for the interval, so this pins the
312+
# *synchronous* pass, not a lucky race with the daemon.
313+
from codeframe.core.reconciliation import ExternalStateChange
314+
315+
target = three_tasks[0]
316+
317+
class StubEngine:
318+
def __init__(self, workspace):
319+
pass
320+
321+
def check_all_active(self, ids):
322+
from codeframe.core.reconciliation import ReconciliationResult
323+
324+
r = ReconciliationResult()
325+
if target in ids:
326+
r.changes_detected.append(
327+
ExternalStateChange(target, "completed", "github", {})
328+
)
329+
return r
330+
331+
def apply_changes(self, result, batch, procs):
332+
for c in result.changes_detected:
333+
batch.results[c.task_id] = "COMPLETED"
334+
result.tasks_skipped.append(c.task_id)
335+
336+
monkeypatch.setattr(
337+
"codeframe.core.reconciliation.ReconciliationEngine", StubEngine
338+
)
339+
340+
batch = _make_batch(workspace, three_tasks)
341+
conductor._execute_serial(workspace, batch)
342+
343+
assert target not in executed, (
344+
"the first task ran before reconciliation ever looked at it"
345+
)
346+
assert batch.results[target] == "COMPLETED"
347+
348+
293349
class TestEveryWriteSiteUsesTheGuard:
294350
def test_no_executor_writes_batch_results_directly(self):
295351
"""A guard that four of five call sites bypass guards nothing.

tests/core/test_github_issue_reconciliation_1032.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,43 @@ class Batch:
167167
assert batch.results["t1"] == "COMPLETED"
168168
assert result.tasks_skipped == ["t1"]
169169

170+
def test_a_blocked_task_with_open_blockers_still_checks_github(self, monkeypatch):
171+
"""The blocker branch used to swallow this case: a BLOCKED task whose
172+
blockers are unanswered never reached the GitHub check, so closing the
173+
issue left the task stuck and re-runnable forever."""
174+
task = make_task(status=TaskStatus.BLOCKED)
175+
unanswered = type("B", (), {"status": type("S", (), {"value": "OPEN"})()})()
176+
monkeypatch.setattr("codeframe.core.tasks.get", lambda ws, tid: task)
177+
monkeypatch.setattr(
178+
"codeframe.core.blockers.list_for_task", lambda ws, tid: [unanswered]
179+
)
180+
engine = ReconciliationEngine(
181+
workspace=object(),
182+
issue_state=GitHubIssueState(fetch=FakeGitHub(state="closed"), pat="tok"),
183+
)
184+
185+
changes = engine.check_task("t1")
186+
187+
assert [(c.change_type, c.source) for c in changes] == [("completed", "github")]
188+
189+
def test_a_resolved_blocker_still_wins_over_the_github_check(self, monkeypatch):
190+
"""Don't let the new check displace the existing signal."""
191+
task = make_task(status=TaskStatus.BLOCKED)
192+
answered = type("B", (), {"status": type("S", (), {"value": "ANSWERED"})()})()
193+
monkeypatch.setattr("codeframe.core.tasks.get", lambda ws, tid: task)
194+
monkeypatch.setattr(
195+
"codeframe.core.blockers.list_for_task", lambda ws, tid: [answered]
196+
)
197+
gh = FakeGitHub(state="closed")
198+
engine = ReconciliationEngine(
199+
workspace=object(), issue_state=GitHubIssueState(fetch=gh, pat="tok")
200+
)
201+
202+
changes = engine.check_task("t1")
203+
204+
assert [c.change_type for c in changes] == ["blocker_resolved"]
205+
assert gh.calls == [], "spent a call on a task that already had a change"
206+
170207
def test_already_done_task_is_not_charged_a_github_call(self, monkeypatch):
171208
"""The local DONE check already fires; asking GitHub would be a wasted
172209
call on every tick for every finished task."""

0 commit comments

Comments
 (0)