Skip to main content
The reconciler is Longshot’s self-healing system — a periodic background process that checks build and test health, detects failures, and automatically generates targeted fix tasks.

Core Responsibility

Keep the main branch green. When errors are detected, create fix tasks. When the system is healthy, back off.
The reconciler does NOT write code. It analyzes failures and produces task descriptions for workers to execute.

Sweep Process

The reconciler runs periodic “sweeps” of the main branch:
// From reconciler.ts:228-518
async sweep(): Promise<SweepResult> {
  // 1. Run TypeScript compiler
  const tscResult = await this.runCommand("npx", ["tsc", "--noEmit"]);
  const buildOk = tscResult.code === 0;
  
  // 2. Run build script
  const buildRunResult = await this.runCommand("npm", ["run", "build", "--if-present"]);
  const buildRunOk = buildRunResult.code === 0;
  
  // 3. Run tests
  const testResult = await this.runCommand("npm", ["test"]);
  const testsOk = testResult.code === 0;
  
  // 4. Check for merge conflict markers
  const conflictResult = await this.runCommand(
    "git", ["grep", "-rl", "<<<<<<<", "--", "*.ts", "*.tsx", "*.js", "*.json"]
  );
  const conflictFiles = conflictResult.stdout.trim().split("\n").filter(Boolean);
  const hasConflictMarkers = conflictFiles.length > 0;
  
  // 5. If all green, adjust to slower interval and return
  if (buildOk && buildRunOk && testsOk && !hasConflictMarkers) {
    this.consecutiveGreenSweeps++;
    if (this.consecutiveGreenSweeps >= 3) {
      this.adjustInterval(this.maxIntervalMs);
    }
    return { buildOk: true, testsOk: true, fixTasks: [] };
  }
  
  // 6. Call LLM to generate fix tasks from error output
  const response = await this.completeLLM(messages);
  const rawTasks = parseLLMTaskArray(response.content);
  
  // 7. Cap to maxFixTasks and deduplicate
  const tasks = rawTasks.slice(0, this.reconcilerConfig.maxFixTasks);
  
  // 8. Reset to faster interval when errors detected
  this.consecutiveGreenSweeps = 0;
  this.adjustInterval(this.minIntervalMs);
  
  return { buildOk, testsOk, hasConflictMarkers, fixTasks: tasks };
}

Adaptive Intervals

The reconciler adjusts its sweep frequency based on health:
// Configuration
const reconcilerConfig = {
  intervalMs: 300_000,      // Default: 5 minutes
  maxFixTasks: 5,           // Max tasks per sweep
};

this.minIntervalMs = Math.min(60_000, reconcilerConfig.intervalMs);  // 1 minute
this.maxIntervalMs = reconcilerConfig.intervalMs;                    // 5 minutes
StateIntervalReason
Red (errors detected)1 minuteRapid feedback during active fixing
Green (all passing)5 minutesConserve resources during stability
TransitionalVariesGradual adjustment
// From reconciler.ts:538-563
private adjustInterval(targetMs: number): void {
  if (this.currentIntervalMs === targetMs) return;
  this.currentIntervalMs = targetMs;
  
  if (this.timer) {
    clearInterval(this.timer);
    this.timer = setInterval(async () => {
      const result = await this.sweep();
      for (const cb of this.sweepCompleteCallbacks) {
        cb(result);
      }
    }, this.currentIntervalMs);
  }
  
  logger.info("Adjusted sweep interval", {
    newIntervalMs: this.currentIntervalMs,
    consecutiveGreen: this.consecutiveGreenSweeps,
  });
}
Adaptive intervals balance responsiveness (catch errors quickly) with efficiency (don’t waste resources when stable).

Error Priority

When multiple error types coexist, the reconciler fixes in this order:
  1. Merge conflict markers: Nothing works until resolved
  2. Build failures: Project cannot be used
  3. Compiler errors: Type safety violations compound quickly
  4. Test failures: Functional regressions
The reconciler NEVER creates tasks for multiple priority levels in the same sweep. Lower-priority errors often resolve as side effects of fixing higher-priority ones.

LLM Prompt Construction

When errors are detected, the reconciler builds a context-rich prompt:
// From reconciler.ts:376-407
let userMessage = "";

if (hasConflictMarkers) {
  userMessage += `## Merge Conflict Markers Found\n`;
  userMessage += `Files with unresolved conflict markers:\n`;
  for (const f of conflictFiles.slice(0, 20)) {
    userMessage += `- ${f}\n`;
  }
}

if (!buildOk) {
  userMessage += `## Build Output (tsc --noEmit)\n`;
  userMessage += `\`\`\`\n${buildOutput.slice(0, 8000)}\n\`\`\`\n`;
}

if (!buildRunOk) {
  userMessage += `## Build Output (npm run build)\n`;
  userMessage += `\`\`\`\n${buildRunOutput.slice(0, 8000)}\n\`\`\`\n`;
}

