Skip to content

feat: Sentinel AI Learning Engine & Stitch-Loop Orchestrator#453

Merged
jmbish04 merged 6 commits intomainfrom
claude/sentinel-engine
Mar 31, 2026
Merged

feat: Sentinel AI Learning Engine & Stitch-Loop Orchestrator#453
jmbish04 merged 6 commits intomainfrom
claude/sentinel-engine

Conversation

@jmbish04
Copy link
Copy Markdown
Owner

Summary

  • Phase 1: StitchService + StitchLoopWorkflow — durable 3-step pipeline (enhance prompt → generate UX via Stitch MCP → Jules implementation)
  • Phase 2: 11 learning micro-domain Drizzle tables (sessions, threads, messages, enrichment, tags, insights, PR reflections, etc.)
  • Phase 3: Sentinel API routes (/api/sentinel/tasks, insights, orchestrate-ui, health/learning) with auth middleware
  • Phase 4: LearningAgent DurableObject — cron-driven ingestion, MCP enrichment, AI analysis, Contemplation Gate, vectorization
  • Phase 5: PR interceptors (SentinelInterceptor on open/sync, SentinelPostMerge on merge) using PAT auth for human-persona comments
  • Phase 6: Frontend control plane — SentinelDashboard (Recharts), SentinelKanban (5-column), repo-scoped SentinelHud

36 files changed — 28 new files, 8 modified existing files.

Test plan

  • wrangler dev starts without errors
  • pnpm run db:generate:all produces migration files for 11 new learning tables
  • POST /api/sentinel/health/learning returns health status
  • GET /api/sentinel/insights returns empty array (no data yet)
  • POST /api/sentinel/orchestrate-ui triggers StitchLoopWorkflow
  • Create test PR → verify SentinelInterceptor posts analysis comment
  • Navigate to /sentinel → dashboard renders with Recharts
  • Navigate to /sentinel/kanban → 5-column Kanban renders
  • Navigate to /repos/:owner/:repo/sentinel → HUD shows repo-scoped insights

🤖 Generated with Claude Code

Implements the full 6-phase Sentinel system:
- Phase 1: StitchService + StitchLoopWorkflow (durable 3-step pipeline)
- Phase 2: 11 learning micro-domain tables (sessions, threads, insights, reflections, etc.)
- Phase 3: Sentinel API routes (/api/sentinel/tasks, insights, orchestrate, health)
- Phase 4: LearningAgent DurableObject with contemplation gate + vectorization
- Phase 5: PR interceptors (SentinelInterceptor + SentinelPostMerge) using PAT auth
- Phase 6: Frontend control plane (Dashboard, Kanban, repo-scoped HUD)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces the Sentinel Learning Engine, a comprehensive system for architectural pattern detection and automated UI orchestration. It includes the LearningAgent Durable Object for ingesting and analyzing conversations, new GitHub automations (SentinelInterceptor, SentinelPostMerge) for PR analysis, and the StitchLoopWorkflow for design-to-code pipelines. While the implementation is feature-rich, several critical issues were identified regarding database performance and logic. Specifically, multiple N+1 query patterns in the LearningAgent will cause performance degradation, and an empty where clause in the pattern analysis query effectively breaks the ingestion filter. Additionally, the PR diff truncation in the interceptor is too aggressive for meaningful AI analysis, and the singleton pattern used for the Stitch service may lead to environment leakage across requests in the Cloudflare Workers environment.

Comment on lines +293 to +302
const analyzed = await db
.select()
.from(learningMessages)
.where(
and(
// Has AI analysis
// We check for non-null ai_analysis
)
)
.limit(20);
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.

critical

The where clause in this query is effectively empty because the and() function has no arguments. This will cause the query to return the first 20 messages regardless of whether they have been analyzed or not. If the first 20 messages in the table are new and unenriched, the filter at line 304 will result in an empty array, and the agent will fail to process any insights. You should add a condition like isNotNull(learningMessages.aiAnalysis).

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed — replaced empty and() with isNotNull(learningMessages.aiAnalysis) in commit b5495d7.

Comment on lines +160 to +175
const completedSessions = await db
.select()
.from(julesSessions)
.where(eq(julesSessions.status, "completed"))
.orderBy(desc(julesSessions.createdAt))
.limit(20);

