* Improve asynchronous social publishing reliability
* fix: resume asynchronous social publishes
* fix: preserve publish checkpoints across retries
* fix: harden resumable publish lifecycle
* fix: clean retry resources on terminal failures
* test: cover resumable social publishing edge cases
* feat: add failed post retry command
* chore: remove retry command ai rule
* fix: require confirmation for post retries
* chore: remove ai rules index
* chore: remove ai social rule
* refactor: clarify TikTok derivative path validation
* refactor: simplify social publishing retries
* refactor: further simplify social publishing retries
* refactor: retry all failed post platforms
* style: import throwable in social retries
* refactor: decouple TikTok cleanup from image format
* refactor: extract missing publish scopes
* refactor: encapsulate missing scope failure
* fix: resume failed publishes and treat Instagram rate limits as transient
Keep TikTok/Instagram checkpoints on posts:retry so a manual retry does not
start a duplicate remote post. Classify Meta BUC 400s on Instagram status
polls as retryable via GraphError.
Co-authored-by: Cursor <cursoragent@cursor.com>
* test: cover resume paths and transient Instagram rate limits
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: resume posts:retry only for in-flight publish failures
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: resume posts:retry via ErrorCategory instead of string lists
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: handle Instagram PUBLISHED and EXPIRED container statuses
Treat EXPIRED as a terminal server error so posts:retry starts over, and complete already-published containers without a second media_publish.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: recover published Instagram stories from /stories
Stories are not on GET /{ig-user-id}/media. Resume a PUBLISHED story container from the stories edge so we do not bind a feed post id.
Co-authored-by: Cursor <cursoragent@cursor.com>
* test: cover Instagram EXPIRED retry and published recovery paths
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: stop guessing Instagram media ids from recent /media
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: checkpoint TikTok publish_id and keep in-flight photo derivatives
Persist publish_id right after /init/ so a crash can resume without a second publish. Keep hosted photos while that id is resumable, including token expiry on status fetch; prune only after success or a confirmed remote failure.
Co-authored-by: Cursor <cursoragent@cursor.com>
* test: cover remaining TikTok in-flight derivative edge cases
Guard the empty publish_id prune path, account guards without a checkpoint, and video status 401 after /init/.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: map TikTok publish statuses with an official enum
Use PublishStatus for status/fetch values from the Content Posting API. Keep only the documented cases, including FAILED as the terminal failure.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: share in-flight publish checkpoint keys
Read TikTok and Instagram resume state through one helper so publishers, posts:retry, and derivative cleanup agree on the same keys.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: retry Instagram media_publish after transient Graph failures
A 500/code 2 after Meta already published left the job Failed as unknown.
Treat that as still-processing so resume can confirm PUBLISHED instead of posting again.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: resume Instagram publish after dropped Graph connections
A timeout or connection reset after Meta already published was marked unknown.
Treat it as still-processing so resume can confirm PUBLISHED instead of posting again.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
86 lines
3.1 KiB
PHP
86 lines
3.1 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Enums\Post\Status as PostStatus;
|
|
use App\Enums\PostPlatform\Status as PlatformStatus;
|
|
use App\Exceptions\Social\ErrorCategory;
|
|
use App\Models\Post;
|
|
use App\Models\PostPlatform;
|
|
use App\Support\Social\TikTokPhotoDerivativeCleaner;
|
|
use Illuminate\Console\Command;
|
|
|
|
class RecoverStuckPosts extends Command
|
|
{
|
|
protected $signature = 'social:recover-stuck-posts';
|
|
|
|
protected $description = 'Recover posts stuck in publishing status for more than 1 hour';
|
|
|
|
public function __construct(
|
|
private readonly TikTokPhotoDerivativeCleaner $tiktokPhotoDerivativeCleaner,
|
|
) {
|
|
parent::__construct();
|
|
}
|
|
|
|
public function handle(): void
|
|
{
|
|
$count = 0;
|
|
|
|
Post::query()
|
|
->where('status', PostStatus::Publishing)
|
|
->where('updated_at', '<=', now()->subHour())
|
|
->each(function (Post $post) use (&$count) {
|
|
$stalePlatforms = $post->postPlatforms()
|
|
->enabled()
|
|
->whereIn('status', [PlatformStatus::Publishing, PlatformStatus::Pending, PlatformStatus::Retrying])
|
|
->where('updated_at', '<=', now()->subHour())
|
|
->get();
|
|
|
|
$stalePlatforms->each(function (PostPlatform $postPlatform): void {
|
|
$this->tiktokPhotoDerivativeCleaner->cleanupUnlessPublishInFlight(
|
|
$postPlatform->error_context,
|
|
$postPlatform->id,
|
|
);
|
|
|
|
$postPlatform->update([
|
|
'status' => PlatformStatus::Failed,
|
|
'error_message' => __('posts.errors.publishing_timed_out'),
|
|
'error_context' => [
|
|
...($postPlatform->error_context ?? []),
|
|
'category' => ErrorCategory::Timeout->value,
|
|
'failed_at' => now()->toIso8601String(),
|
|
],
|
|
]);
|
|
});
|
|
|
|
// Delayed platform-unavailable retries keep the platform Retrying with a
|
|
// fresh updated_at — do not finalize the post while that work is still live.
|
|
$stillActive = $post->postPlatforms()
|
|
->enabled()
|
|
->whereIn('status', [PlatformStatus::Publishing, PlatformStatus::Pending, PlatformStatus::Retrying])
|
|
->exists();
|
|
|
|
if ($stillActive) {
|
|
return;
|
|
}
|
|
|
|
$enabledPlatforms = $post->postPlatforms()->enabled()->get();
|
|
$total = $enabledPlatforms->count();
|
|
$publishedCount = $enabledPlatforms->where('status', PlatformStatus::Published)->count();
|
|
|
|
if ($publishedCount === $total) {
|
|
$post->markAsPublished();
|
|
} elseif ($publishedCount > 0) {
|
|
$post->markAsPartiallyPublished();
|
|
} else {
|
|
$post->markAsFailed();
|
|
}
|
|
|
|
$count++;
|
|
});
|
|
|
|
$this->info("Recovered {$count} stuck posts.");
|
|
}
|
|
}
|