if (!testsOk) {
  userMessage += `## Test Output (npm test)\n`;
  userMessage += `\`\`\`\n${testOutput.slice(0, 8000)}\n\`\`\`\n`;
}

userMessage += `## Recent Commits\n${recentCommits}\n`;

if (this.recentFixScopes.size > 0) {
  userMessage += `## Pending Fix Scopes\n`;
  userMessage += `Fix tasks already target these files — do NOT create duplicates: `;
  userMessage += `${[...this.recentFixScopes].join(", ")}\n`;
}
The LLM receives:
  • Full error output (build, test, conflict markers)
  • Recent commit log for context
  • List of files already being fixed (to avoid duplicates)

Fix Task Generation

The reconciler’s system prompt (from prompts/reconciler.md) instructs it to:
  1. Parse error output
  2. Classify root cause
  3. Group related errors
  4. Scope to minimal file set (max 3 files)
  5. Emit JSON array of fix tasks
Example fix task:
{
  "id": "fix-001",
  "description": "Fix type error in renderer.ts line 42: TS2345 Argument of type 'string' is not assignable to parameter of type 'number'. The setViewport call passes a string width but expects number.",
  "scope": ["src/engine/renderer.ts"],
  "acceptance": "tsc --noEmit returns 0 with no errors in renderer.ts",
  "branch": "worker/fix-001-type-error-renderer",
  "priority": 1
}
Fix tasks always have priority 1 (highest) to land before new feature work.

Error Grouping

The reconciler groups related errors into single tasks:
PatternAction
Same file, same cause5 errors from renamed type → 1 task
Import chainFile A can’t find export from File B → 1 task scoped to both
Interface mismatchType changed in A, callers in B and C break → 1 task
Maximum of 5 fix tasks per sweep to avoid overwhelming the system:
// From reconciler.ts:460-488
const capped = rawTasks.slice(0, this.reconcilerConfig.maxFixTasks);

const tasks: Task[] = [];
for (const raw of capped) {
  const scope = raw.scope || [];
  
  // Skip if all files in scope are already covered by pending fixes
  const allScopesCovered = scope.length > 0 && 
    scope.every((f) => this.recentFixScopes.has(f));
  if (allScopesCovered) {
    logger.debug("Skipping duplicate fix task (scope already covered)");
    continue;
  }
  
  // Create fix task
  this.fixCounter++;
  const id = raw.id || `fix-${String(this.fixCounter).padStart(3, "0")}`;
  tasks.push({ ...raw, id, status: "pending", priority: 1 });
  
  // Track scopes to prevent duplicates
  for (const f of scope) {
    this.recentFixScopes.add(f);
  }
}

Deduplication

The reconciler maintains a set of file scopes currently being fixed:
private recentFixScopes: Set<string> = new Set();
If all files in a proposed fix task are already covered by pending fixes, the task is skipped. Scopes are cleared when sweeps return green:
// From reconciler.ts:326
this.recentFixScopes.clear();

Stale Result Detection

If merges occur during a sweep, results may be stale:
// From reconciler.ts:232, 342-367
const mergeCountBefore = this.mergeQueue.getMergeStats().totalMerged;

// ... run checks ...

const mergeCountAfter = this.mergeQueue.getMergeStats().totalMerged;
if (mergeCountAfter > mergeCountBefore) {
  logger.info("Merges occurred during sweep — discarding stale results");
  return { buildOk, testsOk, hasConflictMarkers, fixTasks: [] };
}
This prevents creating fix tasks for errors that may have already been fixed by the new merges.

Integration with Planner

Fix tasks are injected directly into the planner:
// Orchestrator registers sweep completion handler
reconciler.onSweepComplete((result: SweepResult) => {
  planner.setLastSweepResult(result);  // Include in planner context
  
  for (const fixTask of result.fixTasks) {
    planner.injectTask(fixTask);  // Bypass LLM planning
  }
});
The planner includes build/test health in its next sprint context:
// From planner.ts:575-591
if (this.lastSweepResult) {
  const sr = this.lastSweepResult;
  msg += `## Build/Test Health\n`;
  msg += `Build: ${sr.buildOk ? "PASS" : "FAIL"}`;
  if (!sr.buildOk && sr.buildOutput) {
    msg += ` — errors:\n\`\`\`\n${sr.buildOutput.slice(0, 2000)}\n\`\`\`\n`;
  }
  msg += `Tests: ${sr.testsOk ? "PASS" : "FAIL"}`;
  if (!sr.testsOk && sr.testOutput) {
    msg += ` — output:\n\`\`\`\n${sr.testOutput.slice(0, 2000)}\n\`\`\`\n`;
  }
  if (sr.hasConflictMarkers) {
    msg += `Conflict markers in: ${sr.conflictFiles.join(", ")}\n`;
  }
}
This gives the planner visibility into health trends, enabling proactive planning (e.g., pause feature work if build is broken).

