1414
1515class CleanupRunService {
1616
17- private const DEFAULT_APPLY_LIMIT = 25 ;
18- private const MAX_APPLY_LIMIT = 100 ;
17+ private const DEFAULT_APPLY_LIMIT = 25 ;
18+ private const MAX_APPLY_LIMIT = 100 ;
19+ private const WORKTREE_APPLY_BATCH_LIMIT = 1 ;
1920
2021
2122
@@ -101,9 +102,10 @@ public function apply( string $run_id, array $opts = array() ): array|\WP_Error
101102 $ results = array ();
102103
103104 if ( array () !== $ artifact_rows ) {
104- $ artifact_batch = array_slice ($ artifact_rows , 0 , $ limit );
105- $ processed_rows += count ($ artifact_batch );
106- $ batch_type = 'artifact_cleanup ' ;
105+ $ artifact_batch = array_slice ($ artifact_rows , 0 , $ limit );
106+ $ processed_rows += count ($ artifact_batch );
107+ $ batch_type = 'artifact_cleanup ' ;
108+ $ this ->mark_batch_applying ($ artifact_batch , $ run_id , $ batch_type , $ limit , $ remaining_rows );
107109 $ results ['artifact_cleanup ' ] = $ this ->workspace ->worktree_cleanup_artifacts (
108110 array (
109111 'apply_plan ' => array ( 'candidates ' => array_map (fn ( $ item ) => $ item ['evidence ' ], $ artifact_batch ) ),
@@ -116,9 +118,10 @@ public function apply( string $run_id, array $opts = array() ): array|\WP_Error
116118
117119 $ remaining_capacity = max (0 , $ limit - $ processed_rows );
118120 if ( $ remaining_capacity > 0 && array () !== $ worktree_rows ) {
119- $ worktree_batch = array_slice ($ worktree_rows , 0 , $ remaining_capacity );
120- $ processed_rows += count ($ worktree_batch );
121- $ batch_type = '' === $ batch_type ? 'worktree_removal ' : 'mixed ' ;
121+ $ worktree_batch = array_slice ($ worktree_rows , 0 , min ($ remaining_capacity , self ::WORKTREE_APPLY_BATCH_LIMIT ));
122+ $ processed_rows += count ($ worktree_batch );
123+ $ batch_type = '' === $ batch_type ? 'worktree_removal ' : 'mixed ' ;
124+ $ this ->mark_batch_applying ($ worktree_batch , $ run_id , $ batch_type , $ limit , $ remaining_rows );
122125 $ results ['worktree_removal ' ] = $ this ->workspace ->worktree_cleanup_merged (
123126 array (
124127 'apply_plan ' => array ( 'candidates ' => array_map (fn ( $ item ) => $ item ['evidence ' ], $ worktree_batch ) ),
@@ -195,13 +198,15 @@ public function status( string $run_id ): array|\WP_Error {
195198 $ summary ['items_by_status ' ][ $ status ] = ( $ summary ['items_by_status ' ][ $ status ] ?? 0 ) + 1 ;
196199 $ summary ['items_by_type ' ][ $ type ] = ( $ summary ['items_by_type ' ][ $ type ] ?? 0 ) + 1 ;
197200 $ summary ['bytes_reclaimed ' ] += max (0 , (int ) ( $ item ['bytes_reclaimed ' ] ?? 0 ));
198- if ( in_array ($ status , array ( 'pending ' , 'failed ' ), true ) ) {
201+ if ( in_array ($ status , array ( 'pending ' , 'failed ' , ' applying ' ), true ) ) {
199202 ++$ summary ['pending_or_failed ' ];
200203 }
201204 }
202205 ksort ($ summary ['items_by_status ' ]);
203206 ksort ($ summary ['items_by_type ' ]);
204207
208+ $ progress = $ this ->run_progress ($ run , $ items , $ summary );
209+
205210 return array (
206211 'success ' => true ,
207212 'state ' => (string ) ( $ run ['status ' ] ?? 'unknown ' ),
@@ -210,7 +215,8 @@ public function status( string $run_id ): array|\WP_Error {
210215 'mode ' => $ run ['mode ' ] ?? '' ,
211216 'run ' => $ run ,
212217 'summary ' => $ summary ,
213- 'remaining_work_summary ' => CleanupRemainingWorkSummary::from_items ($ items ),
218+ 'progress ' => $ progress ,
219+ 'remaining_work_summary ' => $ this ->remaining_work_summary ($ run_id , $ items , $ progress ),
214220 );
215221 }
216222
@@ -285,7 +291,109 @@ private function plan_items( array $plan ): array {
285291 }
286292
287293 private function pending_rows_of_type ( array $ items , string $ type ): array {
288- return array_values (array_filter ($ items , fn ( $ item ) => (string ) ( $ item ['item_type ' ] ?? '' ) === $ type && in_array ( (string ) ( $ item ['status ' ] ?? '' ), array ( 'pending ' , 'failed ' ), true )));
294+ return array_values (array_filter ($ items , fn ( $ item ) => (string ) ( $ item ['item_type ' ] ?? '' ) === $ type && in_array ( (string ) ( $ item ['status ' ] ?? '' ), array ( 'pending ' , 'failed ' , 'applying ' ), true )));
295+ }
296+
297+ /**
298+ * Mark rows as in-progress before invoking destructive cleanup so interrupted
299+ * operator runs leave a visible, resumable checkpoint instead of silent state.
300+ *
301+ * @param array<int,array<string,mixed>> $items Batch rows.
302+ * @param string $run_id Run ID.
303+ * @param string $batch_type Batch type label.
304+ * @param int $limit Requested apply limit.
305+ * @param int $remaining_rows Rows remaining before this batch.
306+ */
307+ private function mark_batch_applying ( array $ items , string $ run_id , string $ batch_type , int $ limit , int $ remaining_rows ): void {
308+ $ started_at = gmdate ('Y-m-d H:i:s ' );
309+ foreach ( $ items as $ item ) {
310+ $ this ->repository ->update_item (
311+ (int ) $ item ['id ' ],
312+ array (
313+ 'status ' => 'applying ' ,
314+ 'evidence ' => array_merge (
315+ (array ) ( $ item ['evidence ' ] ?? array () ),
316+ array (
317+ 'applying_started_at ' => $ started_at ,
318+ 'applying_batch_type ' => $ batch_type ,
319+ )
320+ ),
321+ )
322+ );
323+ }
324+
325+ $ this ->repository ->update_run (
326+ $ run_id ,
327+ array (
328+ 'summary ' => array (
329+ 'applying_batch ' => array (
330+ 'type ' => $ batch_type ,
331+ 'limit ' => $ limit ,
332+ 'row_count ' => count ($ items ),
333+ 'remaining_before ' => $ remaining_rows ,
334+ 'started_at ' => $ started_at ,
335+ ),
336+ ),
337+ )
338+ );
339+ }
340+
341+ /**
342+ * Build operator progress metadata for status/evidence output.
343+ *
344+ * @param array<string,mixed> $run Run row.
345+ * @param array<int,array<string,mixed>> $items Item rows.
346+ * @param array<string,mixed> $summary Aggregate summary.
347+ * @return array<string,mixed>
348+ */
349+ private function run_progress ( array $ run , array $ items , array $ summary ): array {
350+ $ applying = array_values (array_filter ($ items , fn ( $ item ) => 'applying ' === (string ) ( $ item ['status ' ] ?? '' )));
351+ $ examples = array_slice (array_map (fn ( $ item ) => array (
352+ 'handle ' => (string ) ( $ item ['handle ' ] ?? '' ),
353+ 'type ' => (string ) ( $ item ['item_type ' ] ?? '' ),
354+ ), $ applying ), 0 , 3 );
355+
356+ $ started_at = (string ) ( $ run ['started_at ' ] ?? '' );
357+ $ age = '' !== $ started_at ? max (0 , time () - strtotime ($ started_at )) : 0 ;
358+ $ run_status = (string ) ( $ run ['status ' ] ?? '' );
359+ $ resumable = (int ) ( $ summary ['pending_or_failed ' ] ?? 0 ) > 0 && in_array ($ run_status , array ( 'applying ' , 'needs_resume ' ), true );
360+
361+ return array (
362+ 'applying_rows ' => count ($ applying ),
363+ 'applying_examples ' => $ examples ,
364+ 'pending_or_failed ' => (int ) ( $ summary ['pending_or_failed ' ] ?? 0 ),
365+ 'started_at ' => $ started_at ,
366+ 'age_seconds ' => $ age ,
367+ 'resumable ' => $ resumable ,
368+ 'note ' => count ($ applying ) > 0 ? 'Rows marked applying are safe to retry with workspace cleanup resume if the previous apply process was interrupted. ' : '' ,
369+ );
370+ }
371+
372+ /**
373+ * Build remaining-work summary and prepend the current run resume command.
374+ *
375+ * @param string $run_id Run ID.
376+ * @param array<int,array<string,mixed>> $items Item rows.
377+ * @param array<string,mixed> $progress Progress metadata.
378+ * @return array<string,mixed>
379+ */
380+ private function remaining_work_summary ( string $ run_id , array $ items , array $ progress ): array {
381+ $ summary = CleanupRemainingWorkSummary::from_items ($ items );
382+ if ( ! empty ($ progress ['resumable ' ]) ) {
383+ array_unshift (
384+ $ summary ['recommended_commands ' ],
385+ array (
386+ 'bucket ' => 'current_run_resume ' ,
387+ 'command ' => sprintf ('studio wp datamachine-code workspace cleanup status %s --format=json ' , $ run_id ),
388+ 'apply ' => sprintf ('studio wp datamachine-code workspace cleanup resume %s --limit=%d ' , $ run_id , self ::DEFAULT_APPLY_LIMIT ),
389+ 'destructive ' => false ,
390+ 'apply_destructive ' => true ,
391+ 'why ' => 'Resume the reviewed DB-backed cleanup run from persisted pending/failed/applying rows. ' ,
392+ )
393+ );
394+ }
395+
396+ return $ summary ;
289397 }
290398
291399 private function apply_limit ( array $ opts ): int {
0 commit comments