mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-01-30 06:12:06 +00:00
feat: Add robust fallback mechanisms for spec creation chat
Add multiple escape hatches to prevent users from getting stuck during
spec creation when the WebSocket completion signal fails.
Changes:
- Add "Exit to Project" button always visible in chat header
- Add /exit command detection to immediately exit to project
- Add backend GET /api/spec/status/{project} endpoint to poll status file
- Add getSpecStatus() API function in frontend
- Add status file polling (every 3s) in useSpecChat hook
- Update create-spec.md with status file write instructions
How it works:
1. Happy path: Claude writes .spec_status.json as final step, UI polls
and detects completion, shows "Continue to Project" button
2. Escape hatch: User can always click "Exit to Project" or type /exit
to instantly select the project and close modal, then manually start
the agent from the main UI
This ensures users always have a way forward even if the WebSocket
completion detection fails due to tool call tracking issues.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -473,7 +473,50 @@ After: **CRITICAL:** You must create exactly **25** features using the `feature
|
|||||||
|
|
||||||
**Verify the update:** After editing, read the file again to confirm the feature count appears correctly. If `[FEATURE_COUNT]` still appears in the file, the update failed and you must try again.
|
**Verify the update:** After editing, read the file again to confirm the feature count appears correctly. If `[FEATURE_COUNT]` still appears in the file, the update failed and you must try again.
|
||||||
|
|
||||||
**Note:** You do NOT need to update `coding_prompt.md` - the coding agent works through features one at a time regardless of total count.
|
**Note:** You may also update `coding_prompt.md` if the user requests changes to how the coding agent should work. Include it in the status file if modified.
|
||||||
|
|
||||||
|
## 3. Write Status File (REQUIRED - Do This Last)
|
||||||
|
|
||||||
|
**Output path:** `$ARGUMENTS/prompts/.spec_status.json`
|
||||||
|
|
||||||
|
**CRITICAL:** After you have completed ALL requested file changes, write this status file to signal completion to the UI. This is required for the "Continue to Project" button to appear.
|
||||||
|
|
||||||
|
Write this JSON file:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "complete",
|
||||||
|
"version": 1,
|
||||||
|
"timestamp": "[current ISO 8601 timestamp, e.g., 2025-01-15T14:30:00.000Z]",
|
||||||
|
"files_written": [
|
||||||
|
"prompts/app_spec.txt",
|
||||||
|
"prompts/initializer_prompt.md"
|
||||||
|
],
|
||||||
|
"feature_count": [the feature count from Phase 4L]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Include ALL files you modified** in the `files_written` array. If the user asked you to also modify `coding_prompt.md`, include it:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "complete",
|
||||||
|
"version": 1,
|
||||||
|
"timestamp": "2025-01-15T14:30:00.000Z",
|
||||||
|
"files_written": [
|
||||||
|
"prompts/app_spec.txt",
|
||||||
|
"prompts/initializer_prompt.md",
|
||||||
|
"prompts/coding_prompt.md"
|
||||||
|
],
|
||||||
|
"feature_count": 35
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**IMPORTANT:**
|
||||||
|
- Write this file LAST, after all other files are successfully written
|
||||||
|
- Only write it when you consider ALL requested work complete
|
||||||
|
- The UI polls this file to detect completion and show the Continue button
|
||||||
|
- If the user asks for additional changes after you've written this, you may update it again when the new changes are complete
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -487,7 +530,9 @@ Once files are generated, tell the user what to do next:
|
|||||||
> - `$ARGUMENTS/prompts/app_spec.txt`
|
> - `$ARGUMENTS/prompts/app_spec.txt`
|
||||||
> - `$ARGUMENTS/prompts/initializer_prompt.md`
|
> - `$ARGUMENTS/prompts/initializer_prompt.md`
|
||||||
>
|
>
|
||||||
> **Next step:** Type `/exit` to exit this Claude session. The autonomous coding agent will start automatically.
|
> The **Continue to Project** button should now appear. Click it to start the autonomous coding agent!
|
||||||
|
>
|
||||||
|
> **If you don't see the button:** Type `/exit` or click **Exit to Project** in the header.
|
||||||
>
|
>
|
||||||
> **Important timing expectations:**
|
> **Important timing expectations:**
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -98,6 +98,67 @@ async def cancel_session(project_name: str):
|
|||||||
return {"success": True, "message": "Session cancelled"}
|
return {"success": True, "message": "Session cancelled"}
|
||||||
|
|
||||||
|
|
||||||
|
class SpecFileStatus(BaseModel):
|
||||||
|
"""Status of spec files on disk (from .spec_status.json)."""
|
||||||
|
exists: bool
|
||||||
|
status: str # "complete" | "in_progress" | "not_started"
|
||||||
|
feature_count: Optional[int] = None
|
||||||
|
timestamp: Optional[str] = None
|
||||||
|
files_written: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status/{project_name}", response_model=SpecFileStatus)
|
||||||
|
async def get_spec_file_status(project_name: str):
|
||||||
|
"""
|
||||||
|
Get spec creation status by reading .spec_status.json from the project.
|
||||||
|
|
||||||
|
This is used for polling to detect when Claude has finished writing spec files.
|
||||||
|
Claude writes this status file as the final step after completing all spec work.
|
||||||
|
"""
|
||||||
|
if not validate_project_name(project_name):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid project name")
|
||||||
|
|
||||||
|
project_dir = _get_project_path(project_name)
|
||||||
|
if not project_dir:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found in registry")
|
||||||
|
|
||||||
|
if not project_dir.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Project directory not found")
|
||||||
|
|
||||||
|
status_file = project_dir / "prompts" / ".spec_status.json"
|
||||||
|
|
||||||
|
if not status_file.exists():
|
||||||
|
return SpecFileStatus(
|
||||||
|
exists=False,
|
||||||
|
status="not_started",
|
||||||
|
feature_count=None,
|
||||||
|
timestamp=None,
|
||||||
|
files_written=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(status_file.read_text(encoding="utf-8"))
|
||||||
|
return SpecFileStatus(
|
||||||
|
exists=True,
|
||||||
|
status=data.get("status", "unknown"),
|
||||||
|
feature_count=data.get("feature_count"),
|
||||||
|
timestamp=data.get("timestamp"),
|
||||||
|
files_written=data.get("files_written", []),
|
||||||
|
)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.warning(f"Invalid JSON in spec status file: {e}")
|
||||||
|
return SpecFileStatus(
|
||||||
|
exists=True,
|
||||||
|
status="error",
|
||||||
|
feature_count=None,
|
||||||
|
timestamp=None,
|
||||||
|
files_written=[],
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error reading spec status file: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to read status file")
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# WebSocket Endpoint
|
# WebSocket Endpoint
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -148,6 +148,12 @@ export function NewProjectModal({
|
|||||||
setSpecMethod(null)
|
setSpecMethod(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleExitToProject = () => {
|
||||||
|
// Exit chat and go directly to project - user can start agent manually
|
||||||
|
onProjectCreated(projectName.trim())
|
||||||
|
handleClose()
|
||||||
|
}
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
setStep('name')
|
setStep('name')
|
||||||
setProjectName('')
|
setProjectName('')
|
||||||
@@ -178,6 +184,7 @@ export function NewProjectModal({
|
|||||||
projectName={projectName.trim()}
|
projectName={projectName.trim()}
|
||||||
onComplete={handleSpecComplete}
|
onComplete={handleSpecComplete}
|
||||||
onCancel={handleChatCancel}
|
onCancel={handleChatCancel}
|
||||||
|
onExitToProject={handleExitToProject}
|
||||||
initializerStatus={initializerStatus}
|
initializerStatus={initializerStatus}
|
||||||
initializerError={initializerError}
|
initializerError={initializerError}
|
||||||
onRetryInitializer={handleRetryInitializer}
|
onRetryInitializer={handleRetryInitializer}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { Send, X, CheckCircle2, AlertCircle, Wifi, WifiOff, RotateCcw, Loader2, ArrowRight, Zap, Paperclip } from 'lucide-react'
|
import { Send, X, CheckCircle2, AlertCircle, Wifi, WifiOff, RotateCcw, Loader2, ArrowRight, Zap, Paperclip, ExternalLink } from 'lucide-react'
|
||||||
import { useSpecChat } from '../hooks/useSpecChat'
|
import { useSpecChat } from '../hooks/useSpecChat'
|
||||||
import { ChatMessage } from './ChatMessage'
|
import { ChatMessage } from './ChatMessage'
|
||||||
import { QuestionOptions } from './QuestionOptions'
|
import { QuestionOptions } from './QuestionOptions'
|
||||||
@@ -23,6 +23,7 @@ interface SpecCreationChatProps {
|
|||||||
projectName: string
|
projectName: string
|
||||||
onComplete: (specPath: string, yoloMode?: boolean) => void
|
onComplete: (specPath: string, yoloMode?: boolean) => void
|
||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
|
onExitToProject: () => void // Exit to project without starting agent
|
||||||
initializerStatus?: InitializerStatus
|
initializerStatus?: InitializerStatus
|
||||||
initializerError?: string | null
|
initializerError?: string | null
|
||||||
onRetryInitializer?: () => void
|
onRetryInitializer?: () => void
|
||||||
@@ -32,6 +33,7 @@ export function SpecCreationChat({
|
|||||||
projectName,
|
projectName,
|
||||||
onComplete,
|
onComplete,
|
||||||
onCancel,
|
onCancel,
|
||||||
|
onExitToProject,
|
||||||
initializerStatus = 'idle',
|
initializerStatus = 'idle',
|
||||||
initializerError = null,
|
initializerError = null,
|
||||||
onRetryInitializer,
|
onRetryInitializer,
|
||||||
@@ -86,6 +88,13 @@ export function SpecCreationChat({
|
|||||||
// Allow sending if there's text OR attachments
|
// Allow sending if there's text OR attachments
|
||||||
if ((!trimmed && pendingAttachments.length === 0) || isLoading) return
|
if ((!trimmed && pendingAttachments.length === 0) || isLoading) return
|
||||||
|
|
||||||
|
// Detect /exit command - exit to project without sending to Claude
|
||||||
|
if (/^\s*\/exit\s*$/i.test(trimmed)) {
|
||||||
|
setInput('')
|
||||||
|
onExitToProject()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
sendMessage(trimmed, pendingAttachments.length > 0 ? pendingAttachments : undefined)
|
sendMessage(trimmed, pendingAttachments.length > 0 ? pendingAttachments : undefined)
|
||||||
setInput('')
|
setInput('')
|
||||||
setPendingAttachments([]) // Clear attachments after sending
|
setPendingAttachments([]) // Clear attachments after sending
|
||||||
@@ -210,6 +219,16 @@ export function SpecCreationChat({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Exit to Project - always visible escape hatch */}
|
||||||
|
<button
|
||||||
|
onClick={onExitToProject}
|
||||||
|
className="neo-btn neo-btn-ghost text-sm py-2"
|
||||||
|
title="Exit chat and go to project (you can start the agent manually)"
|
||||||
|
>
|
||||||
|
<ExternalLink size={16} />
|
||||||
|
Exit to Project
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
className="neo-btn neo-btn-ghost p-2"
|
className="neo-btn neo-btn-ghost p-2"
|
||||||
@@ -347,7 +366,7 @@ export function SpecCreationChat({
|
|||||||
? 'Or type a custom response...'
|
? 'Or type a custom response...'
|
||||||
: pendingAttachments.length > 0
|
: pendingAttachments.length > 0
|
||||||
? 'Add a message with your image(s)...'
|
? 'Add a message with your image(s)...'
|
||||||
: 'Type your response...'
|
: 'Type your response... (or /exit to go to project)'
|
||||||
}
|
}
|
||||||
className="neo-input flex-1"
|
className="neo-input flex-1"
|
||||||
disabled={(isLoading && !currentQuestions) || connectionStatus !== 'connected'}
|
disabled={(isLoading && !currentQuestions) || connectionStatus !== 'connected'}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||||
import type { ChatMessage, ImageAttachment, SpecChatServerMessage, SpecQuestion } from '../lib/types'
|
import type { ChatMessage, ImageAttachment, SpecChatServerMessage, SpecQuestion } from '../lib/types'
|
||||||
|
import { getSpecStatus } from '../lib/api'
|
||||||
|
|
||||||
type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error'
|
type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error'
|
||||||
|
|
||||||
@@ -70,6 +71,66 @@ export function useSpecChat({
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Poll status file as fallback completion detection
|
||||||
|
// Claude writes .spec_status.json when done with all spec work
|
||||||
|
useEffect(() => {
|
||||||
|
// Don't poll if already complete
|
||||||
|
if (isComplete) return
|
||||||
|
|
||||||
|
// Start polling after initial delay (let WebSocket try first)
|
||||||
|
const startDelay = setTimeout(() => {
|
||||||
|
const pollInterval = setInterval(async () => {
|
||||||
|
// Stop if already complete
|
||||||
|
if (isCompleteRef.current) {
|
||||||
|
clearInterval(pollInterval)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const status = await getSpecStatus(projectName)
|
||||||
|
|
||||||
|
if (status.exists && status.status === 'complete') {
|
||||||
|
// Status file indicates completion - set complete state
|
||||||
|
setIsComplete(true)
|
||||||
|
setIsLoading(false)
|
||||||
|
|
||||||
|
// Mark any streaming message as done
|
||||||
|
setMessages((prev) => {
|
||||||
|
const lastMessage = prev[prev.length - 1]
|
||||||
|
if (lastMessage?.role === 'assistant' && lastMessage.isStreaming) {
|
||||||
|
return [
|
||||||
|
...prev.slice(0, -1),
|
||||||
|
{ ...lastMessage, isStreaming: false },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return prev
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add system message about completion
|
||||||
|
setMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: generateId(),
|
||||||
|
role: 'system',
|
||||||
|
content: `Spec creation complete! Files written: ${status.files_written.join(', ')}${status.feature_count ? ` (${status.feature_count} features)` : ''}`,
|
||||||
|
timestamp: new Date(),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
clearInterval(pollInterval)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silently ignore polling errors - WebSocket is primary mechanism
|
||||||
|
}
|
||||||
|
}, 3000) // Poll every 3 seconds
|
||||||
|
|
||||||
|
// Cleanup interval on unmount
|
||||||
|
return () => clearInterval(pollInterval)
|
||||||
|
}, 3000) // Start polling after 3 second delay
|
||||||
|
|
||||||
|
return () => clearTimeout(startDelay)
|
||||||
|
}, [projectName, isComplete])
|
||||||
|
|
||||||
const connect = useCallback(() => {
|
const connect = useCallback(() => {
|
||||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -145,6 +145,22 @@ export async function resumeAgent(projectName: string): Promise<AgentActionRespo
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Spec Creation API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface SpecFileStatus {
|
||||||
|
exists: boolean
|
||||||
|
status: 'complete' | 'in_progress' | 'not_started' | 'error' | 'unknown'
|
||||||
|
feature_count: number | null
|
||||||
|
timestamp: string | null
|
||||||
|
files_written: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSpecStatus(projectName: string): Promise<SpecFileStatus> {
|
||||||
|
return fetchJSON(`/spec/status/${encodeURIComponent(projectName)}`)
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Setup API
|
// Setup API
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user