Build vs. Compile Checks

The reconciler runs both tsc --noEmit and npm run build: Why both?
  • tsc --noEmit: Catches type errors
  • npm run build: Catches bundling errors, missing assets, config issues that tsc alone won’t catch
Some projects use build tools (Vite, Webpack, esbuild) that have different error modes than raw TypeScript.
// From reconciler.ts:235-277
const tscResult = await this.runCommand("npx", ["tsc", "--noEmit"]);
const buildOk = tscResult.code === 0;

const buildRunResult = await this.runCommand(
  "npm", ["run", "build", "--if-present"]
);
const buildRunOk = buildRunResult.code === 0;
If npm run build is not configured, it’s treated as passing:
const buildRunNotConfigured = 
  /Missing script|npm error|ERR!/i.test(buildRunOutput) &&
  buildRunResult.code !== 0;
const buildRunOk = buildRunNotConfigured || buildRunResult.code === 0;

Test Handling

Similarly, if tests are not configured, they’re treated as passing:
// From reconciler.ts:280-294
const testResult = await this.runCommand("npm", ["test"]);
const testOutput = testResult.stdout + testResult.stderr;
const testNotConfigured = /Missing script|no test specified/i.test(testOutput);
const testsOk = testNotConfigured || testResult.code === 0;
This allows Longshot to work with projects at any stage of test maturity.

Callbacks and Observability

The reconciler exposes callbacks for integration:
reconciler.onSweepComplete((result: SweepResult) => {
  console.log(`Sweep: build=${result.buildOk}, tests=${result.testsOk}`);
  console.log(`Fix tasks created: ${result.fixTasks.length}`);
});

reconciler.onError((error: Error) => {
  console.error("Sweep failed:", error.message);
});
These power dashboard visualizations and alerting.

Graceful LLM Failure

If the LLM is unreachable, the sweep logs a warning but doesn’t crash:
// From reconciler.ts:428-458
try {
  const response = await this.completeLLM(messages);
  rawTasks = parseLLMTaskArray(response.content);
} catch (llmError) {
  logger.warn(
    "LLM unreachable for reconciler — skipping fix task generation",
    { error: llmError.message }
  );
  return {
    buildOk,
    testsOk,
    hasConflictMarkers,
    buildOutput,
    testOutput,
    conflictFiles,
    fixTasks: [],  // No tasks, will retry next sweep
  };
}
The next sweep will retry when the LLM is available again.

Anti-Patterns

The reconciler avoids:
  • Fixing warnings: Only errors (build failures, test failures, conflict markers)
  • Creating enhancement tasks: Only fixes for broken code
  • Infinite retry loops: Max 5 tasks per sweep prevents runaway generation
  • Duplicate tasks: File scope tracking prevents re-fixing the same files
  • Overreacting: Adaptive intervals prevent excessive sweeping when healthy

Example Sweep Scenarios

Scenario 1: Type Error After Merge

Input:
src/engine/renderer.ts(42,5): error TS2345: 
  Argument of type 'string' is not assignable to parameter of type 'number'.
Fix Task:
{
  "id": "fix-001",
  "description": "Fix type error in renderer.ts line 42: TS2345 Argument of type 'string' is not assignable to parameter of type 'number'. The setViewport call passes a string width but expects number.",
  "scope": ["src/engine/renderer.ts"],
  "acceptance": "tsc --noEmit returns 0 with no errors in renderer.ts",
  "priority": 1
}

Scenario 2: Merge Conflict Markers

Input:
src/engine/renderer.ts
src/engine/camera.ts
Fix Task:
{
  "id": "fix-001",
  "description": "Resolve merge conflict markers in renderer.ts and camera.ts. Open each file, find <<<<<<< / ======= / >>>>>>> blocks, resolve by keeping the correct version based on surrounding code context. Remove all conflict markers.",
  "scope": ["src/engine/renderer.ts", "src/engine/camera.ts"],
  "acceptance": "No <<<<<<< markers in either file. npm run build returns 0 and tsc --noEmit returns 0.",
  "priority": 1
}

Scenario 3: Test Failure

Input:
FAIL src/world/__tests__/chunk.test.ts
  ● ChunkManager › should generate terrain for new chunks
    Expected: 64 / Received: undefined
Fix Task:
{
  "id": "fix-001",
  "description": "Fix failing test 'ChunkManager should generate terrain for new chunks': expected height 64 but received undefined. getHeight() likely returns undefined after recent terrain generator refactor.",
  "scope": ["src/world/chunk.ts", "src/world/__tests__/chunk.test.ts"],
  "acceptance": "npm test returns 0, ChunkManager terrain test passes",
  "priority": 1
}

Configuration

interface ReconcilerConfig {
  intervalMs: number;     // Default: 300_000 (5 minutes)
  maxFixTasks: number;    // Default: 5
}
Adjust based on project size and development velocity.

Build docs developers (and LLMs) love