When AI-Assisted Development Fails: A Technical Postmortem
I asked an AI to build a complete system in one go: a robust asyncio-based scheduler, task execution with dynamic parameter adjustment, self-improving task generation, and a human ticketing system for failures. The AI delivered working code remarkably quickly. The problem only appeared when I tried to integrate everything.
The system collapsed. Not from novel failures, but from classical software engineering problems amplified by an unconstrained development process: overengineered scope, unreviewed integration, tight coupling, and technical debt baked into the foundation.
Why Unconstrained AI Development Fails
Scope Inflation Without Architectural Review
The AI does not have business context or architectural intuition to say no. It is a powerful implementation engine optimized to execute requests. By asking for everything at once without defining integration points or architectural constraints, I inadvertently directed it to solve a future problem that did not yet exist, abandoning the immediate need for a stable scheduler.
The "intelligent" features were bolted onto a core that had never been stress-tested. I was treating AI as an autonomous developer, expecting coherence from a list of features. A human colleague, if given the same brief, would have pushed back, proposed a phased approach, and raised concerns about complexity. The AI simply executed.
The Event Loop Conflict
When I tried to run the integrated system, it crashed. The root cause was paradigmatic: different modules, developed in isolation, were trying to manage the same asyncio event loop.
The core scheduler was initialized with one pattern. Then the ticketing system, developed separately, used asyncio.run() to handle failed tasks:
Problematic Code Snippet 1: Conflicting Event Loops
# In scheduler_core.py, generated by one prompt
import asyncio
from apscheduler.schedulers.asyncio import AsyncIOScheduler
class MainScheduler:
def __init__(self):
self.scheduler = AsyncIOScheduler()
def run(self):
self.scheduler.start()
# This call blocks forever, running the loop.
asyncio.get_event_loop().run_forever()
# In ticketing_system.py, generated by another prompt
import asyncio
class TicketingSystem:
async def process_ticket(self, ticket_data):
# ... logic ...
print("Processing ticket")
def handle_failed_task(self, task_info):
# This is the anti-pattern! It tries to run a new loop.
asyncio.run(self.process_ticket(task_info))
When the scheduler called handle_failed_task during execution, the code crashed with RuntimeError: This event loop is already running. The ticketing system's developer (the AI) saw a local problem—"I need to run an async function"—and applied a standard solution without understanding the global context: it was part of an already-running event loop.
A second architectural mismatch involved apscheduler's CronTrigger. Its blocking implementation and separate threading model conflicted with the async design I had envisioned. The result: timing bugs and race conditions that were hard to isolate.
Tight Coupling and Cascading Failures
The system became a monolith where modules had deep dependencies on each other's internals. The auto-improvement task module depended directly on the ticketing system's data structures. The main scheduler knew internal details about task execution logic.
Conceptual Problem: Tight Coupling
# Before: A tangled mess of dependencies
class MainScheduler:
def __init__(self):
# The scheduler directly instantiates its "smart" components
self.improver = AutoTaskImprover()
self.ticketer = TicketingSystem()
def _execute_task(self, task):
result = task.run()
if not result.success:
# Direct call into another module's implementation
new_script = self.improver.analyze_and_suggest_fix(task.script, result.error)
if new_script:
task.update_script(new_script)
else:
# Another direct, deep call
self.ticketer.handle_failed_task(task.info)
When one component failed, the failure cascaded through the entire system. Debugging was impossible because errors did not originate where they manifested. The system was not a collection of cooperating modules; it was a single fragile machine.
The Abdication of Architectural Responsibility
I had relinquished my role as architect. I specified what to build but not how to build it or how it should integrate. Without manual code reviews and integration testing at each step, I was invisible to the accumulating architectural rot.
Recovery and Reframing the Human-AI Relationship
Rebuilding from this failure revealed a clear framework for effective human-AI collaboration.
Start with a Stable Core
The fix was not to refine the existing system but to abandon it and start over with a single, clear goal: a rock-solid, simple asynchronous scheduler. No intelligent features. No self-improvement. Just a stable, pressure-tested foundation.
Only after this core was built, tested, and proven reliable did I add features back, one at a time. Each new feature became a distinct, optional module, not a core component.
The Fix: A Modular, Pluggable Architecture
# After: A clean, decoupled design using dependency injection
# --- Core Scheduler (knows nothing about "smart" features) ---
class MainScheduler:
def __init__(self, plugins=None):
self.plugins = plugins or []
def _execute_task(self, task):
result = task.run()
if not result.success:
# The core only publishes an event, it doesn't know the consumers.
self.publish_event('task_failed', task=task, result=result)
def publish_event(self, event_type, **kwargs):
for plugin in self.plugins:
if hasattr(plugin, f"on_{event_type}"):
getattr(plugin, f"on_{event_type}")(**kwargs)
# --- Optional Plugin ---
class AutoImprovementPlugin:
def on_task_failed(self, task, result):
# Logic to improve task is now isolated here
print(f"Plugin: Analyzing failure for task {task.id}")
# ...
# --- Main application wiring ---
core_scheduler = MainScheduler(plugins=[AutoImprovementPlugin()])
# Now the smart feature is an optional plugin, not a core dependency.
This keeps the core clean and allows features to be swapped, upgraded, or disabled without affecting the rest of the system.
The Human Must Architect and Review
I changed my role from "project manager" to "lead architect and senior developer." The new workflow:
- Define a small, isolated task. (e.g., "Create a plugin that logs task failures to a JSON file.")
- AI generates the code.
- I review every line. I check for anti-patterns, architectural mismatches, and hidden assumptions.
- I refactor and integrate it myself. I connect it to the main application and ensure it follows the established architecture.
- I write integration tests and commit.
This human-centric review loop is essential. It keeps the human in control of architectural decisions and quality standards.
Enforce Architectural Constraints
The asyncio problem was solved by enforcing a single rule: only one event loop, managed by the application's entry point. Modules and plugins must never call asyncio.run() or loop.run_forever(). They expose async functions that the main loop awaits.
The Fix: A Single, Unified Event Loop
# In a plugin file (e.g., ticketing_plugin.py)
class TicketingPlugin:
async def on_task_failed(self, task, result):
# This function is now async and expects to be awaited
await self.create_ticket(task.info)
async def create_ticket(self, info):
print(f"Creating ticket for {info}")
# ... await async I/O operations ...
await asyncio.sleep(0.1)
# In the main application entry point
async def main():
# Plugins are now designed to be awaited
ticketing_plugin = TicketingPlugin()
scheduler = MainScheduler(plugins=[ticketing_plugin])
# The scheduler's `publish_event` would need to be async
# and await the plugin calls.
# ... startup logic ...
await scheduler.run() # The main run function is now awaitable
if __name__ == "__main__":
# The one and only place the event loop is run
asyncio.run(main())
This principle—enforcing simplicity globally—must come from the human. An AI optimizing locally may not choose the simplest global solution.
Lessons: The Human as Pilot
AI development tools are powerful copilots, not autonomous pilots. They can execute complex instructions at superhuman speed and handle vast technical details. But the human developer must remain in command: responsible for architecture, code review, integration, and ultimate direction.
The promise of AI is real. It requires a new discipline: resist the temptation to let it run unsupervised. Instead, guide it rigorously, question assumptions, and integrate its output with the foresight that only human architects provide.
By pairing strategic human oversight with AI's tactical capability, you avoid flying into a storm of accumulated complexity and instead build software that is both remarkable and maintainable.
What Actually Works
- Start with a stable foundation, not a feature list. Build core functionality, test it thoroughly, then add complexity.
- Every line of AI-generated code requires human architectural review. Not syntax checking—architectural review. Does this fit? Does it couple unnecessarily? What assumptions does it make?
- Integrate incrementally. Add one feature at a time, test the integration, commit. This catches architectural conflicts early.
- Keep features optional and loosely coupled. A modular system is maintainable; a monolith is fragile.
- The human architect controls the decision. AI executes. Humans decide scope, boundaries, and trade-offs.