Skip to content

feat(core): continuous reconciliation during batch execution - #439

Merged
frankbria merged 1 commit into
mainfrom
feature/issue-403-continuous-reconciliation
Mar 13, 2026
Merged

feat(core): continuous reconciliation during batch execution#439
frankbria merged 1 commit into
mainfrom
feature/issue-403-continuous-reconciliation

Conversation

@frankbria

@frankbria frankbria commented Mar 13, 2026

Copy link
Copy Markdown
Owner

Summary

Implements #403: Continuous Reconciliation During Batch Execution (+ sub-issues #404, #405, #406)

Adds periodic checking of tasks for external state changes during batch execution with automatic adjustments.

  • ReconciliationEngine (codeframe/core/reconciliation.py): Standalone checker that detects completed tasks, resolved blockers, and GitHub issue closures
  • GitHub Issue Sync (codeframe/git/github_issue_sync.py): Synchronous httpx client for checking issue state, injectable into the engine
  • Conductor Integration: Daemon reconciliation thread in both _execute_serial and _execute_parallel, configurable interval (default 30s)
  • Task.github_issue_number field + DB migration for linking tasks to GitHub issues
  • Event types: RECONCILIATION_STARTED, RECONCILIATION_TASK_SKIPPED, RECONCILIATION_TASK_REQUEUED, RECONCILIATION_ERROR

Acceptance Criteria

  • Running/queued tasks checked for external state changes every N seconds (configurable)
  • Tasks closed externally -> agent killed, task skipped in batch
  • Tasks completed externally -> agent killed, task marked DONE
  • Blockers resolved externally -> blocked tasks re-queued
  • GitHub issue state changes reflected in batch execution
  • Reconciliation failures logged but don't crash the batch
  • Integration tests for external state change -> batch adjustment

Test Plan

  • 27 unit tests covering engine, GitHub sync, config, and apply_changes
  • All 1734 core tests passing (0 regressions)
  • Ruff linting clean

Implementation Notes

  • ReconciliationEngine is stateless: Call check_all_active() to scan, apply_changes() to act
  • GitHub checker is optional: Injected via callable, skipped if no GITHUB_TOKEN
  • Daemon thread uses existing locks: _active_processes_lock in conductor for safe process termination
  • DB migration: github_issue_number INTEGER column added via existing _init_database() migration pattern

Closes #403, #404, #405, #406

Summary by CodeRabbit

Release Notes

  • New Features
    • Added background reconciliation to monitor task states during batch execution
    • Integrated GitHub issue tracking for automatic external state synchronization
    • Tasks automatically requeue when blockers resolve or skip when externally completed
    • Configurable reconciliation check interval via environment settings

Implement periodic checking of tasks for external state changes during
batch execution, with automatic adjustment of running batches.

- ReconciliationEngine: standalone checker for task state changes
  - Detects: completed externally, blocker resolved, GitHub issue closed
  - All errors caught per-task (never crashes the batch)
- GitHub issue state sync via synchronous httpx client
  - build_github_task_checker() factory for injection into engine
  - Skips tasks without github_issue_number
- Conductor integration: daemon reconciliation thread in serial/parallel
  - Configurable interval (default 30s) via reconciliation_interval_seconds
  - Uses existing _active_processes dict for subprocess termination
- Task.github_issue_number field + DB migration
- RECONCILIATION_* event types for observability
- 27 unit tests covering engine, sync, config, and apply_changes

Closes #403, #404, #405, #406
@coderabbitai

coderabbitai Bot commented Mar 13, 2026

Copy link
Copy Markdown
Contributor

Walkthrough

Implements continuous background reconciliation during batch execution. Periodically checks active task states for external changes—GitHub issues closed, manual task completion, blockers resolved—and adjusts running batches accordingly. Introduces reconciliation engine, event types, database schema updates, and GitHub integration.

Changes

