@@ -58,6 +58,21 @@ class Atmosphere {
5858 */
5959 private const META_PUBLISH_RETRIES = '_atmosphere_publish_retries ' ;
6060
61+ /**
62+ * Post meta key holding the most recent publish/update failure.
63+ *
64+ * Written by the cron workers when an attempt fails, cleared on the
65+ * next success. Shape: `code`, `message` (sanitized + truncated —
66+ * PDS-supplied strings can carry hostile bytes), `retrying` (whether
67+ * the backoff ladder scheduled another attempt), `time`. Exposed to
68+ * the block editor via the read-only `atmosphere_publish_error`
69+ * REST field so authors can see that a share failed instead of the
70+ * failure vanishing into a WP_DEBUG-gated log line.
71+ *
72+ * @var string
73+ */
74+ private const META_LAST_PUBLISH_ERROR = '_atmosphere_last_publish_error ' ;
75+
6176 /**
6277 * Backoff ladder for transient publish/update failures, in seconds.
6378 *
@@ -715,9 +730,13 @@ public function on_status_change( string $new_status, string $old_status, \WP_Po
715730 * dead retry event (disconnect cleared the queue, the post
716731 * was trashed mid-ladder, a cron event was lost) would
717732 * silently shrink — or zero out — the ladder of the next
718- * publish attempt.
733+ * publish attempt. The stale failure record goes with it:
734+ * on a cleanup transition the delete worker never routes
735+ * through the retry helper, so an old "share failed" notice
736+ * would otherwise stick to a post that is no longer shared.
719737 */
720738 \delete_post_meta ( $ post ->ID , self ::META_PUBLISH_RETRIES );
739+ \delete_post_meta ( $ post ->ID , self ::META_LAST_PUBLISH_ERROR );
721740
722741 if ( $ is_publishable ) {
723742 \wp_clear_scheduled_hook ( 'atmosphere_delete_post ' , array ( $ post ->ID ) );
@@ -1415,11 +1434,15 @@ public function register_share_meta(): void {
14151434 }
14161435
14171436 /**
1418- * Register a read-only REST field with the published post's Bluesky URL.
1419- *
1420- * Lets the block-editor panel show a "View on Bluesky" link once the
1421- * post has been shared, without exposing internal AT-URI meta keys.
1422- * Empty until the post has a Bluesky record.
1437+ * Register the read-only REST fields backing the editor panel.
1438+ *
1439+ * `atmosphere_url` lets the panel show a "View on Bluesky" link once
1440+ * the post has been shared, without exposing internal AT-URI meta
1441+ * keys (empty until the post has a Bluesky record).
1442+ * `atmosphere_publish_error` carries the most recent share failure
1443+ * (null when the last attempt succeeded) so the panel can tell the
1444+ * author a share failed instead of the failure vanishing into a
1445+ * WP_DEBUG-gated log line. Both are edit-context only.
14231446 */
14241447 public function register_share_status_field (): void {
14251448 foreach ( get_supported_post_types () as $ post_type ) {
@@ -1440,6 +1463,51 @@ public function register_share_status_field(): void {
14401463 ),
14411464 )
14421465 );
1466+
1467+ \register_rest_field (
1468+ $ post_type ,
1469+ 'atmosphere_publish_error ' ,
1470+ array (
1471+ 'get_callback ' => static function ( $ post_arr ) {
1472+ $ error = \get_post_meta ( (int ) $ post_arr ['id ' ], self ::META_LAST_PUBLISH_ERROR , true );
1473+
1474+ if ( ! \is_array ( $ error ) || empty ( $ error ['code ' ] ) ) {
1475+ return null ;
1476+ }
1477+
1478+ return array (
1479+ 'code ' => (string ) $ error ['code ' ],
1480+ 'message ' => (string ) ( $ error ['message ' ] ?? '' ),
1481+ 'retrying ' => ! empty ( $ error ['retrying ' ] ),
1482+ 'time ' => (int ) ( $ error ['time ' ] ?? 0 ),
1483+ );
1484+ },
1485+ 'update_callback ' => null ,
1486+ 'schema ' => array (
1487+ 'type ' => array ( 'object ' , 'null ' ),
1488+ 'description ' => \__ ( 'The most recent Bluesky sharing failure for this post, null when the last attempt succeeded. ' , 'atmosphere ' ),
1489+ 'context ' => array ( 'edit ' ),
1490+ 'properties ' => array (
1491+ 'code ' => array (
1492+ 'type ' => 'string ' ,
1493+ 'description ' => \__ ( 'Machine-readable failure code. ' , 'atmosphere ' ),
1494+ ),
1495+ 'message ' => array (
1496+ 'type ' => 'string ' ,
1497+ 'description ' => \__ ( 'Human-readable failure message. ' , 'atmosphere ' ),
1498+ ),
1499+ 'retrying ' => array (
1500+ 'type ' => 'boolean ' ,
1501+ 'description ' => \__ ( 'Whether another automatic attempt is scheduled. ' , 'atmosphere ' ),
1502+ ),
1503+ 'time ' => array (
1504+ 'type ' => 'integer ' ,
1505+ 'description ' => \__ ( 'Unix timestamp of the failed attempt. ' , 'atmosphere ' ),
1506+ ),
1507+ ),
1508+ ),
1509+ )
1510+ );
14431511 }
14441512 }
14451513
@@ -1545,6 +1613,15 @@ static function ( int $post_id ): void {
15451613 ? Publisher::update_post ( $ post )
15461614 : Publisher::publish_post ( $ post );
15471615 self ::log_cron_error ( 'delete_post_publishable_reconcile ' , $ post_id , $ result );
1616+
1617+ /*
1618+ * Same lifecycle as the publish/update workers: a
1619+ * successful reconcile clears the retry counter and
1620+ * the stale failure record, and a transient failure
1621+ * re-queues (as an update — the worker re-checks
1622+ * state when the retry fires).
1623+ */
1624+ self ::maybe_schedule_publish_retry ( 'atmosphere_update_post ' , $ post_id , $ result );
15481625 if ( ! \is_wp_error ( $ result ) ) {
15491626 self ::clear_visibility_cleanup_marker ( $ post );
15501627 }
@@ -1910,7 +1987,14 @@ public static function log_reconcile_cleanup_error( int $post_id, $result ): voi
19101987 * @param mixed $result Publisher result: array on success, `WP_Error` on failure.
19111988 */
19121989 private static function maybe_schedule_publish_retry ( string $ hook , int $ post_id , $ result ): void {
1913- if ( ! \is_wp_error ( $ result ) || ! self ::is_transient_publish_error ( $ result ) ) {
1990+ if ( ! \is_wp_error ( $ result ) ) {
1991+ \delete_post_meta ( $ post_id , self ::META_PUBLISH_RETRIES );
1992+ \delete_post_meta ( $ post_id , self ::META_LAST_PUBLISH_ERROR );
1993+ return ;
1994+ }
1995+
1996+ if ( ! self ::is_transient_publish_error ( $ result ) ) {
1997+ self ::record_publish_error ( $ post_id , $ result , false );
19141998 \delete_post_meta ( $ post_id , self ::META_PUBLISH_RETRIES );
19151999 return ;
19162000 }
@@ -1948,6 +2032,7 @@ private static function maybe_schedule_publish_retry( string $hook, int $post_id
19482032 * would misread as a failure of the ladder the operator
19492033 * deliberately switched off.
19502034 */
2035+ self ::record_publish_error ( $ post_id , $ result , false );
19512036 \delete_post_meta ( $ post_id , self ::META_PUBLISH_RETRIES );
19522037
19532038 if ( ! empty ( $ delays ) ) {
@@ -1964,6 +2049,7 @@ private static function maybe_schedule_publish_retry( string $hook, int $post_id
19642049 return ;
19652050 }
19662051
2052+ self ::record_publish_error ( $ post_id , $ result , true );
19672053 \update_post_meta ( $ post_id , self ::META_PUBLISH_RETRIES , $ attempts + 1 );
19682054 \wp_schedule_single_event (
19692055 \time () + $ delays [ $ attempts ],
@@ -1972,6 +2058,31 @@ private static function maybe_schedule_publish_retry( string $hook, int $post_id
19722058 );
19732059 }
19742060
2061+ /**
2062+ * Persist a publish failure to post meta for the editor to surface.
2063+ *
2064+ * The message flows from `WP_Error::get_error_message()` and can carry
2065+ * PDS-supplied bytes, so it is sanitized and truncated before storage
2066+ * — same caution as `log_cron_error()`, but for a value that ends up
2067+ * rendered in the block editor rather than a log line.
2068+ *
2069+ * @param int $post_id Post the attempt ran for.
2070+ * @param \WP_Error $error The failure.
2071+ * @param bool $retrying Whether the backoff ladder scheduled another attempt.
2072+ */
2073+ private static function record_publish_error ( int $ post_id , \WP_Error $ error , bool $ retrying ): void {
2074+ \update_post_meta (
2075+ $ post_id ,
2076+ self ::META_LAST_PUBLISH_ERROR ,
2077+ array (
2078+ 'code ' => (string ) $ error ->get_error_code (),
2079+ 'message ' => truncate_text ( sanitize_text ( $ error ->get_error_message () ), 300 ),
2080+ 'retrying ' => $ retrying ,
2081+ 'time ' => \time (),
2082+ )
2083+ );
2084+ }
2085+
19752086 /**
19762087 * Whether a publish failure is worth retrying.
19772088 *
0 commit comments