diff --git a/inc/Cli/Commands/WorkspaceCommand.php b/inc/Cli/Commands/WorkspaceCommand.php index 8eeeb39..5c38bb2 100644 --- a/inc/Cli/Commands/WorkspaceCommand.php +++ b/inc/Cli/Commands/WorkspaceCommand.php @@ -2764,6 +2764,14 @@ private function run_worktree_abandoned_orchestration( array $assoc_args ): arra $limit = isset($assoc_args['limit']) ? max(1, min(100, (int) $assoc_args['limit'])) : 100; $passes = isset($assoc_args['passes']) ? max(1, min(25, (int) $assoc_args['passes'])) : 5; $until_budget = isset($assoc_args['until-budget']) && '' !== trim( (string) $assoc_args['until-budget']) ? trim( (string) $assoc_args['until-budget']) : ''; + $deadline = null; + if ( '' !== $until_budget ) { + $budget_seconds = $this->parse_worktree_abandoned_budget($until_budget); + if ( is_wp_error($budget_seconds) ) { + return $budget_seconds; + } + $deadline = microtime(true) + $budget_seconds; + } $required = array( 'reconcile_metadata' => 'datamachine-code/workspace-worktree-reconcile-metadata', @@ -2818,9 +2826,6 @@ private function run_worktree_abandoned_orchestration( array $assoc_args ): arra 'limit' => $limit, 'source' => self::CLEANUP_CLI_SOURCE, ); - if ( '' !== $until_budget ) { - $common_page['until_budget'] = $until_budget; - } $reconcile_input = array_merge( $common_page, @@ -2829,7 +2834,7 @@ private function run_worktree_abandoned_orchestration( array $assoc_args ): arra 'apply' => $apply, ) ); - $reconcile = $this->drain_worktree_abandoned_pages($abilities['reconcile_metadata'], $reconcile_input, $apply); + $reconcile = $this->drain_worktree_abandoned_pages($abilities['reconcile_metadata'], $reconcile_input, $apply, $deadline); if ( is_wp_error($reconcile) ) { return $reconcile; } @@ -2848,6 +2853,11 @@ private function run_worktree_abandoned_orchestration( array $assoc_args ): arra $result['executed_passes'] = $pass; $pass_marked = 0; foreach ( $mark_steps as $key => $ability ) { + if ( $this->worktree_abandoned_budget_expired($deadline) ) { + $result['evidence']['budget_exhausted'] = true; + break 2; + } + $step_input = array_merge( $common_page, array( @@ -2855,7 +2865,7 @@ private function run_worktree_abandoned_orchestration( array $assoc_args ): arra 'offset' => 0, ) ); - $step = $this->drain_worktree_abandoned_pages($ability, $step_input, $apply); + $step = $this->drain_worktree_abandoned_pages($ability, $step_input, $apply, $deadline); if ( is_wp_error($step) ) { return $step; } @@ -2940,7 +2950,12 @@ private function run_worktree_abandoned_orchestration( array $assoc_args ): arra * @param bool $apply Whether the orchestration is applying changes. * @return array|\WP_Error */ - private function drain_worktree_abandoned_pages( object $ability, array $base_input, bool $apply ): array|\WP_Error { + private function drain_worktree_abandoned_pages( object $ability, array $base_input, bool $apply, ?float $deadline = null ): array|\WP_Error { + $execute = array( $ability, 'execute' ); + if ( ! is_callable($execute) ) { + return new \WP_Error('worktree_abandoned_ability_invalid', 'Worktree abandoned cleanup ability is not executable.', array( 'status' => 500 )); + } + $pages = array(); $summary = array(); $pagination = array(); @@ -2949,12 +2964,17 @@ private function drain_worktree_abandoned_pages( object $ability, array $base_in $last_result = array(); for ( $page = 1; $page <= $max_pages; ++$page ) { + if ( null !== $deadline && $this->worktree_abandoned_budget_expired($deadline) ) { + break; + } + $input = $base_input; if ( isset($base_input['offset']) || $page > 1 ) { $input['offset'] = $offset; } + $this->apply_worktree_abandoned_remaining_budget($input, $deadline); - $result = $ability->execute($input); + $result = $execute($input); if ( is_wp_error($result) ) { return $result; } @@ -2986,6 +3006,16 @@ private function drain_worktree_abandoned_pages( object $ability, array $base_in $offset = $next_offset; } + if ( array() === $last_result ) { + $last_result = array( + 'success' => true, + 'mode' => 'abandoned_budget_exhausted', + 'dry_run' => ! empty($base_input['dry_run']), + 'applied' => $apply, + 'budget_exhausted' => true, + ); + } + $last_result['summary'] = $summary; $last_result['pagination'] = $pagination; $last_result['pages'] = $pages; @@ -2994,6 +3024,54 @@ private function drain_worktree_abandoned_pages( object $ability, array $base_in return $last_result; } + /** + * Parse abandoned-cleanup wall-clock budget. + * + * @param string $duration Duration like 60s, 10m, or 1h. + * @return int|\WP_Error + */ + private function parse_worktree_abandoned_budget( string $duration ): int|\WP_Error { + if ( ! preg_match('/^(\d+)([smh])$/', trim($duration), $matches) ) { + return new \WP_Error('invalid_worktree_abandoned_budget', 'Invalid --until-budget duration. Use a compact value like 60s, 10m, or 1h.', array( 'status' => 400 )); + } + + $value = (int) $matches[1]; + if ( $value < 1 ) { + return new \WP_Error('invalid_worktree_abandoned_budget', 'Invalid --until-budget duration. Duration must be greater than zero.', array( 'status' => 400 )); + } + + return match ( $matches[2] ) { + 'h' => $value * HOUR_IN_SECONDS, + 'm' => $value * MINUTE_IN_SECONDS, + default => $value, + }; + } + + /** + * Forward only the remaining abandoned-cleanup budget to one ability call. + * + * @param array $input Ability input. + * @param float|null $deadline Shared wall-clock deadline. + */ + private function apply_worktree_abandoned_remaining_budget( array &$input, ?float $deadline ): void { + if ( null === $deadline ) { + return; + } + + $remaining = max(1, (int) floor($deadline - microtime(true))); + $input['until_budget'] = $remaining . 's'; + } + + /** + * Check whether the abandoned-cleanup shared deadline has expired. + * + * @param float|null $deadline Shared wall-clock deadline. + * @return bool + */ + private function worktree_abandoned_budget_expired( ?float $deadline ): bool { + return null !== $deadline && microtime(true) >= $deadline; + } + /** * Build a compact step summary for abandoned cleanup output. * @@ -3020,17 +3098,15 @@ private function summarize_worktree_abandoned_step( array $step ): array { /** * Merge blocked rows by handle so repeated passes do not duplicate output. * - * @param array> $existing Existing blocked rows. - * @param array $incoming Incoming skipped rows. + * @param array> $existing Existing blocked rows. + * @param array $incoming Incoming skipped rows. * @return array> */ private function merge_worktree_abandoned_blockers( array $existing, array $incoming ): array { $merged = array(); foreach ( $existing as $row ) { - if ( is_array($row) ) { - $handle = (string) ( $row['handle'] ?? count($merged) ); - $merged[ $handle ] = $row; - } + $handle = (string) ( $row['handle'] ?? count($merged) ); + $merged[ $handle ] = $row; } foreach ( $incoming as $row ) { diff --git a/tests/smoke-worktree-cleanup-cli.php b/tests/smoke-worktree-cleanup-cli.php index 6bcbdad..27e783a 100644 --- a/tests/smoke-worktree-cleanup-cli.php +++ b/tests/smoke-worktree-cleanup-cli.php @@ -1078,7 +1078,8 @@ public function execute( array $input ): array datamachine_code_cleanup_assert(1 === (int) ( $reconcile_metadata_ability->last_input['limit'] ?? 0 ), 'abandoned forwards limit to metadata reconciliation'); datamachine_code_cleanup_assert(false === ( $reconcile_metadata_ability->last_input['dry_run'] ?? null ), 'abandoned --apply applies metadata reconciliation'); datamachine_code_cleanup_assert(array( 0, 1 ) === array_map(fn( $input ) => (int) ( $input['offset'] ?? 0 ), array_slice($reconcile_metadata_ability->inputs, -2)), 'abandoned drains metadata reconciliation pages in apply mode'); - datamachine_code_cleanup_assert('30s' === ( $active_finalized_ability->last_input['until_budget'] ?? '' ), 'abandoned forwards time budget to active/no-signal marking'); + $abandoned_forwarded_budget = (string) ( $active_finalized_ability->last_input['until_budget'] ?? '' ); + datamachine_code_cleanup_assert(1 === preg_match('/^\d+s$/', $abandoned_forwarded_budget) && (int) $abandoned_forwarded_budget <= 30, 'abandoned forwards remaining time budget to active/no-signal marking'); datamachine_code_cleanup_assert(array( 0, 1 ) === array_map(fn( $input ) => (int) ( $input['offset'] ?? 0 ), array_slice($active_finalized_ability->inputs, -2)), 'abandoned drains active/no-signal classifier pages in apply mode'); datamachine_code_cleanup_assert(true === ( $bounded_apply_ability->last_input['force'] ?? null ), 'abandoned forwards force only to bounded cleanup removal'); datamachine_code_cleanup_assert(false === ( $bounded_apply_ability->last_input['dry_run'] ?? null ), 'abandoned --apply removes eligible rows');