Cohort / File(s) Summary
Configuration & Event Infrastructure
codeframe/core/config.py, codeframe/core/events.py
Added reconciliation_interval_seconds config field (default 30s) and four new event type constants (RECONCILIATION_STARTED, RECONCILIATION_TASK_SKIPPED, RECONCILIATION_TASK_REQUEUED, RECONCILIATION_ERROR).
Core Reconciliation Engine
codeframe/core/reconciliation.py
New module implementing stateless ReconciliationEngine with check/apply phases. Includes ExternalStateChange and ReconciliationResult dataclasses. Engine detects completed/closed tasks and resolved blockers, applies state changes to batches, and terminates relevant processes.
Conductor Integration
codeframe/core/conductor.py
Added _start_reconciliation_thread() private function and lifecycle hooks in _execute_serial, _execute_parallel, and _execute_serial_resume. Thread runs as daemon, periodically invokes ReconciliationEngine, and emits reconciliation events.
Data Model Updates
codeframe/core/tasks.py, codeframe/core/workspace.py
Added optional github_issue_number field to Task dataclass and corresponding INTEGER column to tasks table schema with null-safe migration.
GitHub State Synchronization
codeframe/git/github_issue_sync.py
New module providing synchronous GitHub API utilities: get_issue_state() fetches issue state via HTTP, build_github_task_checker() factory returns checker callable for ReconciliationEngine integration.
Reconciliation Test Suite
tests/core/test_reconciliation.py
Comprehensive unit tests covering reconciliation config, engine behavior (task detection, change application, error handling), GitHub checker integration, and event type constants.

Sequence Diagram

sequenceDiagram
    actor Conductor
    participant Thread as Reconciliation<br/>Thread
    participant Engine as Reconciliation<br/>Engine
    participant GHChecker as GitHub<br/>Checker
    participant Batch as Batch<br/>Process
    participant OS as OS<br/>Process

    Conductor->>Thread: Start daemon thread<br/>(interval=30s)
    
    loop Every 30 seconds
        Thread->>Engine: check_all_active(active_ids)
        
        par Check Task 1
            Engine->>Engine: check_task(id)
            alt Task status = DONE
                Engine->>Engine: Record "completed"
            else Task status = BLOCKED
                Engine->>Engine: Record "blocker_resolved"
            end
        and Check Task 2 (GitHub)
            Engine->>GHChecker: invoke_github_checker(task)
            GHChecker-->>Engine: ExternalStateChange<br/>(if closed)
        end
        
        Engine-->>Thread: ReconciliationResult
        
        Thread->>Engine: apply_changes(result, batch,<br/>active_processes)
        
        alt "completed" or "closed"
            Engine->>OS: Terminate process
            Engine->>Batch: Mark COMPLETED/FAILED
        else "blocker_resolved"
            Engine->>Batch: Re-queue task (READY)
        end
        
        Thread->>Batch: Emit reconciliation events
    end
    
    Conductor->>Thread: Signal stop event
    Thread-->>Conductor: Thread exits
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 Behold! A thread hops through the batch so tight,
Checking tasks each tick—GitHub closed? Mark done, alright!
Blockers resolved? Re-queue with cheer, no fright!
External changes caught mid-run, the loop gets it right.
Reconciliation dances on—the work flows ever light! 🌙

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 27.91% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main feature: adding continuous reconciliation functionality during batch execution, which aligns perfectly with the primary objective and changes.
Linked Issues check ✅ Passed The PR implements all key objectives from linked issues #403-406: reconciliation engine with check/apply methods, GitHub issue sync, conductor integration with configurable intervals, event types, and comprehensive tests.
Out of Scope Changes check ✅ Passed All changes are directly scoped to reconciliation implementation: new reconciliation engine, GitHub sync module, conductor integration, config addition, task model enhancement, and comprehensive tests.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/issue-403-continuous-reconciliation
📝 Coding Plan
  • Generate coding plan for human review comments

Comment @coderabbitai help to get the list of available commands and usage tips.

@claude

claude Bot commented Mar 13, 2026

Copy link
Copy Markdown

Code Review — PR #439: Continuous Reconciliation During Batch Execution

The overall shape of this feature is solid. The architecture is clean and headless, the injection-based github_checker pattern is flexible, and test coverage is thorough. A few issues need attention before merge.


1. Thread safety: batch.results accessed without a lock (Medium)

_start_reconciliation_thread reads and writes batch.results from the daemon thread while _execute_serial / _execute_parallel also write to it from the main thread. Neither path holds a lock when touching this dict.

# In _loop() — no lock:
active_ids = [
    tid for tid in batch.task_ids
    if batch.results.get(tid) is None or batch.results.get(tid) == "RUNNING"
]

And apply_changes writes batch.results[change.task_id] without a lock too. The _active_processes_lock is only acquired for process termination, not for result dict access. A separate _batch_results_lock (or reusing an existing lock) should protect batch.results in both paths.


2. False-positive detection for normally-completed tasks (High)

check_task reports any TaskStatus.DONE task as an external change:

if task.status == TaskStatus.DONE:
    changes.append(ExternalStateChange(task_id=..., change_type="completed", ...))

