Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 89 additions & 13 deletions inc/Cli/Commands/WorkspaceCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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,
Expand All @@ -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;
}
Expand All @@ -2848,14 +2853,19 @@ 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(
'dry_run' => ! $apply,
'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;
}
Expand Down Expand Up @@ -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<string,mixed>|\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();
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
Expand All @@ -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<string,mixed> $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.
*
Expand All @@ -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<int,array<string,mixed>> $existing Existing blocked rows.
* @param array<int,mixed> $incoming Incoming skipped rows.
* @param array<int|string,array<string,mixed>> $existing Existing blocked rows.
* @param array<int,mixed> $incoming Incoming skipped rows.
* @return array<string,array<string,mixed>>
*/
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 ) {
Expand Down
3 changes: 2 additions & 1 deletion tests/smoke-worktree-cleanup-cli.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading