Skip to content

Commit 703ea3f

Browse files
authored
Merge pull request #18 from mariuspruvot/fix/deleted-pr-branch
fix(container): surface clear error when PR branch is deleted
2 parents 86b02be + 6c5de8e commit 703ea3f

5 files changed

Lines changed: 51 additions & 10 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ infra/
5151
- **Conversation UI**: frontend renders session output as a structured conversation with markdown (react-markdown + remark-gfm), syntax highlighting (shiki with JS regex engine), diff coloring, and collapsible tool_use/thinking blocks. Components: `ConversationOutput` (scroll container) -> `MessageBlock` (role dispatch) -> `MarkdownContent` / `CodeBlock` / `ToolUseBlock` / `ThinkingBlock`. Data flows as `StreamMessage[]` (structured content blocks) instead of flat text lines.
5252
- **Session persistence**: stream-json events are batch-persisted to `session_events` table (JSONB) during SSE streaming via `stream_and_persist()`. Completed sessions can be replayed from `GET /sessions/{id}/events`. The streaming pipeline is layered: `stream_events()` (raw tuples) -> `stream_and_persist()` (SSE + DB writes) or `stream_output()` (SSE only, for tests).
5353
- **Multi-turn via per-turn invocations**: `--input-format stream-json` exits after each turn (by design). The entrypoint runs `claude -p` for the first turn, then loops reading user messages from a FIFO and calling `claude -c -p` (--continue) for each subsequent turn. Each invocation emits its own `system` init + `result` events — frontend should expect multiple `system`/`result` events per session.
54-
- **Stream-json protocol**: containers emit NDJSON with 5 event types: `system` (init/retry), `assistant` (one event per content block — thinking/text/tool_use), `user` (tool_result), `result` (turn end + metadata), `rate_limit_event`. The `result.result` field duplicates the last assistant text — only display assistant events, use result for status only. No `--include-partial-messages` flag, so no `stream_event` deltas.
54+
- **Stream-json protocol**: containers emit NDJSON with 5 event types: `system` (init/retry), `assistant` (one event per content block — thinking/text/tool_use), `user` (tool_result), `result` (turn end + metadata), `rate_limit_event`. The `result.result` field duplicates the last assistant text — only display assistant events, use result for status only. No `--include-partial-messages` flag, so no `stream_event` deltas. A 6th type `error` (`{"type":"error","error":{"message":"..."}}`) is emitted by the entrypoint when setup fails (clone/checkout errors). The SSE `done` event includes both `message` and `status` fields — frontend uses `status` to distinguish `completed` vs `failed`.
5555
- **API prefix**: all routes under `/api/v1`
5656
- **Admin panel**: SQLAdmin at `/admin`, configured in `admin/views.py`
5757

apps/api/src/helprs/modules/container/router.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -158,15 +158,17 @@ async def _event_stream():
158158
# Stream ended naturally — container exited.
159159
# Mark session completed in DB and send done event to frontend.
160160
msg = "Session completed."
161+
status = "completed"
161162
try:
162163
async with get_db_context() as db_ctx:
163-
result = await mark_completed(db_ctx, session_id, docker)
164-
if result.status == ContainerStatus.FAILED:
164+
completed = await mark_completed(db_ctx, session_id, docker)
165+
status = completed.status.value
166+
if completed.status == ContainerStatus.FAILED:
165167
msg = "Session failed."
166168
except Exception:
167169
pass # Best effort; cleanup task handles stragglers
168170

169-
yield f"event: done\ndata: {json.dumps({'message': msg})}\n\n"
171+
yield f"event: done\ndata: {json.dumps({'message': msg, 'status': status})}\n\n"
170172
finally:
171173
await docker.close()
172174

apps/web/src/features/session/ContainerSession.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -263,12 +263,17 @@ export default function ContainerSession({
263263
if (!mountedRef.current) return
264264
source.close()
265265
eventSourceRef.current = null
266-
setStatus('completed')
267266
setIsThinking(false)
268267
try {
269-
const parsed = JSON.parse(event.data as string) as { message?: string }
268+
const parsed = JSON.parse(event.data as string) as { message?: string; status?: string }
269+
const finalStatus = parsed.status === 'failed' ? 'failed' : 'completed'
270+
setStatus(finalStatus as ContainerStatus)
271+
if (finalStatus === 'failed') {
272+
setError(parsed.message ?? 'Session failed.')
273+
}
270274
appendStatus(parsed.message ?? 'Session completed.')
271275
} catch {
276+
setStatus('completed')
272277
appendStatus('Session completed.')
273278
}
274279
})

apps/web/src/features/session/containerTypes.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,11 +138,17 @@ interface ResultEvent {
138138
duration_ms?: number
139139
}
140140

141+
interface ErrorEvent {
142+
type: 'error'
143+
error: { message: string }
144+
}
145+
141146
type StreamJsonEvent =
142147
| AssistantEvent
143148
| SystemEvent
144149
| ResultEvent
145150
| UserEvent
151+
| ErrorEvent
146152
| { type: 'rate_limit_event' }
147153
| { type: 'stream_event' }
148154
| { type: string }
@@ -216,6 +222,16 @@ export function parseStreamMessage(raw: string): Omit<StreamMessage, 'id' | 'tim
216222
return null
217223
}
218224

225+
// Entrypoint error events — emitted when setup fails (e.g. deleted branch).
226+
case 'error': {
227+
const err = event as ErrorEvent
228+
return {
229+
role: 'result',
230+
blocks: [{ type: 'text', text: err.error?.message ?? 'Unknown error' }],
231+
isError: true,
232+
}
233+
}
234+
219235
// rate_limit_event, stream_event, unknown — hide
220236
default:
221237
return null

infra/docker/claude-runner/entrypoint.sh

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,34 @@ cleanup() {
1111
}
1212
trap cleanup SIGTERM SIGINT
1313

14+
# Emit a structured error event to stdout so the SSE pipeline relays it
15+
# to the frontend before the container exits.
16+
emit_error() {
17+
local msg="$1"
18+
# Escape backslashes and double quotes for JSON safety
19+
msg="${msg//\\/\\\\}"
20+
msg="${msg//\"/\\\"}"
21+
printf '{"type":"error","error":{"message":"%s"}}\n' "$msg"
22+
}
23+
1424
# gh CLI auto-detects GITHUB_TOKEN env var -- no explicit login needed.
1525
gh auth status > /dev/null 2>&1 || {
16-
echo "ERROR: GitHub authentication failed" >&2
26+
emit_error "GitHub authentication failed. The token may be expired or invalid."
1727
exit 1
1828
}
1929

20-
# Clone the repo and check out the PR branch
21-
gh repo clone "$REPO_FULL_NAME" /workspace
30+
# Clone the repo and check out the PR branch.
31+
# Errors from gh/git go to stderr (invisible to our stdout-only stream),
32+
# so we emit a structured error event before exiting on failure.
33+
gh repo clone "$REPO_FULL_NAME" /workspace || {
34+
emit_error "Failed to clone repository ${REPO_FULL_NAME}. It may have been deleted or made private."
35+
exit 1
36+
}
2237
cd /workspace
23-
gh pr checkout "$PR_NUMBER"
38+
gh pr checkout "$PR_NUMBER" || {
39+
emit_error "Failed to checkout PR #${PR_NUMBER}. The branch may have been deleted after the PR was merged."
40+
exit 1
41+
}
2442

2543
# Fetch PR metadata for the prompt context
2644
PR_TITLE=$(gh pr view "$PR_NUMBER" --json title --jq '.title')

0 commit comments

Comments
 (0)