But the reconciliation thread runs every 30 seconds throughout batch execution. A task that the agent finished normally (IN_PROGRESS → DONE) will be flagged as "externally completed" on the next reconciliation tick. apply_changes will then try to terminate the (already-dead) process and fire RECONCILIATION_TASK_SKIPPED events. The proc.terminate() silently fails, but the spurious event emission and tasks_skipped entry are misleading.

Fix: track which tasks were originally DONE before the batch started (or at thread launch time), and only flag tasks that transition to DONE after the batch began executing them.


3. GitHub "closed" issue maps to "FAILED" (Low-Medium)

status = "COMPLETED" if change.change_type == "completed" else "FAILED"

A GitHub issue closed externally is often closed because it was resolved — that's a success, not a failure. Setting batch result to "FAILED" for a "closed" change seems incorrect; "SKIPPED" would better match the PR description ("Tasks closed externally → agent killed, task skipped in batch").


4. RECONCILIATION_STARTED event is defined but never emitted

EventType.RECONCILIATION_STARTED is listed in the acceptance criteria and defined in events.py, but _start_reconciliation_thread never emits it. Either emit it at thread launch or remove the event type so there's no dead constant.


5. Circular import in github_issue_sync.py (Low)

ExternalStateChange is imported inside the checker closure on every call:

def checker(task_id: str, task) -> list:
    from codeframe.core.reconciliation import ExternalStateChange  # every call
    ...

Move this to a top-level import — there's no circular dependency risk since reconciliation.py doesn't import github_issue_sync.py.


6. reconciliation_interval_seconds=0 creates a spin loop (Low)

stop_event.wait(timeout=0) returns immediately, so setting the interval to 0 would cause the reconciliation thread to spin at 100% CPU. Add a guard:

interval = max(1, env_config.reconciliation_interval_seconds if env_config else 30)

7. Weak assertion in test_apply_changes_catches_errors (Low)

assert len(result.errors) >= 0  # always true

This test doesn't verify the error-handling path at all. It should assert something meaningful, e.g. assert result.tasks_skipped == [] (no skip on error) or check the errors list is non-empty after the OSError.


Minor Nits

  • Variable naming inconsistency in conductor.py: _execute_serial uses load_environment_config / env_config / reconcile_stop, while _execute_parallel uses _load_env_config / _env_config_p / _reconcile_stop_p. The underscore-prefixed names with _p suffix look like a copy-paste. Using consistent names would improve readability.
  • build_github_task_checker lacks a return type annotation — -> Callable[[str, Any], list[ExternalStateChange]] would make the interface explicit.
  • No thread.join(timeout=...) after reconcile_stop.set(). If the reconciliation thread is mid-cycle when the batch ends, it continues running until the next wait() timeout elapses. daemon=True prevents hangs on process exit, but an explicit short-timeout join would make teardown deterministic in tests.

Summary

The design is sound and the test coverage for the happy path is good. The main actionable items before merge are #2 (false-positive detection) and #1 (lock on batch.results) — both can cause real behavioral issues at runtime. The rest are lower-risk but worth addressing.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
codeframe/core/workspace.py (1)

379-381: ⚠️ Potential issue | 🟠 Major

Missing github_issue_number migration in _ensure_schema_upgrades.

The github_issue_number column is added in _init_database (line 137), but _ensure_schema_upgrades does not include this column. Existing workspaces that upgrade will not have this column, causing the _row_to_task function to fail when checking len(row) > 13 since the column won't exist in the database.

🐛 Proposed fix to add migration

Add after line 381:

         if "uncertainty_level" not in task_columns:
             cursor.execute("ALTER TABLE tasks ADD COLUMN uncertainty_level TEXT")
             conn.commit()
