Sprint 005 전체 구현 — F5 (중간 끊김/타임아웃 무한대기) 해결:
Resilience core:
- src/resilience/backoff.ts — exponential backoff + jitter (base 1s, cap 30s)
+ cancellable sleep
- src/resilience/classifier.ts — error → {retryable, reason}
retryable: timeout, network, rate_limit, transient
non-retryable: permission, config, invariant(ZodError)
휴리스틱: ETIMEDOUT/ECONNREFUSED/429 등 메시지 패턴 감지
+ assertAllowedThinkingTier('xhigh' 금지)
- src/resilience/retry.ts — withRetry 래퍼
non-retryable은 즉시 중단, max retries 초과시 classification 반환
- src/resilience/kill.ts — child process SIGTERM→SIGKILL grace 처리
+ 전역 cleanup handler (SIGINT/SIGTERM)
- src/resilience/escalate.ts — recordEscalation + EscalationNotifier
+ listEscalations / resolveEscalation
Prisma:
- Escalation 모델 추가 (pipelineId, reason, errorCategory, attempts, contextSnapshot)
- Pipeline.escalations 역참조
Runner 통합:
- transport.invoke 를 withRetry 로 래핑
- 실패시 classification 기반으로 자동 escalation 기록 (non-retryable만)
- RunOptions 에 maxRetries / notifier 추가
CLI:
- rails resume <pipeline-id> — escalated → idle 전이
- rails abort <pipeline-id> [-r reason] — 강제 종료
Tests (25 신규, 82 total pass):
- backoff: 기본값/지수/캡/jitter 범위/abort
- classifier: 6 error 클래스 + 3 휴리스틱 + xhigh 금지
- withRetry: 성공/재시도 후 성공/non-retryable 즉시 중단/max 초과/abort
검증: tsc --noEmit ✓ | vitest 82/82 ✓ | build ✓ | CLI ✓
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
95 lines
2.6 KiB
Plaintext
95 lines
2.6 KiB
Plaintext
generator client {
|
|
provider = "prisma-client-js"
|
|
}
|
|
|
|
datasource db {
|
|
provider = "mysql"
|
|
url = env("DATABASE_URL")
|
|
}
|
|
|
|
model Pipeline {
|
|
id String @id @db.VarChar(26) // ULID
|
|
projectName String @db.VarChar(255)
|
|
requirements String @db.Text
|
|
currentState String @db.VarChar(50) @default("idle")
|
|
contextJson String @db.LongText
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
transitions StateTransition[]
|
|
actorSpawns ActorSpawn[]
|
|
contracts Contract[]
|
|
escalations Escalation[]
|
|
|
|
@@index([currentState])
|
|
@@index([createdAt])
|
|
@@map("pipelines")
|
|
}
|
|
|
|
model StateTransition {
|
|
id Int @id @default(autoincrement())
|
|
pipelineId String @db.VarChar(26)
|
|
fromState String @db.VarChar(50)
|
|
toState String @db.VarChar(50)
|
|
eventType String @db.VarChar(50)
|
|
eventPayload String @db.LongText
|
|
timestamp DateTime @default(now())
|
|
|
|
pipeline Pipeline @relation(fields: [pipelineId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([pipelineId, timestamp])
|
|
@@index([eventType])
|
|
@@map("state_transitions")
|
|
}
|
|
|
|
model ActorSpawn {
|
|
id Int @id @default(autoincrement())
|
|
pipelineId String @db.VarChar(26)
|
|
actorName String @db.VarChar(100)
|
|
stage String @db.VarChar(50)
|
|
spawnedAt DateTime @default(now())
|
|
exitCode Int?
|
|
exitedAt DateTime?
|
|
resultJson String? @db.LongText
|
|
|
|
pipeline Pipeline @relation(fields: [pipelineId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([pipelineId, spawnedAt])
|
|
@@map("actor_spawns")
|
|
}
|
|
|
|
model Contract {
|
|
id String @id @db.VarChar(26) // ULID
|
|
pipelineId String @db.VarChar(26)
|
|
sprintId String @db.VarChar(100)
|
|
version String @db.VarChar(20) @default("v1")
|
|
bodyJson String @db.LongText
|
|
frozenAt DateTime?
|
|
createdAt DateTime @default(now())
|
|
|
|
pipeline Pipeline @relation(fields: [pipelineId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([pipelineId])
|
|
@@index([sprintId])
|
|
@@map("contracts")
|
|
}
|
|
|
|
model Escalation {
|
|
id String @id @db.VarChar(26) // ULID
|
|
pipelineId String @db.VarChar(26)
|
|
reason String @db.VarChar(500)
|
|
errorCategory String @db.VarChar(50)
|
|
stage String @db.VarChar(50) @default("")
|
|
attempts Int @default(0)
|
|
contextSnapshot String @db.LongText
|
|
resolvedAt DateTime?
|
|
resolution String? @db.VarChar(50)
|
|
createdAt DateTime @default(now())
|
|
|
|
pipeline Pipeline @relation(fields: [pipelineId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([pipelineId])
|
|
@@index([createdAt])
|
|
@@map("escalations")
|
|
}
|