@@ -22,6 +22,8 @@ class WorkspaceAbandonedCleanupOrchestrator {
2222 /** @var callable */
2323 private $ clock ;
2424
25+ private ?object $ active_no_signal_report_ability = null ;
26+
2527 /**
2628 * @param callable|null $ability_resolver Optional resolver receiving an ability name.
2729 * @param callable|null $clock Optional clock returning microtime-style seconds.
@@ -69,7 +71,7 @@ public function run( array $input ): array|\WP_Error {
6971 $ deadline = $ this ->now () + $ budget_seconds ;
7072 }
7173
72- $ abilities = $ this ->resolve_required_abilities ();
74+ $ abilities = $ this ->resolve_required_abilities ($ active_no_signal_drain );
7375 if ( is_wp_error ($ abilities ) ) {
7476 return $ abilities ;
7577 }
@@ -217,7 +219,7 @@ private function stage_order(): array {
217219 }
218220
219221 /** @return array<string,mixed>|\WP_Error */
220- private function resolve_required_abilities (): array |\WP_Error {
222+ private function resolve_required_abilities ( bool $ active_no_signal_drain = false ): array |\WP_Error {
221223 $ required = array (
222224 'reconcile_metadata ' => 'datamachine-code/workspace-worktree-reconcile-metadata ' ,
223225 'finalized ' => 'datamachine-code/workspace-worktree-active-no-signal-finalized-apply ' ,
@@ -227,6 +229,9 @@ private function resolve_required_abilities(): array|\WP_Error {
227229 'bounded_apply ' => 'datamachine-code/workspace-worktree-bounded-cleanup-eligible-apply ' ,
228230 'prune ' => 'datamachine-code/workspace-worktree-prune ' ,
229231 );
232+ if ( $ active_no_signal_drain ) {
233+ $ required ['active_no_signal_report ' ] = 'datamachine-code/workspace-worktree-active-no-signal-report ' ;
234+ }
230235
231236 $ abilities = array ();
232237 foreach ( $ required as $ key => $ ability_name ) {
@@ -236,6 +241,7 @@ private function resolve_required_abilities(): array|\WP_Error {
236241 }
237242 $ abilities [ $ key ] = $ ability ;
238243 }
244+ $ this ->active_no_signal_report_ability = $ abilities ['active_no_signal_report ' ] ?? null ;
239245
240246 return $ abilities ;
241247 }
@@ -334,12 +340,122 @@ private function finalize_result( array $result, bool $apply, bool $force, int $
334340 if ( empty ($ result ['continuation ' ]) && ! $ force && ! $ active_no_signal_drain ) {
335341 $ result ['next_commands ' ][] = sprintf ('studio wp datamachine-code workspace worktree abandoned --apply --force --limit=%d --passes=%d%s --format=json ' , $ limit , $ passes , '' !== $ until_budget ? ' --until-budget= ' . $ until_budget : '' );
336342 }
343+ if ( $ active_no_signal_drain && empty ($ result ['continuation ' ]) && empty ($ result ['evidence ' ]['budget_exhausted ' ]) ) {
344+ $ this ->append_active_no_signal_backlog_summary ($ result , min ($ limit , 25 ));
345+ }
337346
338347 $ result ['evidence ' ]['elapsed_ms ' ] = (int ) round (( $ this ->now () - $ started_at ) * 1000 );
339348
340349 return $ result ;
341350 }
342351
352+ private function append_active_no_signal_backlog_summary ( array &$ result , int $ limit ): void {
353+ if ( null === $ this ->active_no_signal_report_ability ) {
354+ return ;
355+ }
356+
357+ $ limit = max (1 , $ limit );
358+ $ report = $ this ->execute_ability (
359+ $ this ->active_no_signal_report_ability ,
360+ array (
361+ 'limit ' => $ limit ,
362+ 'offset ' => 0 ,
363+ 'until_budget ' => '15s ' ,
364+ )
365+ );
366+ if ( is_wp_error ($ report ) ) {
367+ $ result ['remaining_active_no_signal_backlog ' ] = array (
368+ 'available ' => false ,
369+ 'reason ' => (string ) $ report ->get_error_code (),
370+ 'message ' => $ report ->get_error_message (),
371+ 'next_commands ' => array (
372+ sprintf ('studio wp datamachine-code workspace worktree active-no-signal-report --limit=%d --offset=0 --format=json ' , $ limit ),
373+ ),
374+ );
375+ return ;
376+ }
377+
378+ $ result ['remaining_active_no_signal_backlog ' ] = $ this ->build_active_no_signal_backlog_summary ($ report , $ limit );
379+ foreach ( (array ) ( $ result ['remaining_active_no_signal_backlog ' ]['next_commands ' ] ?? array () ) as $ command ) {
380+ $ result ['next_commands ' ][] = (string ) $ command ;
381+ }
382+ $ result ['next_commands ' ] = array_values (array_unique (array_filter (array_map ('strval ' , (array ) $ result ['next_commands ' ]))));
383+ }
384+
385+ /** @return array<string,mixed> */
386+ private function build_active_no_signal_backlog_summary ( array $ report , int $ limit ): array {
387+ $ rows = (array ) ( $ report ['rows ' ] ?? array () );
388+ $ summary = (array ) ( $ report ['summary ' ] ?? array () );
389+ $ pagination = (array ) ( $ report ['pagination ' ] ?? array () );
390+ $ total = (int ) ( $ summary ['total_active_no_signal ' ] ?? $ pagination ['total ' ] ?? 0 );
391+ $ sampled = (int ) ( $ summary ['inspected ' ] ?? count ($ rows ) );
392+ $ buckets = array ();
393+
394+ foreach ( (array ) ( $ summary ['by_suggested_action ' ] ?? array () ) as $ reason => $ count ) {
395+ $ buckets [ (string ) $ reason ] = array (
396+ 'count ' => (int ) $ count ,
397+ 'examples ' => array (),
398+ );
399+ }
400+
401+ foreach ( $ rows as $ row ) {
402+ if ( ! is_array ($ row ) ) {
403+ continue ;
404+ }
405+ $ reason = (string ) ( $ row ['suggested_action ' ] ?? 'insufficient_signal ' );
406+ $ buckets [ $ reason ] ??= array (
407+ 'count ' => 0 ,
408+ 'examples ' => array (),
409+ );
410+ if ( ! isset ($ summary ['by_suggested_action ' ][ $ reason ]) ) {
411+ ++$ buckets [ $ reason ]['count ' ];
412+ }
413+ if ( count ($ buckets [ $ reason ]['examples ' ]) < 3 ) {
414+ $ buckets [ $ reason ]['examples ' ][] = $ this ->active_no_signal_backlog_example ($ row );
415+ }
416+ }
417+ ksort ($ buckets );
418+
419+ $ commands = array (
420+ sprintf ('studio wp datamachine-code workspace worktree active-no-signal-report --limit=%d --offset=0 --format=json ' , $ limit ),
421+ sprintf ('studio wp datamachine-code workspace worktree active-no-signal-report --limit=%d --offset=0 --verbose --format=json ' , $ limit ),
422+ );
423+ if ( ! empty ($ pagination ['next_command ' ]) ) {
424+ $ commands [] = (string ) $ pagination ['next_command ' ];
425+ }
426+
427+ return array (
428+ 'available ' => true ,
429+ 'total_active_no_signal ' => $ total ,
430+ 'sampled ' => $ sampled ,
431+ 'unreviewed_count ' => max (0 , $ total - $ sampled ),
432+ 'by_actionable_reason ' => $ buckets ,
433+ 'counts_scope ' => 'bounded_post_drain_sample_only ' ,
434+ 'limitation ' => 'Counts by actionable reason cover only this bounded post-drain sample; active-no-signal report has pagination but no safe bucket filter, so full per-bucket totals are not scanned by default. ' ,
435+ 'pagination ' => $ pagination ,
436+ 'next_commands ' => array_values (array_unique (array_filter ($ commands ))),
437+ );
438+ }
439+
440+ /** @return array<string,mixed> */
441+ private function active_no_signal_backlog_example ( array $ row ): array {
442+ $ example = array (
443+ 'handle ' => (string ) ( $ row ['handle ' ] ?? '' ),
444+ );
445+ foreach ( array ( 'repo ' , 'branch ' , 'path ' , 'reason ' ) as $ field ) {
446+ if ( isset ($ row [ $ field ]) && '' !== (string ) $ row [ $ field ] ) {
447+ $ example [ $ field ] = (string ) $ row [ $ field ];
448+ }
449+ }
450+ if ( isset ($ row ['dirty ' ]) ) {
451+ $ example ['dirty ' ] = (int ) $ row ['dirty ' ];
452+ }
453+ if ( isset ($ row ['unpushed ' ]) ) {
454+ $ example ['unpushed ' ] = (int ) $ row ['unpushed ' ];
455+ }
456+ return $ example ;
457+ }
458+
343459 private function stage_incomplete ( array $ step ): bool {
344460 $ pagination = (array ) ( $ step ['pagination ' ] ?? $ step ['continuation ' ] ?? array () );
345461 if ( empty ($ pagination ) || ! empty ($ pagination ['complete ' ]) || ! isset ($ pagination ['next_offset ' ]) ) {
0 commit comments