fix: add batch failure recovery to ContentService #55

Merged
Snider merged 1 commit from fix/batch-failure-recovery into main 2026-02-23 11:39:31 +00:00
3 changed files with 415 additions and 15 deletions

View file

@ -118,15 +118,21 @@ class ContentService
/**
* Generate content for a batch.
*
* Progress is persisted to a state file after each article so the batch
* can be resumed after a partial failure. Call generateBatch() or
* resumeBatch() again to pick up from the last saved state.
*
* @param string $batchId Batch identifier (e.g., 'batch-001-link-getting-started')
* @param string $provider AI provider ('gemini' for bulk, 'claude' for refinement)
* @param bool $dryRun If true, shows what would be generated without creating files
* @param int $maxRetries Extra attempts per article on failure (0 = no retry)
* @return array Generation results
*/
public function generateBatch(
string $batchId,
string $provider = 'gemini',
bool $dryRun = false
bool $dryRun = false,
int $maxRetries = 1,
): array {
$spec = $this->loadBatch($batchId);
if (! $spec) {
@ -144,6 +150,13 @@ class ContentService
$promptTemplate = $this->loadPromptTemplate('help-article');
// Load or initialise progress state (skipped for dry runs)
$progress = null;
if (! $dryRun) {
$progress = $this->loadBatchProgress($batchId)
?? $this->initialiseBatchState($batchId, $spec['articles'] ?? [], $provider);
}
foreach ($spec['articles'] ?? [] as $article) {
$slug = $article['slug'] ?? null;
if (! $slug) {
@ -152,34 +165,182 @@ class ContentService
$draftPath = $this->getDraftPath($spec, $slug);
// Skip if already drafted
// Skip if draft file already exists on disk
if (File::exists($draftPath)) {
$results['articles'][$slug] = ['status' => 'skipped', 'reason' => 'already drafted'];
$results['skipped']++;
if ($progress !== null) {
$progress['articles'][$slug]['status'] = 'skipped';
}
continue;
}
if ($dryRun) {
$results['articles'][$slug] = ['status' => 'would_generate', 'path' => $draftPath];
continue;
}
try {
$content = $this->generateArticle($article, $spec, $promptTemplate, $provider);
$this->saveDraft($draftPath, $content, $article);
$results['articles'][$slug] = ['status' => 'generated', 'path' => $draftPath];
$results['generated']++;
} catch (\Exception $e) {
$results['articles'][$slug] = ['status' => 'failed', 'error' => $e->getMessage()];
$results['failed']++;
// Skip articles successfully generated in a prior run
if (($progress['articles'][$slug]['status'] ?? 'pending') === 'generated') {
$results['articles'][$slug] = ['status' => 'skipped', 'reason' => 'previously generated'];
$results['skipped']++;
continue;
}
$priorAttempts = $progress['articles'][$slug]['attempts'] ?? 0;
$articleResult = $this->attemptArticleGeneration($article, $spec, $promptTemplate, $provider, $maxRetries);
if ($articleResult['status'] === 'generated') {
$results['articles'][$slug] = ['status' => 'generated', 'path' => $articleResult['path']];
$results['generated']++;
$progress['articles'][$slug] = [
'status' => 'generated',
'attempts' => $priorAttempts + $articleResult['attempts'],
'last_error' => null,
'generated_at' => now()->toIso8601String(),
'last_attempt_at' => now()->toIso8601String(),
];
} else {
$results['articles'][$slug] = ['status' => 'failed', 'error' => $articleResult['error']];
$results['failed']++;
$progress['articles'][$slug] = [
'status' => 'failed',
'attempts' => $priorAttempts + $articleResult['attempts'],
'last_error' => $articleResult['error'],
'generated_at' => null,
'last_attempt_at' => now()->toIso8601String(),
];
}
// Persist after each article so a crash mid-batch is recoverable
$progress['last_updated'] = now()->toIso8601String();
$this->saveBatchProgress($batchId, $progress);
}
if ($progress !== null) {
$progress['last_updated'] = now()->toIso8601String();
$this->saveBatchProgress($batchId, $progress);
}
return $results;
}
/**
* Resume a batch from its last saved state.
*
* Articles that were successfully generated are skipped; failed and
* pending articles are retried. Returns an error if no progress state
* exists (i.e. generateBatch() has never been called for this batch).
*/
public function resumeBatch(string $batchId, ?string $provider = null, int $maxRetries = 1): array
{
$progress = $this->loadBatchProgress($batchId);
if ($progress === null) {
return ['error' => "No progress state found for batch: {$batchId}"];
}
$provider ??= $progress['provider'] ?? 'gemini';
$result = $this->generateBatch($batchId, $provider, false, $maxRetries);
$result['resumed_from'] = $progress['last_updated'];
return $result;
}
/**
* Load batch progress state from the state file.
*
* Returns null when no state file exists (batch has not been started).
*/
public function loadBatchProgress(string $batchId): ?array
{
$path = $this->getProgressPath($batchId);
if (! File::exists($path)) {
return null;
}
$data = json_decode(File::get($path), true);
return is_array($data) ? $data : null;
}
/**
* Attempt to generate a single article with retry logic.
*
* Returns ['status' => 'generated', 'path' => ..., 'attempts' => N]
* or ['status' => 'failed', 'error' => ..., 'attempts' => N].
*/
protected function attemptArticleGeneration(
array $article,
array $spec,
string $promptTemplate,
string $provider,
int $maxRetries,
): array {
$draftPath = $this->getDraftPath($spec, $article['slug']);
$lastError = null;
$totalAttempts = $maxRetries + 1;
for ($attempt = 1; $attempt <= $totalAttempts; $attempt++) {
try {
$content = $this->generateArticle($article, $spec, $promptTemplate, $provider);
$this->saveDraft($draftPath, $content, $article);
return ['status' => 'generated', 'path' => $draftPath, 'attempts' => $attempt];
} catch (\Exception $e) {
$lastError = $e->getMessage();
}
}
return ['status' => 'failed', 'error' => $lastError, 'attempts' => $totalAttempts];
}
/**
* Initialise a fresh batch progress state.
*/
protected function initialiseBatchState(string $batchId, array $articles, string $provider): array
{
$articleStates = [];
foreach ($articles as $article) {
$slug = $article['slug'] ?? null;
if ($slug) {
$articleStates[$slug] = [
'status' => 'pending',
'attempts' => 0,
'last_error' => null,
'generated_at' => null,
'last_attempt_at' => null,
];
}
}
return [
'batch_id' => $batchId,
'provider' => $provider,
'started_at' => now()->toIso8601String(),
'last_updated' => now()->toIso8601String(),
'articles' => $articleStates,
];
}
/**
* Save batch progress state to the state file.
*/
protected function saveBatchProgress(string $batchId, array $state): void
{
File::put($this->getProgressPath($batchId), json_encode($state, JSON_PRETTY_PRINT));
}
/**
* Get the progress state file path for a batch.
*/
protected function getProgressPath(string $batchId): string
{
return base_path("{$this->batchPath}/{$batchId}.progress.json");
}
/**
* Generate a single article.
*/

View file

@ -104,10 +104,11 @@ Production-quality task list for the AI agent orchestration package.
- Issue: No try/catch around streaming, could fail silently
- Fix: Wrap in exception handling, yield error events
- [ ] **ERR-002: ContentService has no batch failure recovery**
- [x] **ERR-002: ContentService has no batch failure recovery** (FIXED 2026-02-23)
- Location: `Services/ContentService.php::generateBatch()`
- Issue: Failed articles stop processing, no resume capability
- Fix: Add progress tracking, allow resuming from failed point
- Fix: Added progress state file, per-article retry (maxRetries param), `resumeBatch()` method
- Tests: 6 new tests in `tests/Feature/ContentServiceTest.php` covering state persistence, resume, retries
---

View file

@ -2,9 +2,21 @@
use Core\Mod\Agentic\Services\AgenticManager;
use Core\Mod\Agentic\Services\AgenticProviderInterface;
use Core\Mod\Agentic\Services\AgenticResponse;
use Core\Mod\Agentic\Services\ContentService;
use Illuminate\Support\Facades\File;
function makeAgenticResponse(string $content = '## Article Content'): AgenticResponse
{
return new AgenticResponse(
content: $content,
model: 'test-model',
inputTokens: 0,
outputTokens: 0,
durationMs: 0,
);
}
beforeEach(function () {
$this->manager = Mockery::mock(AgenticManager::class);
$this->service = new ContentService($this->manager);
@ -60,11 +72,15 @@ it('handles generation errors gracefully', function () {
File::put($testBatchPath, "# Test Batch\n**Service:** Test\n### Article 1:\n```yaml\nSLUG: test-slug-error\nTITLE: Test\n```");
// Clean up potential leftover draft
// Clean up potential leftover draft and state files
$draftPath = base_path('app/Mod/Agentic/Resources/drafts/help/general/test-slug-error.md');
$statePath = base_path('app/Mod/Agentic/Resources/tasks/batch-test-error.progress.json');
if (File::exists($draftPath)) {
File::delete($draftPath);
}
if (File::exists($statePath)) {
File::delete($statePath);
}
try {
$results = $this->service->generateBatch('batch-test-error', 'gemini', false);
@ -79,5 +95,227 @@ it('handles generation errors gracefully', function () {
if (File::exists($draftPath)) {
File::delete($draftPath);
}
if (File::exists($statePath)) {
File::delete($statePath);
}
}
});
it('returns null progress when no state file exists', function () {
$progress = $this->service->loadBatchProgress('batch-nonexistent-xyz');
expect($progress)->toBeNull();
});
it('saves progress state after batch generation', function () {
$provider = Mockery::mock(AgenticProviderInterface::class);
$provider->shouldReceive('generate')->andThrow(new \Exception('API Error'));
$this->manager->shouldReceive('provider')->with('gemini')->andReturn($provider);
$promptPath = base_path('app/Mod/Agentic/Resources/prompts/content/help-article.md');
if (! File::exists($promptPath)) {
$this->markTestSkipped('Help article prompt not found');
}
$batchId = 'batch-test-progress';
$batchPath = base_path("app/Mod/Agentic/Resources/tasks/{$batchId}.md");
$statePath = base_path("app/Mod/Agentic/Resources/tasks/{$batchId}.progress.json");
File::put($batchPath, "# Test Batch\n**Service:** Test\n### Article 1:\n```yaml\nSLUG: progress-slug-a\nTITLE: Test A\n```\n### Article 2:\n```yaml\nSLUG: progress-slug-b\nTITLE: Test B\n```");
try {
$this->service->generateBatch($batchId, 'gemini', false, 0);
$progress = $this->service->loadBatchProgress($batchId);
expect($progress)->toBeArray();
expect($progress['batch_id'])->toBe($batchId);
expect($progress['provider'])->toBe('gemini');
expect($progress['articles'])->toHaveKeys(['progress-slug-a', 'progress-slug-b']);
expect($progress['articles']['progress-slug-a']['status'])->toBe('failed');
expect($progress['articles']['progress-slug-a']['attempts'])->toBe(1);
expect($progress['articles']['progress-slug-a']['last_error'])->toBe('API Error');
} finally {
File::deleteDirectory(base_path('app/Mod/Agentic/Resources/drafts/help/general'), true);
if (File::exists($batchPath)) {
File::delete($batchPath);
}
if (File::exists($statePath)) {
File::delete($statePath);
}
}
});
it('skips previously generated articles on second run', function () {
$callCount = 0;
$provider = Mockery::mock(AgenticProviderInterface::class);
$provider->shouldReceive('generate')
->andReturnUsing(function () use (&$callCount) {
$callCount++;
return makeAgenticResponse();
});
$this->manager->shouldReceive('provider')->with('gemini')->andReturn($provider);
$promptPath = base_path('app/Mod/Agentic/Resources/prompts/content/help-article.md');
if (! File::exists($promptPath)) {
$this->markTestSkipped('Help article prompt not found');
}
$batchId = 'batch-test-resume-skip';
$batchPath = base_path("app/Mod/Agentic/Resources/tasks/{$batchId}.md");
$statePath = base_path("app/Mod/Agentic/Resources/tasks/{$batchId}.progress.json");
$draftDir = base_path('app/Mod/Agentic/Resources/drafts/help/general');
File::put($batchPath, "# Test Batch\n**Service:** Test\n### Article 1:\n```yaml\nSLUG: resume-skip-slug-a\nTITLE: Test A\n```\n### Article 2:\n```yaml\nSLUG: resume-skip-slug-b\nTITLE: Test B\n```");
try {
// First run generates both articles
$first = $this->service->generateBatch($batchId, 'gemini', false, 0);
expect($first['generated'])->toBe(2);
expect($callCount)->toBe(2);
// Second run skips already-generated articles
$second = $this->service->generateBatch($batchId, 'gemini', false, 0);
expect($second['generated'])->toBe(0);
expect($second['skipped'])->toBe(2);
// Provider should not have been called again
expect($callCount)->toBe(2);
} finally {
foreach (['resume-skip-slug-a', 'resume-skip-slug-b'] as $slug) {
$draft = "{$draftDir}/{$slug}.md";
if (File::exists($draft)) {
File::delete($draft);
}
}
if (File::exists($batchPath)) {
File::delete($batchPath);
}
if (File::exists($statePath)) {
File::delete($statePath);
}
}
});
it('resume returns error when no prior state exists', function () {
$result = $this->service->resumeBatch('batch-no-state-xyz');
expect($result)->toHaveKey('error');
expect($result['error'])->toContain('No progress state found');
});
it('resume retries only failed and pending articles', function () {
$slugs = ['resume-retry-a', 'resume-retry-b'];
$callCount = 0;
$provider = Mockery::mock(AgenticProviderInterface::class);
$provider->shouldReceive('generate')
->andReturnUsing(function () use (&$callCount) {
$callCount++;
// Call 1: A on first run → fails
// Call 2: B on first run → succeeds
// Resume run: only A is retried (B is already generated)
if ($callCount === 1) {
throw new \Exception('Transient Error');
}
return makeAgenticResponse('## Content');
});
$this->manager->shouldReceive('provider')->with('gemini')->andReturn($provider);
$promptPath = base_path('app/Mod/Agentic/Resources/prompts/content/help-article.md');
if (! File::exists($promptPath)) {
$this->markTestSkipped('Help article prompt not found');
}
$batchId = 'batch-test-resume-retry';
$batchPath = base_path("app/Mod/Agentic/Resources/tasks/{$batchId}.md");
$statePath = base_path("app/Mod/Agentic/Resources/tasks/{$batchId}.progress.json");
$draftDir = base_path('app/Mod/Agentic/Resources/drafts/help/general');
File::put($batchPath, "# Test Batch\n**Service:** Test\n### Article 1:\n```yaml\nSLUG: resume-retry-a\nTITLE: Retry A\n```\n### Article 2:\n```yaml\nSLUG: resume-retry-b\nTITLE: Retry B\n```");
try {
// First run: A fails, B succeeds
$first = $this->service->generateBatch($batchId, 'gemini', false, 0);
expect($first['failed'])->toBe(1);
expect($first['generated'])->toBe(1);
expect($first['articles']['resume-retry-a']['status'])->toBe('failed');
expect($first['articles']['resume-retry-b']['status'])->toBe('generated');
// Resume: only retries failed article A
$resumed = $this->service->resumeBatch($batchId, 'gemini', 0);
expect($resumed)->toHaveKey('resumed_from');
expect($resumed['skipped'])->toBeGreaterThanOrEqual(1); // B is skipped
expect($resumed['articles']['resume-retry-b']['status'])->toBe('skipped');
} finally {
foreach ($slugs as $slug) {
$draft = "{$draftDir}/{$slug}.md";
if (File::exists($draft)) {
File::delete($draft);
}
}
if (File::exists($batchPath)) {
File::delete($batchPath);
}
if (File::exists($statePath)) {
File::delete($statePath);
}
}
});
it('retries individual failures up to maxRetries times', function () {
$callCount = 0;
$provider = Mockery::mock(AgenticProviderInterface::class);
$provider->shouldReceive('generate')
->andReturnUsing(function () use (&$callCount) {
$callCount++;
if ($callCount < 3) {
throw new \Exception("Attempt {$callCount} failed");
}
return makeAgenticResponse('## Content');
});
$this->manager->shouldReceive('provider')->with('gemini')->andReturn($provider);
$promptPath = base_path('app/Mod/Agentic/Resources/prompts/content/help-article.md');
if (! File::exists($promptPath)) {
$this->markTestSkipped('Help article prompt not found');
}
$batchId = 'batch-test-maxretries';
$batchPath = base_path("app/Mod/Agentic/Resources/tasks/{$batchId}.md");
$statePath = base_path("app/Mod/Agentic/Resources/tasks/{$batchId}.progress.json");
$draftPath = base_path('app/Mod/Agentic/Resources/drafts/help/general/maxretries-slug.md');
File::put($batchPath, "# Test Batch\n**Service:** Test\n### Article 1:\n```yaml\nSLUG: maxretries-slug\nTITLE: Retry Test\n```");
try {
// With maxRetries=2 (3 total attempts), succeeds on 3rd attempt
$results = $this->service->generateBatch($batchId, 'gemini', false, 2);
expect($results['generated'])->toBe(1);
expect($results['failed'])->toBe(0);
expect($results['articles']['maxretries-slug']['status'])->toBe('generated');
expect($callCount)->toBe(3);
$progress = $this->service->loadBatchProgress($batchId);
expect($progress['articles']['maxretries-slug']['status'])->toBe('generated');
expect($progress['articles']['maxretries-slug']['attempts'])->toBe(3);
} finally {
if (File::exists($batchPath)) {
File::delete($batchPath);
}
if (File::exists($statePath)) {
File::delete($statePath);
}
if (File::exists($draftPath)) {
File::delete($draftPath);
}
}
});