+        if "github_issue_number" not in task_columns:
+            cursor.execute("ALTER TABLE tasks ADD COLUMN github_issue_number INTEGER")
+            conn.commit()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@codeframe/core/workspace.py` around lines 379 - 381, The schema upgrade
function _ensure_schema_upgrades is missing the migration that adds the
github_issue_number column (added in _init_database), so existing databases will
lack this column and _row_to_task will break; update _ensure_schema_upgrades to
check task_columns for "github_issue_number" and, if absent, execute an ALTER
TABLE tasks ADD COLUMN github_issue_number TEXT (similar to the existing
uncertainty_level migration) and commit the connection so the tasks table
matches what _row_to_task expects.
codeframe/core/tasks.py (1)

138-145: ⚠️ Potential issue | 🟠 Major

SELECT queries do not include github_issue_number column.

The _row_to_task function attempts to read row[13] for github_issue_number, but the SELECT queries throughout the file (e.g., lines 140-141, 176-177, 187-188) only select 13 columns (indices 0-12). This means len(row) > 13 will always be false, and github_issue_number will always be None even when the column has a value in the database.

Update all SELECT queries to include github_issue_number:

🐛 Proposed fix for SELECT queries
     cursor.execute(
         """
-        SELECT id, workspace_id, prd_id, title, description, status, priority, depends_on, estimated_hours, complexity_score, uncertainty_level, created_at, updated_at
+        SELECT id, workspace_id, prd_id, title, description, status, priority, depends_on, estimated_hours, complexity_score, uncertainty_level, created_at, updated_at, github_issue_number
         FROM tasks
         WHERE workspace_id = ? AND id = ?
         """,

Apply similarly to all other SELECT queries in list_tasks (lines 176-177 and 187-188).

Also applies to: 673-673

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@codeframe/core/tasks.py` around lines 138 - 145, The SELECT statements that
build Task rows (e.g., the query in the get-by-id block and those used by
list_tasks) are missing the github_issue_number column, so _row_to_task (which
expects row[13] for github_issue_number) always sees None; update every SELECT
in codeframe/core/tasks.py that selects task columns (including the query around
cursor.execute in the single-task fetch and the queries used in list_tasks) to
include github_issue_number in the column list in the same position as
_row_to_task expects, ensuring the row length and indices align with
_row_to_task's mapping.
🧹 Nitpick comments (4)
codeframe/git/github_issue_sync.py (1)

53-53: Consider adding a return type annotation for the checker.

The inner checker function returns list but could specify list[ExternalStateChange] for better type safety and IDE support.

♻️ Proposed type annotation
-    def checker(task_id: str, task) -> list:
+    def checker(task_id: str, task) -> list["ExternalStateChange"]:
         from codeframe.core.reconciliation import ExternalStateChange

Note: Use a string literal for forward reference since ExternalStateChange is imported inside the function.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@codeframe/git/github_issue_sync.py` at line 53, Add a precise return
annotation to the inner function checker by changing its signature to return
list["ExternalStateChange"]; specifically update def checker(task_id: str, task)
-> list["ExternalStateChange"]: so the return type is a forward-referenced list
of ExternalStateChange (keep the existing import of ExternalStateChange inside
the function and use the string literal to avoid forward-reference issues).
codeframe/core/reconciliation.py (1)

132-137: Consider stronger typing for batch parameter.

The batch: object type hint is intentionally weak to avoid circular imports with conductor.py. This is acceptable, but consider using a Protocol or TYPE_CHECKING import for better type safety if the interface stabilizes.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@codeframe/core/reconciliation.py` around lines 132 - 137, The apply_changes
method's batch parameter is currently typed as object which is too weak; update
apply_changes(self, result: ReconciliationResult, batch: object,
active_processes: dict) to use a stricter interface by defining a lightweight
Protocol describing the attributes/methods the code expects (or use a
TYPE_CHECKING import from conductor to import the concrete Batch type only for
type checking), then annotate batch with that Protocol or the TYPE_CHECKING-only
type; reference the apply_changes function and ReconciliationResult so callers
and implementers get stronger static checks without introducing circular runtime
imports.
codeframe/core/conductor.py (1)

878-1030: _execute_serial_resume does not start a reconciliation thread.

Unlike _execute_serial and _execute_parallel, the _execute_serial_resume function does not start/stop a reconciliation thread. This means resumed batches won't benefit from continuous reconciliation for external state changes.

