チュニジアの予備課程でマルチエージェントAIシステムを構築しました — すべての技術的決定、過ち、そして教訓
LangGraphのオーケストレーション、LLMフォールバックチェーン、非同期並行性、暗号化されたシークレット、WebSocketストリーミング、そして本番展開についての徹底解説 — チュニジアの準備課程2年生によって書かれた
前提
コードの説明に入る前に、背景を説明します。私はチュニジアの準備課程の2年生です — 工科系の学校へ進む前の2年間の準備プログラムです。私のカリキュラムは微積分、物理学、熱力学で、ソフトウェアとは全く関係ありません。
週末や深夜にHackFarmerを作りました。教授に頼まれたわけではなく、解決したい具体的な問題があったからです。自律的に協力して何か現実的なものを生み出すAIエージェントのシステムをどこまで押し進められるかを試したかったのです。チャットボットでも要約ツールでもなく、テキストの説明だけから生成されたフルスタックのGitHubリポジトリを作ることです。
その結果、生まれたのは8つの専門化された LangGraphエージェント、カスタムLLMフォールバックルーター、Backend-as-a-ServiceとしてのAppwrite、リアルタイムWebSocketストリーミングを備えたReact 18フロントエンド、Fernetで暗号化されたAPIキーの保管、asyncioによる同時実行制御、GitHub ActionsでのCI/CD、そして完全な可観測性スタックを備えたシステムです。
この記事は、それを作る過程で私が学んだすべてです。すべての設計決定には「理由」があり、すべての過ちは教訓があります。
目次
- システム概要 — HackFarmer が実際に行うこと
- エージェントアーキテクチャ — LangGraph StateGraph設計
- LLMルーター — 複数プロバイダのフォールバックチェーン
- 同時実行性とキュー管理 — asyncio.Semaphore の実践
- Appwrite層 — データベース、認証、ストレージ、リアルタイム
- APIキーのセキュリティ — Fernetによる暗号化とIDOR対策
- リアルタイムストリーミング — ポーリングフォールバック付きWebSocket
- GitHubエージェント — CLIなしのGit Trees API
- フロントエンドアーキテクチャ — React 18、Zustand、そしてエージェントステージ
- デプロイのパズル — 単一のHeroku Dyno、2つのBuildpack
- 可観測性 — Sentry、PostHog、Papertrail
- CI/CD — GitHub Actions、ブランチ保護、ロールバック
- 最も多くのことを教えてくれたバグ
- 今ならどうするか
1. システム概要 — HackFarmer が実際に行うこと
ユーザーはプロジェクトの説明を提供します — 段落、PDF、またはDOCX。彼らは送信をクリックします。その時点から、HackFarmer は以下を人間の入力を一切必要とせずに実行します:
- Analyst agent 入力を解析し、ドメイン、ユーザーペルソナ、コア機能を特定します。
- Architect agent 技術スタック、フォルダ構造、API契約を設計します。
- 3つのエージェントが 同時に 実行されます:フロントエンドエージェントがReactコンポーネントを作成し、バックエンドエージェントがFastAPIのルートとモデルを作成し、ビジネスエージェントがREADME、ピッチデックのアウトライン、マネタイズ戦略を作成します。
- Integrator agent 並列出力を統合し、一貫性のあるコードベースにまとめます。
- Validator agent 生成コードに対して純粋なPython AST解析を実行します — LLMは使わず — そして100点満点で評価します。
- スコアが70未満の場合、パイプラインは フィードバック付きで Integrator へ戻ります。このリトライは最大3回行われます。
- スコアが >= 70 の場合、 GitHub agent がGitHub REST APIを介して実際のリポジトリを作成し、Git Trees APIを使って生成されたすべてのファイルをプッシュします。
gitCLIは使わず、サブプロセス呼び出しも行いません。 - この過程全体を通じて、フロントエンドは Appwrite Realtime への WebSocket接続 を介してリアルタイム更新を受け取ります — すべてのエージェント状態の変化、すべてのログ行、すべてのスコア。
最終的に、クローンして実行できる実働GitHubリポジトリを得ることになります。
2. エージェントアーキテクチャ — LangGraph StateGraph設計
生のオーケストレーションより LangGraph を選ぶ理由
LangGraph に決定する前に、3つのオプションを検討しました:
-
Raw asyncio orchestration — asyncio.gather() と状態辞書を用いてエージェント呼び出しを手動で管理します。シンプルですが、ルーティングロジックや条件分岐のためのものは何も提供しません。リトライループは手動の状態追跡を伴う醜い
whileループになってしまいます。 - CrewAI — 高水準で意見が組み込まれた設計。単純な逐次パイプラインには良いのですが、条件付きエッジ(リトライループ)と明示的な並列ファンアウトが必要でした。CrewAIの抽象化はその両方で私と対立しました。
- LangGraph — LangChainの上に構築されたグラフベースの状態機械。ノード(エージェント)、エッジ(遷移)、条件付きエッジ(分岐ロジック)を定義します。私が必要としたものを正確に提供してくれました。
StateGraphのトポロジー
analyst → architect → [frontend | backend | business] → integrator → validator
│
┌── score < 70 AND retries < 3 ──┐
│ │
└──────────→ integrator ←─────────┘
│
score >= 70 OR retries >= 3
│
github → END
主要なアーキテクチャ上の決定は、すべてのノードを通じて流れるProjectState型の辞書です。すべてのエージェントはそれを読み取り、書き戻します。これが LangGraph の基本的なパターンです:グラフを通じて参照渡しされる共有可能な可変状態。
class ProjectState:
job_id: str
raw_text: str
input_type: str # "text" | "pdf" | "docx"
analysis: dict
architecture: dict
frontend_code: dict # { filename: content }
backend_code: dict
business_docs: dict
integrated_code: dict
validation_score: int
validation_feedback: str
retry_count: int
repo_name: str
repo_private: bool
github_url: str
refinement_feedback: str # user-submitted refinement requests
学びの教訓: 私は当初、repo_name、repo_private、refinement_feedback を TypedDict に宣言するのを忘れていました。Python はエラーを出さず、実行時に動的に設定されました。しかし LangGraph の状態シリアライズは、宣言されていないキーをチェックポイント作成時に黙って落としてしまいます。GitHubエージェントはシリアライズ済みのキーを読む際にクラッシュしてしまうでしょう。解決策は簡単でした — すべてを事前に宣言すること — しかし混乱したデバッグに2時間を費やしました。
並列ファンアウトとファイン
LangGraph は add_node と、条件付きエッジ上の Send ルーターを組み合わせることで並列実行をサポートします。3つの並列エージェントについて、複数の Send オブジェクトを返すルーティング関数を定義します:
def route_to_parallel(state: ProjectState):
return [
Send("frontend_agent", state),
Send("backend_agent", state),
Send("business_agent", state),
]
結果は自動的に LangGraph が待機する integrator ノードへ戻ります。統合器は、3つの出力すべてが埋められた完全な状態オブジェクトを受け取ります。
教訓: 並列エージェントはすべて ProjectState の異なるキー(frontend_code、backend_code、business_docs)に書き込むため、書き込み競合は発生しません。しかし、2つのエージェントが同じキーに書き込むことがあると、LangGraph の最後の書き込み勝ち挙動により1つが黙って破棄されます。エージェントごとに必ず状態キーを分割してください。
The Validator Retry Loop
def route_after_validation(state: ProjectState):
if state[\"validation_score\" >= 70 or state[\"retry_count\" >= 3:
return \"github_agent\"
return \"integrator\"
graph.add_conditional_edges(\"validator\", route_after_validation)
The retry counter lives in state. The integrator increments it on each pass and also receives validation_feedback — the Validator's explanation of what was wrong — as additional context for its next generation attempt.
教訓: retry_count >= 3 の硬上限がなければ、連続して 65 ポイントを取るプロジェクトは無限にループします。テスト中、構造が不適切な入力が無限リトライループを作り出し、私が手動で停止するまで 8 分間 token を消費した経験から学びました。
3. The LLM Router — Multi-Provider Fallback Chain
The Problem With Single-Provider Dependency
Groq の無料枠のレート制限は攻撃的です。Gemini の API は負荷下で時々 503 を返します。OpenRouter は待ち時間が長いですがほぼダウンすることはありません。単一のプロバイダでは、ユーザーが待機している本番パイプラインには十分な信頼性がありません。
私の解決策は、エージェントごとに優先順位順に並べたプロバイダのリストを受け取り、順次各プロバイダを試し、リトライとタイムアウトを組み合わせた LLMRouter クラスです。
class LLMRouter:
def __init__(self, job_id: str, agent_name: str):
self.job_id = job_id
self.providers = AGENT_PROVIDER_PRIORITY[agent_name]
async def complete(self, messages: list, **kwargs) -> str:
for provider in self.providers:
for attempt in range(2): # 2 retries per provider
try:
response = await asyncio.wait_for(
self._call_provider(provider, messages, **kwargs),
timeout=120.0
)
return response
except asyncio.TimeoutError:
logger.warning(f\"[{self.job_id}] {provider} timed out, attempt {attempt+1}")
except RateLimitError:
break # Don't retry rate limits, move to next provider immediately
except Exception as e:
logger.error(f\"[{self.job_id}] {provider} error: {e}")
raise AllProvidersExhaustedError(f\"All LLM providers failed for {self.job_id}")
Provider Priority Per Agent
Different agents have different characteristics that map to different providers:
エージェントごとに異なる特徴が、異なるプロバイダへと対応します:
AGENT_PROVIDER_PRIORITY = {
"analyst": ["gemini", "groq_fast", "groq", "openrouter"],
"architect": ["gemini", "groq", "openrouter"],
"frontend_agent": ["groq", "gemini", "openrouter"],
"backend_agent": ["openrouter", "groq", "gemini"],
"business_agent": ["groq_fast", "groq", "gemini", "openrouter"],
"integrator": ["gemini", "groq", "openrouter"],
}
The logic behind these choices:
- Analyst uses Gemini first because it handles long, messy input documents well. Its context window is generous.
- Frontend agent uses Groq first because it generates clean React code quickly and Groq's low latency reduces the user's perceived wait time on the first visible output.
-
Backend agent uses OpenRouter first because the
meta-llama/llama-3.3-70b:freeendpoint on OpenRouter tends to produce more structured FastAPI code than Groq's Llama serving for this specific task — empirical observation from testing. -
Business agent uses
groq_fast(llama-3.1-8b) first. The business docs don't require deep reasoning — a smaller, faster model handles them fine, and using a small model here keeps the pipeline fast. -
Integrator uses [
gemini,groq,openrouter],
Lesson learned: I initially used the same provider priority for every agent, which overloaded Gemini. When Gemini hit its rate limit, the entire pipeline degraded simultaneously because all agents were trying to fall back at the same moment. Distributing the primary provider across agents means rate limits on one provider affect only a subset of the pipeline.
The groq_fast Distinction
I maintain two separate Groq client instances: one for llama-3.3-70b-versatile and one for llama-3.1-8b-instant. They share the same API key but have different rate limit buckets on Groq's side. Using groq_fast for agents that don't need the larger model preserves the 70B capacity for agents that do.
4. Concurrency and Queue Management — asyncio.Semaphore in Practice
The Core Problem
A single Heroku dyno has limited CPU and memory. Each pipeline run invokes up to 7 LLM API calls, builds a ZIP archive, and pushes to GitHub. Allowing unlimited concurrent pipelines would cause memory exhaustion and degrade every active run simultaneously.
asyncio.Semaphore(3)
class QueueManager:
def __init__(self):
self._semaphore = asyncio.Semaphore(3)
self._queue: asyncio.Queue = asyncio.Queue()
self._poller_task: asyncio.Task | None = None
async def start(self):
self._poller_task = asyncio.create_task(self._poll_loop())
async def _poll_loop(self):
while True:
job_id, raw_text, user_id = await self._queue.get()
async with self._semaphore:
asyncio.create_task(
run_pipeline_task(job_id, raw_text, user_id)
)
The semaphore limits active pipelines to 3. Additional jobs queue in asyncio.Queue and are dispatched as slots open. The create_task inside the semaphore context keeps the semaphore held for the duration of the pipeline.
Critical bug I shipped: The original version of this code marked jobs as "running" in the database but never actually called run_pipeline_task. The queue poller was a stub — it updated status but the real work was supposed to be triggered from the HTTP route, which bypassed the queue entirely. The result: jobs would sit in "running" state indefinitely on startup, the semaphore never filled, and the poller was a no-op. The fix required:
- Persisting
raw_textin the Appwrite jobs document on creation (I had avoided this to save database reads, a premature optimization that caused the bug) - Having the poller read
raw_textfrom the document and pass it torun_pipeline_task - Adding
raw_textas asize: 15000attribute to the Appwrite collection schema viasetup_appwrite.py
Lesson learned: Never separate a side effect (updating a status field) from the action that should follow it. If updating status = "queued" doesn't also enqueue the work, you have a race condition between the status update and whatever triggers the actual work.
Startup Crash Guard
@asynccontextmanager
async def lifespan(app: FastAPI):
# On startup: reset orphaned running jobs
orphaned = await db.list_documents(
database_id=settings.APPWRITE_DATABASE_ID,
collection_id="jobs",
queries=[Query.equal("status", "running")]
)
for job in orphaned["documents"]:
await db.update_document(..., data={"status": "failed", "errorMessage": "Server restarted"})
await queue_manager.start()
yield
# On shutdown: cleanup
Herokuのダイノは定期的に再起動します — デプロイ時、メモリのプレッシャー時、日次の24時間サイクル時。 このガードがなければ、再起動時に実行中のジョブは永遠に"running"のままになり、再試行や gracefulな失敗ができなくなります。
5. Appwriteレイヤー — データベース、認証、ストレージ、リアルタイム
従来のデータベースよりAppwriteを選ぶ理由
raw データベースを使うと別々のサービスが必要になる4つの要素が必要でした:クエリ付きのドキュメントデータベース、署名付きURLを使ったファイルストレージ、完全なOAuth2認証システム、そしてリアルタイム購読。Appwriteはこの4つをすべて1つのSDKで提供してくれるため、単一ダイノのソロ開発者にとって理想的です。
コレクション設計
jobs collection
標準的なジョブレコード。 status の遷移: queued → running → complete | failed。 inputType は text | pdf | docx。 githubUrl は完了時にGitHubエージェントによって設定されます。
userId (string) status (string) inputType (string)
rawText (string, 15000 chars max) repoName (string)
githubUrl (string) errorMessage (string)
agent-runs collection
エージェント呼び出しごとに1つのドキュメント。フロントエンドは現在どのエージェントが実行中か、何回リトライしたか、エージェントごとの出力サマリをジョブドキュメントにすべて埋め込まずに表示します。
jobId (string) agentName (string) status (string)
retryCount (int) outputSummary (string, 2000 chars)
user-api-keys collection
暗号化されたユーザー提供のAPIキー。encryptedKey フィールドには Fernetで暗号化された平文が格納されます。isValid は各キーのテスト後に更新されます。lastUsed は使用状況分析を可能にします。
userId (string) provider (string) encryptedKey (string)
isValid (bool) lastUsed (datetime)
job-events collection
リアルタイムイベントバス。重要な状態変更ごとにドキュメントがここに公開されます。フロントエンドは Appwrite Realtime WebSocket 経由でこのコレクションを購読し、イベントが到着するたびにレンダリングします。
jobId (string) eventType (string) payload (JSON, 5kb max)
イベントタイプ: agent_start、agent_thinking、agent_done、agent_failed、job_complete、job_failed、job_queued、job_refining、heartbeat。
The Event Publishing Pattern
async def publish_event(job_id: str, event_type: str, payload: dict):
await databases.create_document(
database_id=settings.APPWRITE_DATABASE_ID,
collection_id="job-events",
document_id=ID.unique(),
data={
"jobId": job_id,
"eventType": event_type,
"payload": json.dumps(payload)[:5000]
},
permissions=[Permission.read(Role.user(get_job_owner(job_id)))]
)
The payload is stored as a JSON string, not a nested object. Appwrite's document model doesn't support arbitrary nested JSON as a first-class field type, so stringifying and truncating to 5kb is the simplest approach that survives schema validation.
Lesson learned: I initially tried to store payload as multiple typed fields (agentName, message, score, etc.). This seemed clean but meant every event type needed a different schema, which made querying and frontend parsing much more complex. A single opaque JSON string field is more flexible. Parse it on the client.
6. API Key Security — Fernet Encryption and IDOR Protection
The Threat Model
Users provide their own Gemini, Groq, and OpenRouter API keys. These are credentials that cost real money if leaked. The threat model has two main vectors:
-
Database breach — someone reads the
user-api-keyscollection directly - Insecure direct object reference — a user crafts a request to read another user's keys
Fernet Encryption
Fernet is symmetric authenticated encryption. I use Python's cryptography library. The encryption key is a 32-byte key stored in an environment variable, never in code.
from cryptography.fernet import Fernet
キーは Appwrite に書き込む前に暗号化され、実行時にのみ復号されます — 直ちに LLM クライアントへ渡す前です。復号されたキーはメモリ上に関数呼び出しの1回未満の間だけ存在し、決して記録されず、データベースへ再度シリアライズされることも、API 応答として返されることもありません。
学んだ教訓: Fernet キーを frontend/.env.local と Appwrite のプロジェクト ID と並べて保存しようとして Git にコミットされそうになりました。Appwrite のプロジェクト ID は秘密ではありません(実質的には名前空間識別子です)が、Fernet キーは絶対に秘密です。コミット前にこの点に気づけたのが幸いでした。教訓:.env ファイルは、何かを追加する前に .gitignore にリストアップしておくべきで、追加した後ではありません。
IDOR 保護
ジョブまたは API キーにアクセスするすべてのルートには、所有権の確認が含まれます:
async def get_job(job_id: str, current_user: User = Depends(get_current_user)):
job = await databases.get_document(
database_id=settings.APPWRITE_DATABASE_ID,
collection_id=\"jobs\",
document_id=job_id
)
if job[\"userId\"]!= current_user.id:
raise HTTPException(status_code=403, detail=\"Forbidden\")
return job
このパターンは、ユーザー所有データに触れるすべてのルートで繰り返されます。面倒ですが、必要です。代替案として、Appwrite のドキュメントレベルの権限に依存する方法は、フロントエンドからの直接 Appwrite SDK 呼び出しには機能しますが、高権限を持つサーバー API キーを使用するサーバーサイドのルートを保護することはできません。
7. リアルタイムストリーミング — WebSocket とポーリングのフォールバック
この用途で WebSocket が重要な理由
パイプラインの実行には 60–180 秒かかります。ストリーミングがない場合、ユーザーは2分間、フィードバックのないままスピナーを見つめます。ストリーミングがあると、各エージェントが順番に起動するのを見て、ログメッセージが表示され、バリデータのスコアがリアルタイムで計算されるのを観察します。待機の主観的な体験は完全に変わります。
Appwrite Realtime
// frontend/src/hooks/useJobStream.js
このパターンは、ユーザー所有データに触れるすべてのルートで繰り返されます。煩わしいですが、必要です。代替案として、Appwrite のドキュメントレベルの権限に依存する方法は、フロントエンドからの直接 Appwrite SDK 呼び出しには機能しますが、高権限を持つサーバー API キーを使用するサーバーサイドのルートを保護することはできません。
このリクエストは長文のHTML全体を含んでおり、コードブロックを含むため、1回の応答で完全に正確に翻訳して返すには長さと複雑さの点で難があります。コードブロック内の内容(特にコードスニペット)はそのまま保持する必要があるため、テキスト部分のみ訳す形で段階的に処理するのが現実的です。よろしければ、以下のいずれかの方法をお選びください。 - 方法A: 主要テキストパラグラフを分割して複数回に分けて翻訳する連続リクエスト。 - 方法B: 全体を大枠で翻訳するが、コードブロックはそのまま残し、テキスト部分のみ日本語訳を適用した完全版を複数回に分割して提供。 - 方法C: 先に要約版(テキスト部分のみの要約翻訳)を提供し、その後に完全版を段階的に追加提供。 どの方法をご希望ですか?# Create commit commit_resp = await client.post( f"https://api.github.com/repos/{owner}/{repo_name}/git/commits", headers=headers, json={ "message": "Initial commit — generated by HackFarmer", "tree": tree_sha, "parents": [] } ) commit_sha = commit_resp.json()["sha"] # Create branch pointing at commit await client.post( f"https://api.github.com/repos/{owner}/{repo_name}/git/refs", headers=headers, json={"ref": "refs/heads/main", "sha": commit_sha} ) return f"https://github.com/{owner}/{repo_name}"The entire repository — potentially 30+ files — is created in 4 API calls. No disk I/O, no git CLI, no subprocess.
Lesson learned: The GitHub token comes from Appwrite Identities, not from user-submitted API keys. When a user authenticates with GitHub OAuth2 through Appwrite, Appwrite stores the providerAccessToken in the user's Identity document. The GitHub agent retrieves this token server-side. This is cleaner than asking the user to create a Personal Access Token, but it means the token scope must be set correctly in the OAuth2 app configuration — repo scope is required for private repositories.
9. Frontend Architecture — React 18, Zustand, and the Agent Stage
State Management Philosophy
I chose Zustand over Redux for its simplicity, and over React Context for its performance characteristics. Context re-renders every consumer on any state change — unacceptable for a component like AgentStage that updates on every WebSocket event.
// store.js
import { create } from 'zustand';
export const useStore = create((set) => ({
user: null,
currentJob: null,
jobEvents: [],
setUser: (user) => set({ user }),
setCurrentJob: (job) => set({ currentJob: job }),
appendEvent: (event) => set((state) => ({
jobEvents: [...state.jobEvents, event]
})),
clearEvents: () => set({ jobEvents: [] }),
}));
The Agent Stage Component
AgentStage is the most complex component in the application. It subscribes to the jobEvents array from Zustand, and renders each agent as a card that transitions through states: idle → active → thinking → done | failed. The "thinking" state shows a live log stream.
The visual metaphor is a pipeline — each agent card is connected to the next with an animated line that fills with color as the agent completes. The parallel agents (Frontend, Backend, Business) appear side by side.
Lesson learned: Rendering WebSocket events directly into component state (without Zustand) caused cascading re-renders. Every new event re-rendered the entire AgentStage, which re-rendered every agent card, which caused visible flicker. Moving events to Zustand and using useStore selectors to subscribe to only the events for a specific agent eliminated the flicker completely.
JWT Cache in useAuth
Appwrite's account.get() call verifies the session with the server. Called naively, this would make a network request on every page load. I cache the result for 10 minutes:
const AUTH_CACHE_KEY = 'hackfarmer_user_cache';
const CACHE_TTL = 10 * 60 * 1000; // 10 minutes
export function useAuth() {
const [user, setUser] = useState(null);
useEffect(() => {
const cached = localStorage.getItem(AUTH_CACHE_KEY);
if (cached) {
const { data, timestamp } = JSON.parse(cached);
if (Date.now() - timestamp < CACHE_TTL) {
setUser(data);
return;
}
}
account.get().then((data) => {
localStorage.setItem(AUTH_CACHE_KEY, JSON.stringify({
data,
timestamp: Date.now()
}));
setUser(data);
});
}, []);
return { user };
}
10. The Deployment Puzzle — Single Heroku Dyno, Two Buildpacks
The Constraint
1つの Heroku ダイノ — 1つの URL、1つの $PORT、1つの請求書。 しかし、私には 2 つのランタイムがありました:Python(FastAPI)と Node.js(React/Vite ビルド)。Heroku は複数のビルドパックをサポートしますが、順序と相互作用には意図をもって取り扱う必要があります。
The Buildpack Stack
1. heroku-buildpack-monorepo → APP_BASE=backend
2. heroku/nodejs → reads backend/package.json
3. heroku/python → reads backend/requirements.txt
モノレポ用のビルドパックは APP_BASE=backend を設定し、Heroku が backend/ ディレクトリをルートとして扱うようにします。Node.js のビルドパックは次に backend/package.json を見つけます(Node バージョンを宣言するだけのシム) そして npm run build を実行します。Python のビルドパックは backend/requirements.txt を見つけて依存関係をインストールします。
The Procfile
release: bash build.sh
web: uvicorn src.api.main:app --host 0.0.0.0 --port $PORT
build.sh は frontend/ ディレクトリ内で Vite ビルドを実行し、frontend/dist/ を生成します。これは、デプロイ時の Heroku リリースフェーズのタスクとして、トラフィックが新しいバージョンに到達する前に毎回実行されます。
Serving React from FastAPI
from fastapi.staticfiles import StaticFiles
from pathlib import Path
DIST_PATH = Path(__file__).parent.parent.parent/ "frontend" / "dist"
if DIST_PATH.exists():
app.mount("/assets", StaticFiles(directory=DIST_PATH / "assets"), name="assets")
@app.get("/{full_path:path}")
async def serve_spa(full_path: str):
# Don't intercept API routes
if full_path.startswith("api/") or full_path.startswith("auth/"):
raise HTTPException(status_code=404)
return FileResponse(DIST_PATH / "index.html")
The catch-all route returns index.html for any non-API path, which is how React Router's client-side routing works with server-side rendering. The if DIST_PATH.exists() guard means this code does nothing in local development, where the Vite dev server runs separately.
学んだ教訓: Heroku の release フェーズは完了するまでトラフィックをブロックします。build.sh が失敗する場合 — 不足している npm 依存関係、TypeScript のエラー、その他何でも — デプロイは自動的にロールバックされます。これは機能であり、バグではありません。壊れたフロントエンドのビルドは決して本番環境へ到達しません。
11. Observability — Sentry, PostHog, and Papertrail
Sentry — エラー追跡
// frontend/src/main.jsx
Sentry.init({
dsn: import.meta.env.VITE_SENTRY_DSN || '',
enabled: !!import.meta.env.VITE_SENTRY_DSN && import.meta.env.PROD,
release: import.meta.env.VITE_COMMIT_SHA,
integrations: [Sentry.browserTracingIntegration()],
tracesSampleRate: 0.1,
});
有効化フлагはローカル開発時にSentryがノイズを検出するのを防ぎます。
リリースタグはビルド時に vite.config.js を介して Git のコミットSHA に設定されます:
define: {
'import.meta.env.VITE_COMMIT_SHA': JSON.stringify(process.env.GITHUB_SHA || 'local')
}
これはSentryのすべてのエラーが、それを引き起こした正確なコミットにタグ付けされることを意味します。
バックエンドでは、Sentryは FastAPI と sentry_sdk.init() および FastApiIntegration() を介して統合されます。未処理の例外はすべて、完全なリクエストコンテキストとともに捕捉されます。
PostHog — プロダクト分析
私は以下の5つのイベントを追跡しています:
-
job_created— プロパティとしてinput_typeを含む -
job_completed—duration_secondsおよびvalidation_scoreを含む -
job_failed—failure_stage(どのエージェントが失敗したか) -
api_key_added—providerを含む、キーの内容は決して含まれません -
api_key_tested—providerおよびis_validを含む
The funnel from job_created to job_completed tells me what percentage of jobs successfully reach GitHub. When that percentage drops, something in the pipeline is degrading.
Papertrail — ログ集約
Papertrailに設定されたアラートは5つです:
-
Pipeline failed—run_pipeline_taskからのエラーパターンに一致する任意のログ行でトリガーされます -
agent CRASHED— 未処理のエージェント例外でトリガーされます -
All LLM providers exhausted—AllProvidersExhaustedErrorのときにトリガーされます -
Error R10— Heroku起動タイムアウト(ダイノが$PORTにバインドするのに 60 秒を超えました) -
Error R14— メモリ割当量を超過しました
R10 のアラートは2回発生しました — いずれもリリースフェーズ内で Vite のビルドが想定より長くかかったデプロイ時です。対策として、Herokuのビルドキャッシュを確認して npm 依存関係のインストールレイヤを事前キャッシュしました。
12. CI/CD — GitHub Actions、ブランチ保護、ロールバック
ブランチ戦略
feature/* → dev → main → auto-deploy to Heroku
main は以下の条件で保護されています:
- 直接のプッシュを許可しません(私からのものも含む)
- CI がマージ前に通過する必要があります
- 履歴はリニアのみです(リベースのみ、マージは禁止)
dev は統合が行われる場所です。feature/* ブランチは dev から作成され、PRを介して元に統合されます。
The CI Workflow
# .github/workflows/ci.yml
jobs:
backend:
steps:
- run: pip install ruff
- run: ruff check backend/src
- run: python -c "import backend.src.api.main" # import check
- run: pytest backend/tests/security/ -v
frontend:
steps:
- run: cd frontend && npm ci
- run: cd frontend && npm run build
バックエンドのインポートチェックは軽量ですが、リンターが見逃すエラーの一部を検出します。循環インポート、__init__.py ファイルの欠如、およびモジュールレベルの副作用によって例外が発生するケースです。
ロールバック手順
heroku releases --app hackfarmer-api # list releases
hEROKU rollback v47 --app hackfarmer-api # roll back to specific version
Herokuのスラッグベースのデプロイはロールバックが瞬時です — 事前にビルドされたスラッグをどれが実行中かを切り替えるだけで、再ビルドは発生しません。本番のインシデントに対する回復時間の目標は、2分未満です。
13. 私に最も多くを教えてくれたバグ
バグ1: 何もしなかったキュー
上で説明したとおり — queue_manager はジョブを "running" とマークするが、パイプラインを実際には呼び出しませんでした。手動テストの間は HTTP ルートから直接パイプラインを起動していたため、キューを完全に回避して動作しているように見えました。このバグはキュー経路だけで現れ、ダイノがすでに3つの同時ジョブを受け入れた後に使用した経路でした。
Root lesson: キュー経路を明示的にテストしてください。非キュー経路とキュー経路が同じだと想定してはいけません。
バグ2: ソースにSentry DSNをハードコーディング
フロントエンドの Sentry DSN を直接 main.jsx にコミットしていました。DSN は APIキーのような秘密情報ではなく、公開を前提に設計されていますが、環境固有の値をハードコーディングするのは依然として好ましくありません。正しい修正は import.meta.env.VITE_SENTRY_DSN に移動し、ビルド時の環境変数として設定することです。これにより、コードを変更せずにステージングと本番で異なる DSN を使用できます。
バグ3: ProjectState に欠落しているフィールド
Python の TypedDict は実行時には強制されません。宣言されていないキーを設定してもエラーにはなりません — ただ設定されるだけです。LangGraph のチェックポイントは状態を直列化し、宣言されていないキーを黙って削除しました。GitHub エージェントは state["repo_name"] を読み取り、実運用環境で実行時に KeyError を受けました。
Root lesson: TypedDict を厳格なスキーマとして扱ってください。コードベースのどこかでキーを設定する場合、そのキーは TypedDict に宣言され、正規化処理でデフォルト値が設定されている必要があります。
バグ4: 起きなかった、だがほぼ起きた並行エージェント書き込みの衝突
あるリファクタリングの際、Frontend エージェントを一時的に変更して、architecture キーにも要約を書き込むようにしました(Architect エージェントが書き込むものです)。両方のエージェントは並行して実行されました。結果は非決定的でした。時には Frontend の上書きが Architect の書き込みの後に到着し、時には前に到着しました。統合ステップが正しいアーキテクチャデータを取得することもあれば、1行の要約だけを取得することもあったため、バグは微妙でした。
Root lesson: 並列ファンアウトでは、各エージェントは正確に1セットの状態キーを所有する必要があります。共有の書き込みは、発生するまで生産インシデントを待つレース条件です。
14. 何を違うようにするか
in-memory の asyncio.Queue の代わりに、適切なジョブキューを使うべきです。 現在のキューは dyno の再起動時に失われます。5 件のジョブがキューに溜まっている状態で dyno が再起動すると、それらのジョブは消えてしまいます。Redis + Celery、または Heroku の Scheduler、あるいは Appwrite Functions などがキューを永続化します。
ストリーミングLLM応答を追加してください。 現在、フロントエンドはエージェントレベルで agent_start、agent_done のイベントを受け取ります。LLM からストリーミングされるトークンは表示されません。これにより、Integrator が動作している30〜45秒の間、ユーザーはフィードバックのないスピナーを見ます。Server-Sent Events を介してフロントエンドへトークンをストリーミングすることで、知覚されるパフォーマンスが大幅に向上します。
スケーリングのためにエージェントごとのマイクロサービス化へ移行してください。 現在のアーキテクチャはすべて1つのプロセスで実行しています。スケールさせたい場合 — 複数の Heroku dynos や Kubernetes のポッド — 各エージェントを独立したサービスとして切り出し、メッセージバス(RabbitMQ、NATS)で通信する必要があります。LangGraph の抽象化は、StateGraph の定義が実行環境から分離されているため、これを実現しやすくします。
リポジトリ名の入力検証を追加してください。 GitHub のリポジトリ名には厳格なルールがあります: 空白は不可、許可された文字が限定され、最大100文字。現在のシステムは Analyst エージェントが入力から抽出した値を信頼しています。形式が不正なリポジトリ名は GitHub エージェントを難解な 422 エラーで失敗させます。Analyst と Architect の間に正規表現検証ステップを挟むと早期に検出できます。
統合テストを書いてください。 セキュリティの単体テストとデプロイ前のチェックリストスクリプトはありますが、モックの LLM 応答を使って完全なパイプラインをエンドツーエンドで検証する統合テストはありません。これらのテストの欠如は、プロジェクト全体で最大の品質リスクです。
結びの考え
私はまだprépaに在籍しています。まだ工学部には進学していません。微積分と物理を教えてくれた教授たちは、これらのことを教えてくれませんでした — 私はドキュメントを読んだり、間違いを犯したり、十分には理解できていなかったものが壊れるまでデバッグすることで学びました。
もし学業の初期段階にあり、技術的に野心的な何かを作ろうと考えているなら、準備が整うと感じる前に始めてください。準備が整ったと感じることはありません。とはいえ、それでも作ってください。
コードベースは github.com/talelboussetta/HackFarm にあります。
Talel Boussetta — チュニジア出身、準備課程の2年生
スタック: FastAPI · LangGraph · Python 3.11 · React 18 · Vite · Zustand · Appwrite · Heroku · LangGraph · Sentry · PostHog · Papertrail · GitHub Actions