@@ -2764,6 +2764,14 @@ private function run_worktree_abandoned_orchestration( array $assoc_args ): arra
27642764 $ limit = isset ($ assoc_args ['limit ' ]) ? max (1 , min (100 , (int ) $ assoc_args ['limit ' ])) : 100 ;
27652765 $ passes = isset ($ assoc_args ['passes ' ]) ? max (1 , min (25 , (int ) $ assoc_args ['passes ' ])) : 5 ;
27662766 $ until_budget = isset ($ assoc_args ['until-budget ' ]) && '' !== trim ( (string ) $ assoc_args ['until-budget ' ]) ? trim ( (string ) $ assoc_args ['until-budget ' ]) : '' ;
2767+ $ deadline = null ;
2768+ if ( '' !== $ until_budget ) {
2769+ $ budget_seconds = $ this ->parse_worktree_abandoned_budget ($ until_budget );
2770+ if ( is_wp_error ($ budget_seconds ) ) {
2771+ return $ budget_seconds ;
2772+ }
2773+ $ deadline = microtime (true ) + $ budget_seconds ;
2774+ }
27672775
27682776 $ required = array (
27692777 'reconcile_metadata ' => 'datamachine-code/workspace-worktree-reconcile-metadata ' ,
@@ -2818,9 +2826,6 @@ private function run_worktree_abandoned_orchestration( array $assoc_args ): arra
28182826 'limit ' => $ limit ,
28192827 'source ' => self ::CLEANUP_CLI_SOURCE ,
28202828 );
2821- if ( '' !== $ until_budget ) {
2822- $ common_page ['until_budget ' ] = $ until_budget ;
2823- }
28242829
28252830 $ reconcile_input = array_merge (
28262831 $ common_page ,
@@ -2829,7 +2834,7 @@ private function run_worktree_abandoned_orchestration( array $assoc_args ): arra
28292834 'apply ' => $ apply ,
28302835 )
28312836 );
2832- $ reconcile = $ this ->drain_worktree_abandoned_pages ($ abilities ['reconcile_metadata ' ], $ reconcile_input , $ apply );
2837+ $ reconcile = $ this ->drain_worktree_abandoned_pages ($ abilities ['reconcile_metadata ' ], $ reconcile_input , $ apply, $ deadline );
28332838 if ( is_wp_error ($ reconcile ) ) {
28342839 return $ reconcile ;
28352840 }
@@ -2848,14 +2853,19 @@ private function run_worktree_abandoned_orchestration( array $assoc_args ): arra
28482853 $ result ['executed_passes ' ] = $ pass ;
28492854 $ pass_marked = 0 ;
28502855 foreach ( $ mark_steps as $ key => $ ability ) {
2856+ if ( $ this ->worktree_abandoned_budget_expired ($ deadline ) ) {
2857+ $ result ['evidence ' ]['budget_exhausted ' ] = true ;
2858+ break 2 ;
2859+ }
2860+
28512861 $ step_input = array_merge (
28522862 $ common_page ,
28532863 array (
28542864 'dry_run ' => ! $ apply ,
28552865 'offset ' => 0 ,
28562866 )
28572867 );
2858- $ step = $ this ->drain_worktree_abandoned_pages ($ ability , $ step_input , $ apply );
2868+ $ step = $ this ->drain_worktree_abandoned_pages ($ ability , $ step_input , $ apply, $ deadline );
28592869 if ( is_wp_error ($ step ) ) {
28602870 return $ step ;
28612871 }
@@ -2940,7 +2950,12 @@ private function run_worktree_abandoned_orchestration( array $assoc_args ): arra
29402950 * @param bool $apply Whether the orchestration is applying changes.
29412951 * @return array<string,mixed>|\WP_Error
29422952 */
2943- private function drain_worktree_abandoned_pages ( object $ ability , array $ base_input , bool $ apply ): array |\WP_Error {
2953+ private function drain_worktree_abandoned_pages ( object $ ability , array $ base_input , bool $ apply , ?float $ deadline = null ): array |\WP_Error {
2954+ $ execute = array ( $ ability , 'execute ' );
2955+ if ( ! is_callable ($ execute ) ) {
2956+ return new \WP_Error ('worktree_abandoned_ability_invalid ' , 'Worktree abandoned cleanup ability is not executable. ' , array ( 'status ' => 500 ));
2957+ }
2958+
29442959 $ pages = array ();
29452960 $ summary = array ();
29462961 $ pagination = array ();
@@ -2949,12 +2964,17 @@ private function drain_worktree_abandoned_pages( object $ability, array $base_in
29492964 $ last_result = array ();
29502965
29512966 for ( $ page = 1 ; $ page <= $ max_pages ; ++$ page ) {
2967+ if ( null !== $ deadline && $ this ->worktree_abandoned_budget_expired ($ deadline ) ) {
2968+ break ;
2969+ }
2970+
29522971 $ input = $ base_input ;
29532972 if ( isset ($ base_input ['offset ' ]) || $ page > 1 ) {
29542973 $ input ['offset ' ] = $ offset ;
29552974 }
2975+ $ this ->apply_worktree_abandoned_remaining_budget ($ input , $ deadline );
29562976
2957- $ result = $ ability -> execute ($ input );
2977+ $ result = $ execute ($ input );
29582978 if ( is_wp_error ($ result ) ) {
29592979 return $ result ;
29602980 }
@@ -2986,6 +3006,16 @@ private function drain_worktree_abandoned_pages( object $ability, array $base_in
29863006 $ offset = $ next_offset ;
29873007 }
29883008
3009+ if ( array () === $ last_result ) {
3010+ $ last_result = array (
3011+ 'success ' => true ,
3012+ 'mode ' => 'abandoned_budget_exhausted ' ,
3013+ 'dry_run ' => ! empty ($ base_input ['dry_run ' ]),
3014+ 'applied ' => $ apply ,
3015+ 'budget_exhausted ' => true ,
3016+ );
3017+ }
3018+
29893019 $ last_result ['summary ' ] = $ summary ;
29903020 $ last_result ['pagination ' ] = $ pagination ;
29913021 $ last_result ['pages ' ] = $ pages ;
@@ -2994,6 +3024,54 @@ private function drain_worktree_abandoned_pages( object $ability, array $base_in
29943024 return $ last_result ;
29953025 }
29963026
3027+ /**
3028+ * Parse abandoned-cleanup wall-clock budget.
3029+ *
3030+ * @param string $duration Duration like 60s, 10m, or 1h.
3031+ * @return int|\WP_Error
3032+ */
3033+ private function parse_worktree_abandoned_budget ( string $ duration ): int |\WP_Error {
3034+ if ( ! preg_match ('/^(\d+)([smh])$/ ' , trim ($ duration ), $ matches ) ) {
3035+ return new \WP_Error ('invalid_worktree_abandoned_budget ' , 'Invalid --until-budget duration. Use a compact value like 60s, 10m, or 1h. ' , array ( 'status ' => 400 ));
3036+ }
3037+
3038+ $ value = (int ) $ matches [1 ];
3039+ if ( $ value < 1 ) {
3040+ return new \WP_Error ('invalid_worktree_abandoned_budget ' , 'Invalid --until-budget duration. Duration must be greater than zero. ' , array ( 'status ' => 400 ));
3041+ }
3042+
3043+ return match ( $ matches [2 ] ) {
3044+ 'h ' => $ value * HOUR_IN_SECONDS ,
3045+ 'm ' => $ value * MINUTE_IN_SECONDS ,
3046+ default => $ value ,
3047+ };
3048+ }
3049+
3050+ /**
3051+ * Forward only the remaining abandoned-cleanup budget to one ability call.
3052+ *
3053+ * @param array<string,mixed> $input Ability input.
3054+ * @param float|null $deadline Shared wall-clock deadline.
3055+ */
3056+ private function apply_worktree_abandoned_remaining_budget ( array &$ input , ?float $ deadline ): void {
3057+ if ( null === $ deadline ) {
3058+ return ;
3059+ }
3060+
3061+ $ remaining = max (1 , (int ) floor ($ deadline - microtime (true )));
3062+ $ input ['until_budget ' ] = $ remaining . 's ' ;
3063+ }
3064+
3065+ /**
3066+ * Check whether the abandoned-cleanup shared deadline has expired.
3067+ *
3068+ * @param float|null $deadline Shared wall-clock deadline.
3069+ * @return bool
3070+ */
3071+ private function worktree_abandoned_budget_expired ( ?float $ deadline ): bool {
3072+ return null !== $ deadline && microtime (true ) >= $ deadline ;
3073+ }
3074+
29973075 /**
29983076 * Build a compact step summary for abandoned cleanup output.
29993077 *
@@ -3020,17 +3098,15 @@ private function summarize_worktree_abandoned_step( array $step ): array {
30203098 /**
30213099 * Merge blocked rows by handle so repeated passes do not duplicate output.
30223100 *
3023- * @param array<int,array<string,mixed>> $existing Existing blocked rows.
3024- * @param array<int,mixed> $incoming Incoming skipped rows.
3101+ * @param array<int|string ,array<string,mixed>> $existing Existing blocked rows.
3102+ * @param array<int,mixed> $incoming Incoming skipped rows.
30253103 * @return array<string,array<string,mixed>>
30263104 */
30273105 private function merge_worktree_abandoned_blockers ( array $ existing , array $ incoming ): array {
30283106 $ merged = array ();
30293107 foreach ( $ existing as $ row ) {
3030- if ( is_array ($ row ) ) {
3031- $ handle = (string ) ( $ row ['handle ' ] ?? count ($ merged ) );
3032- $ merged [ $ handle ] = $ row ;
3033- }
3108+ $ handle = (string ) ( $ row ['handle ' ] ?? count ($ merged ) );
3109+ $ merged [ $ handle ] = $ row ;
30343110 }
30353111
30363112 foreach ( $ incoming as $ row ) {
0 commit comments