Consider adding reconciliation thread lifecycle to _execute_serial_resume for consistency.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@codeframe/core/conductor.py` around lines 878 - 1030, _execute_serial_resume
currently omits the reconciliation thread lifecycle: mirror the pattern used in
_execute_serial/_execute_parallel by starting the reconciliation thread at the
top of _execute_serial_resume (e.g., call
_start_reconciliation_thread(workspace, batch.id) and capture the handle) and
ensure it's stopped in a finally block (e.g., call
_stop_reconciliation_thread(handle) or equivalent) so the thread runs during
task retries and is cleaned up on exit/cancel; wrap the main loop in try/finally
to guarantee _stop_reconciliation_thread is invoked even on errors or
cancellation.
tests/core/test_reconciliation.py (1)

324-326: Weak assertion does not verify error handling behavior.

The assertion assert len(result.errors) >= 0 is always true and doesn't verify that the error was actually caught or logged. Consider asserting that no exception propagates (which is implicitly tested) or explicitly check that errors are recorded.

♻️ Proposed stronger assertion
         # Should not raise
         engine.apply_changes(result, batch, active_processes)
-        assert len(result.errors) >= 0  # Error may or may not be logged
+        # Verify the method completed without raising
+        # The OSError from terminate() is silently caught per implementation
+        assert mock_proc.terminate.called
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/core/test_reconciliation.py` around lines 324 - 326, Replace the no-op
assertion after engine.apply_changes(result, batch, active_processes) with an
explicit check on the error container: assert that result.errors is a list
(e.g., assert isinstance(result.errors, list)) and then assert the expected
semantic outcome—either assert len(result.errors) > 0 if you expect an error to
be recorded or assert result.errors == [] if you expect no errors; this makes
the test verify actual error-handling behavior rather than a tautology.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@codeframe/core/tasks.py`:
- Around line 138-145: The SELECT statements that build Task rows (e.g., the
query in the get-by-id block and those used by list_tasks) are missing the
github_issue_number column, so _row_to_task (which expects row[13] for
github_issue_number) always sees None; update every SELECT in
codeframe/core/tasks.py that selects task columns (including the query around
cursor.execute in the single-task fetch and the queries used in list_tasks) to
include github_issue_number in the column list in the same position as
_row_to_task expects, ensuring the row length and indices align with
_row_to_task's mapping.

In `@codeframe/core/workspace.py`:
- Around line 379-381: The schema upgrade function _ensure_schema_upgrades is
missing the migration that adds the github_issue_number column (added in
_init_database), so existing databases will lack this column and _row_to_task
will break; update _ensure_schema_upgrades to check task_columns for
"github_issue_number" and, if absent, execute an ALTER TABLE tasks ADD COLUMN
github_issue_number TEXT (similar to the existing uncertainty_level migration)
and commit the connection so the tasks table matches what _row_to_task expects.

---

Nitpick comments:
In `@codeframe/core/conductor.py`:
- Around line 878-1030: _execute_serial_resume currently omits the
reconciliation thread lifecycle: mirror the pattern used in
_execute_serial/_execute_parallel by starting the reconciliation thread at the
top of _execute_serial_resume (e.g., call
_start_reconciliation_thread(workspace, batch.id) and capture the handle) and
ensure it's stopped in a finally block (e.g., call
_stop_reconciliation_thread(handle) or equivalent) so the thread runs during
task retries and is cleaned up on exit/cancel; wrap the main loop in try/finally
to guarantee _stop_reconciliation_thread is invoked even on errors or
cancellation.

In `@codeframe/core/reconciliation.py`:
- Around line 132-137: The apply_changes method's batch parameter is currently
typed as object which is too weak; update apply_changes(self, result:
ReconciliationResult, batch: object, active_processes: dict) to use a stricter
interface by defining a lightweight Protocol describing the attributes/methods
the code expects (or use a TYPE_CHECKING import from conductor to import the
concrete Batch type only for type checking), then annotate batch with that
Protocol or the TYPE_CHECKING-only type; reference the apply_changes function
and ReconciliationResult so callers and implementers get stronger static checks
without introducing circular runtime imports.

In `@codeframe/git/github_issue_sync.py`:
- Line 53: Add a precise return annotation to the inner function checker by
changing its signature to return list["ExternalStateChange"]; specifically
update def checker(task_id: str, task) -> list["ExternalStateChange"]: so the
return type is a forward-referenced list of ExternalStateChange (keep the
existing import of ExternalStateChange inside the function and use the string
literal to avoid forward-reference issues).

In `@tests/core/test_reconciliation.py`:
- Around line 324-326: Replace the no-op assertion after
engine.apply_changes(result, batch, active_processes) with an explicit check on
the error container: assert that result.errors is a list (e.g., assert
isinstance(result.errors, list)) and then assert the expected semantic
outcome—either assert len(result.errors) > 0 if you expect an error to be
recorded or assert result.errors == [] if you expect no errors; this makes the
test verify actual error-handling behavior rather than a tautology.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 464b6443-9d20-486b-b37f-d0fb1151ade5

📥 Commits

Reviewing files that changed from the base of the PR and between 05606ff and 2b9e4de.

📒 Files selected for processing (8)
  • codeframe/core/conductor.py
  • codeframe/core/config.py
  • codeframe/core/events.py
  • codeframe/core/reconciliation.py
  • codeframe/core/tasks.py
  • codeframe/core/workspace.py
  • codeframe/git/github_issue_sync.py
  • tests/core/test_reconciliation.py

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.

[Phase 4] Continuous Reconciliation During Batch Execution

1 participant