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
| State | Interval | Reason |
|---|
| Red (errors detected) | 1 minute | Rapid feedback during active fixing |
| Green (all passing) | 5 minutes | Conserve resources during stability |
| Transitional | Varies | Gradual 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:
- Merge conflict markers: Nothing works until resolved
- Build failures: Project cannot be used
- Compiler errors: Type safety violations compound quickly
- 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:
- Parse error output
- Classify root cause
- Group related errors
- Scope to minimal file set (max 3 files)
- 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:
| Pattern | Action |
|---|
| Same file, same cause | 5 errors from renamed type → 1 task |
| Import chain | File A can’t find export from File B → 1 task scoped to both |
| Interface mismatch | Type 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.