for (const session of completedSessions) {
// Check if thread already exists for this session
const existing = await db
.select()
.from(learningThreads)
.where(eq(learningThreads.sourceIdentifier, session.id))
.limit(1);

if (existing.length > 0) continue;
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.

high

This loop performs a database query (learningThreads) for every completedSession found. This is an N+1 query pattern that will become a performance bottleneck as the number of sessions grows. Consider using a join or a NOT EXISTS subquery to fetch only the sessions that haven't been ingested yet in a single query.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed — pre-fetches all ingested sourceIdentifiers into a Set before the loop, eliminating N+1 queries. Commit b5495d7.

Comment on lines +328 to +332
const thread = await db
.select()
.from(learningThreads)
.where(eq(learningThreads.id, msg.threadId))
.limit(1);
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.

high

This is an N+1 query pattern. You are fetching the thread details for every analyzed message inside a loop. Consider fetching all required threads in a single query before entering the loop.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed — batch-fetches all referenced threads into a Map<id, thread> before the loop. Commit b5495d7.


**Diff (truncated to 3000 chars):**
\`\`\`
${diff.substring(0, 3000)}
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.

high

Truncating the PR diff to only 3000 characters is likely too aggressive. Most meaningful PRs will have diffs significantly larger than this, and the AI will lose the context necessary to perform a valid architectural analysis. Consider increasing this limit to at least 50,000 characters or using a more sophisticated truncation strategy that preserves file headers and changed lines.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed — increased diff truncation from 3,000 to 50,000 characters. Commit b5495d7.


for (const msg of analyzedWithAnalysis) {
try {
const parsed = JSON.parse(msg.aiAnalysis!);
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.

medium

Directly calling JSON.parse() on LLM output is risky. LLMs often wrap JSON responses in markdown code blocks (e.g., json ... ), which will cause JSON.parse to throw an error. It is safer to use a utility function that extracts the JSON content from the string before parsing.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed — added safeParseJson() helper that strips markdown code fences before parsing, with graceful null return on failure. Commit b5495d7.

Comment on lines 34 to 46
private static instance: StitchService;

private client: StitchToolClient;

private constructor(apiKey: string) {
this.client = new StitchToolClient({ apiKey });
}
private constructor(private readonly env: Env) {}

/**
* Returns the singleton, lazily connecting with the resolved API key.
* Must be called with `await` since key resolution may be async.
* Returns the singleton `StitchService` for the current request context.
*/
static async getInstance(env: Env): Promise<StitchService> {
public static getInstance(env: Env): StitchService {
if (!StitchService.instance) {
const apiKey = await resolveApiKey(env);
StitchService.instance = new StitchService(apiKey);
StitchService.instance = new StitchService(env);
}
return StitchService.instance;
}
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.

medium

The singleton implementation for StitchService captures the env object in a static property. In Cloudflare Workers, global state can persist across multiple requests within the same isolate. If getInstance is called with a different env (e.g., during testing or in a multi-tenant setup), it will return the instance initialized with the previous request's environment. It is better to avoid storing env in a static singleton or to ensure the instance is truly request-scoped.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed — singleton now uses WeakRef<Env> comparison to detect stale env across requests in the same isolate. Commit b5495d7.

jmbish04 and others added 5 commits March 31, 2026 13:37
The workflow referenced `analyze_drizzle_schema.py` but the actual script
is named `audit_drizzle_schema.py`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Fix empty and() where clause in analyzePatterns — now uses isNotNull(aiAnalysis)
- Eliminate N+1 query in ingestSessions — pre-fetch ingested IDs into a Set
- Eliminate N+1 query in analyzePatterns — batch-fetch threads into a Map
- Add safeParseJson helper to handle LLM markdown-fenced JSON output
- Increase PR diff truncation from 3000 to 50000 chars for meaningful analysis
- Make StitchService singleton request-scoped via WeakRef env comparison

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Merge origin/main (PR #452's Sentinel infra) into claude/sentinel-engine
- Remove duplicate kebab-case schema files (ai-insights.ts, ai-pr-reflections.ts, etc.)
  in favor of main's camelCase versions (aiInsights.ts, aiPrReflections.ts)
- Update all imports to use main's schema exports (learningAiInsights, etc.)
- Merge LearningAgent: keep main's pattern detection + contemplation gate,
  add Sentinel pipeline routes (/ingest, /enrich, /schedule/run, /ingest-pr)
- Remove duplicate v7 migration (LearningAgent already in v1_sentinel)
- Restore Sentinel frontend routes in App.tsx

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Resolve conflicts from PR #450 architecture restructuring:
- Integrate with mountRoutes() pattern in routes/index.ts
- Add LearningAgent to ai/agents/exports.ts barrel
- Add StitchLoopWorkflow to workflows/exports.ts barrel
- Add Sentinel routes to GlobalRoutes.tsx and RepoRoutes.tsx
- Add stitch-loop-workflow to wrangler.jsonc workflows array
- Add db:auto script to package.json

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@jmbish04 jmbish04 merged commit b93293b into main Mar 31, 2026
1 check failed
@jmbish04 jmbish04 deleted the claude/sentinel-engine branch March 31, 2026 23:10
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.

1 participant