diff --git a/inc/Workspace/CleanupRunService.php b/inc/Workspace/CleanupRunService.php index 1ac145e1..0f3254fa 100644 --- a/inc/Workspace/CleanupRunService.php +++ b/inc/Workspace/CleanupRunService.php @@ -419,6 +419,7 @@ public function until_empty( array $opts = array() ): array|\WP_Error { 'include_resolvers' => false, 'force_artifact_cleanup' => ! empty($opts['force']), 'allow_active_artifact_cleanup' => ! empty($opts['allow_active_artifact_cleanup']), + 'limit' => $limit, 'worktree_older_than' => isset($opts['older_than']) ? trim( (string) $opts['older_than']) : '', ) ); diff --git a/inc/Workspace/WorkspaceWorktreeLifecycle.php b/inc/Workspace/WorkspaceWorktreeLifecycle.php index 256bf532..dfbc3559 100644 --- a/inc/Workspace/WorkspaceWorktreeLifecycle.php +++ b/inc/Workspace/WorkspaceWorktreeLifecycle.php @@ -239,13 +239,9 @@ private function worktree_add_with_capacity_lock( if ( $demand_plan instanceof \WP_Error ) { return $demand_plan; } - $disk_budget = WorktreeDiskBudget::inspect( - $this->workspace_path, - WorktreeDiskBudget::thresholds($repo, $branch), - $force, - array( 'include_workspace_usage' => true ), - $demand_plan - ); + $disk_budget = $this->inspect_worktree_capacity($repo, $branch, $force, $demand_plan); + $capacity_reclaim = $this->reclaim_capacity_eligible_artifacts($repo, $branch, $force, $demand_plan, $disk_budget); + $disk_budget = $capacity_reclaim['after']; if ( 'refused' === ( $disk_budget['status'] ?? '' ) ) { $recommendations = array_map( static function ( $row ): string { @@ -289,8 +285,9 @@ static function ( $row ): string { implode("\n", array_filter($recommendations)) ), array( - 'status' => 507, - 'disk_budget' => $disk_budget, + 'status' => 507, + 'disk_budget' => $disk_budget, + 'capacity_reclaim' => $capacity_reclaim['evidence'], ) ); } @@ -337,7 +334,8 @@ static function ( $row ): string { ); } - $response['disk_budget'] = $disk_budget; + $response['disk_budget'] = $disk_budget; + $response['capacity_reclaim'] = $capacity_reclaim['evidence']; if ( ! empty($response['rebase_succeeded']) ) { $post_rebase_demand = WorktreeBootstrapper::demand_plan_for_target($wt_path, 'HEAD', $bootstrap); if ( $post_rebase_demand instanceof \WP_Error ) { @@ -345,14 +343,17 @@ static function ( $row ): string { return $post_rebase_demand; } $post_rebase_demand = WorktreeBootstrapper::remaining_demand_after_materialization($post_rebase_demand); - $post_rebase_budget = WorktreeDiskBudget::inspect( - $this->workspace_path, - WorktreeDiskBudget::thresholds($repo, $branch), + $post_rebase_budget = $this->inspect_worktree_capacity($repo, $branch, $force, $post_rebase_demand); + $post_rebase_capacity_reclaim = $this->reclaim_capacity_eligible_artifacts( + $repo, + $branch, $force, - array( 'include_workspace_usage' => true ), - $post_rebase_demand + $post_rebase_demand, + $post_rebase_budget ); + $post_rebase_budget = $post_rebase_capacity_reclaim['after']; $response['post_rebase_disk_budget'] = $post_rebase_budget; + $response['post_rebase_capacity_reclaim'] = $post_rebase_capacity_reclaim['evidence']; $response['disk_budget'] = $post_rebase_budget; if ( 'refused' === ( $post_rebase_budget['status'] ?? '' ) ) { $this->rollback_rejected_worktree($primary_path, $wt_path, $branch, ! empty($response['created_branch'])); @@ -361,9 +362,10 @@ static function ( $row ): string { 'worktree_disk_budget_exceeded', 'Refusing dependency bootstrap because the effective post-rebase target exceeds the workspace capacity budget.', array( - 'status' => 507, - 'disk_budget' => $post_rebase_budget, - 'phase' => 'post_rebase_admission', + 'status' => 507, + 'disk_budget' => $post_rebase_budget, + 'capacity_reclaim' => $post_rebase_capacity_reclaim['evidence'], + 'phase' => 'post_rebase_admission', ) ); } @@ -2113,6 +2115,89 @@ private function repair_cleanup_eligible_stale_worktree_marker( array $row, arra ); } + /** + * Inspect capacity through a testable admission seam. + * + * @param array $demand_plan + * @return array + */ + protected function inspect_worktree_capacity( string $repo, string $branch, bool $force, array $demand_plan ): array { + return WorktreeDiskBudget::inspect( + $this->workspace_path, + WorktreeDiskBudget::thresholds($repo, $branch), + $force, + array( 'include_workspace_usage' => true ), + $demand_plan + ); + } + + /** + * Reclaim only already-eligible reconstructable artifacts before refusing + * admission. The caller holds the global capacity lock, so the follow-up + * measurement and admission decision cannot race another worktree creation. + * + * @param array $demand_plan + * @param array $before + * @return array{after:array,evidence:array} + */ + protected function reclaim_capacity_eligible_artifacts( string $repo, string $branch, bool $force, array $demand_plan, array $before ): array { + $evidence = array( + 'attempted' => false, + 'reclaimed_bytes' => 0, + 'skipped' => array(), + 'final_decision' => 'admitted_without_reclaim', + ); + if ( $force || 'refused' !== ( $before['status'] ?? '' ) ) { + $evidence['skip_reason'] = $force ? 'force_override' : 'capacity_not_refused'; + return array( 'after' => $before, 'evidence' => $evidence ); + } + + $evidence['attempted'] = true; + if ( ! class_exists(CleanupRunService::class) ) { + require_once __DIR__ . '/CleanupRunService.php'; + } + $reclaim = $this->run_capacity_artifact_reclaim(); + $after = $this->inspect_worktree_capacity($repo, $branch, false, $demand_plan); + if ( $reclaim instanceof \WP_Error ) { + $evidence['error_code'] = $reclaim->get_error_code(); + $evidence['final_decision'] = 'refused_after_reclaim_error'; + return array( 'after' => $after, 'evidence' => $evidence ); + } + + $evidence['state'] = (string) ( $reclaim['state'] ?? 'unknown' ); + $evidence['applied'] = (int) ( $reclaim['applied'] ?? 0 ); + $evidence['reclaimed_bytes'] = (int) ( $reclaim['bytes_reclaimed'] ?? 0 ); + $evidence['skipped'] = $this->capacity_reclaim_skipped_categories($reclaim); + $evidence['final_decision'] = 'refused' === ( $after['status'] ?? '' ) ? 'refused_after_reclaim' : 'admitted_after_reclaim'; + + return array( 'after' => $after, 'evidence' => $evidence ); + } + + /** @return array */ + protected function capacity_reclaim_skipped_categories( array $reclaim ): array { + $categories = array(); + foreach ( (array) ( $reclaim['remaining_blocked_reasons'] ?? array() ) as $reason => $bucket ) { + $count = is_array($bucket) ? (int) ( $bucket['count'] ?? 0 ) : (int) $bucket; + if ( $count > 0 ) { + $categories[ (string) $reason ] = $count; + } + } + return $categories; + } + + /** @return array|\WP_Error */ + protected function run_capacity_artifact_reclaim(): array|\WP_Error { + return ( new CleanupRunService() )->until_empty( + array( + 'mode' => 'artifacts', + 'force' => false, + 'limit' => 25, + 'max_passes' => 3, + 'budget_seconds' => 30, + ) + ); + } + /** * Attach host-shell remediation commands to local-git-unavailable worktree errors. * diff --git a/tests/cleanup-until-empty-blocked-only.php b/tests/cleanup-until-empty-blocked-only.php index cd032b5b..9059db7d 100644 --- a/tests/cleanup-until-empty-blocked-only.php +++ b/tests/cleanup-until-empty-blocked-only.php @@ -10,6 +10,9 @@ require_once dirname(__DIR__) . '/inc/Workspace/CleanupRunService.php'; final class FakeUntilEmptyCleanupRunService extends CleanupRunService { + /** @var array> */ + public array $plan_options = array(); + /** @var array> */ private array $plans; @@ -26,6 +29,7 @@ public function __construct( array $plans, array $applies ) { } public function plan( array $opts = array() ): array|\WP_Error { + $this->plan_options[] = $opts; return array_shift($this->plans); } @@ -76,12 +80,13 @@ function cleanup_until_empty_plan(): array { ), ) ); - $result = $service->until_empty(array( 'mode' => 'artifacts' )); + $result = $service->until_empty(array( 'mode' => 'artifacts', 'limit' => 7 )); cleanup_until_empty_assert_same(true, $result['success'] ?? null, 'Blocked-only artifact cleanup should be a successful terminal result.'); cleanup_until_empty_assert_same('complete_with_blockers', $result['state'] ?? null, 'Blocked-only artifact cleanup should report a distinct terminal state.'); cleanup_until_empty_assert_same(2, $result['remaining_blocked_count'] ?? null, 'Blocked-only result should include the blocker count.'); cleanup_until_empty_assert_same($blocked_summary, $result['remaining_blocked_reasons'] ?? null, 'Blocked-only result should include blocker reasons.'); + cleanup_until_empty_assert_same(7, $service->plan_options[0]['limit'] ?? null, 'Bounded cleanup must apply its requested limit while building the DB-backed plan.'); $service = new FakeUntilEmptyCleanupRunService( array( cleanup_until_empty_plan() ), diff --git a/tests/worktree-add-lifecycle.php b/tests/worktree-add-lifecycle.php index f8779963..2f27f7e4 100644 --- a/tests/worktree-add-lifecycle.php +++ b/tests/worktree-add-lifecycle.php @@ -60,6 +60,10 @@ function wp_json_encode( mixed $value, int $flags = 0, int $depth = 512 ): strin return json_encode($value, $flags, $depth); } +function wp_generate_password( int $length = 12, bool $special_chars = true, bool $extra_special_chars = false ): string { + return str_repeat('a', $length); +} + $GLOBALS['datamachine_code_test_options'] = array(); function get_option( string $name, mixed $default = false ): mixed { return $GLOBALS['datamachine_code_test_options'][ $name ] ?? $default; @@ -325,8 +329,11 @@ function create_primary_checkout( string $workspace_root ): void { assert_true('worktree_disk_budget_exceeded' === $refused->get_error_code(), 'unexpected disk pressure refusal error code'); $refusal_data = (array) $refused->get_error_data(); $disk_budget = (array) ( $refusal_data['disk_budget'] ?? array() ); + $reclaim = (array) ( $refusal_data['capacity_reclaim'] ?? array() ); assert_true('refused' === ( $disk_budget['status'] ?? '' ), 'disk pressure refusal did not include refused budget status'); assert_true(isset($disk_budget['free_bytes'], $disk_budget['effective_refuse_bytes']), 'disk pressure refusal must include exact free and required bytes'); + assert_true(true === ( $reclaim['attempted'] ?? false ), 'capacity refusal did not attempt bounded safe artifact reclaim'); + assert_true('refused_after_reclaim' === ( $reclaim['final_decision'] ?? '' ), 'capacity refusal did not report the final reclaim decision'); assert_true(str_contains($refused->get_error_message(), 'studio wp datamachine-code workspace worktree bounded-cleanup-eligible-apply --dry-run --limit=25'), 'disk pressure refusal must include the next cleanup command'); assert_true(! is_dir($workspace_root . '/homeboy@audit-primitives-disk-refused'), 'disk pressure refusal left a worktree directory behind'); diff --git a/tests/worktree-capacity-auto-reclaim.php b/tests/worktree-capacity-auto-reclaim.php new file mode 100644 index 00000000..66358448 --- /dev/null +++ b/tests/worktree-capacity-auto-reclaim.php @@ -0,0 +1,70 @@ +code; + } + } +} + +require_once dirname(__DIR__) . '/inc/Workspace/WorkspaceWorktreeLifecycle.php'; + +use DataMachineCode\Workspace\WorkspaceWorktreeLifecycle; + +function capacity_auto_reclaim_assert_same( mixed $expected, mixed $actual, string $message ): void { + if ( $expected !== $actual ) { + throw new RuntimeException(sprintf('%s Expected %s, got %s.', $message, var_export($expected, true), var_export($actual, true))); + } +} + +final class CapacityAutoReclaimHarness { + use WorkspaceWorktreeLifecycle; + + /** @var array> */ + private array $budgets; + + public function __construct( array $budgets ) { + $this->budgets = $budgets; + } + + public function reclaim( array $before ): array { + return $this->reclaim_capacity_eligible_artifacts('repo', 'branch', false, array( 'bytes' => 10 ), $before); + } + + protected function inspect_worktree_capacity( string $repo, string $branch, bool $force, array $demand_plan ): array { + return array_shift($this->budgets) ?? array( 'status' => 'refused' ); + } + + protected function run_capacity_artifact_reclaim(): array|\WP_Error { + return array( + 'state' => 'completed_with_skips', + 'applied' => 2, + 'bytes_reclaimed' => 60, + 'remaining_blocked_reasons' => array( + 'dirty_worktree' => array( 'count' => 1 ), + 'unpushed_commits' => array( 'count' => 2 ), + ), + ); + } +} + +$before = array( 'status' => 'refused', 'projected_free_bytes' => 90, 'effective_refuse_bytes' => 100 ); +$harness = new CapacityAutoReclaimHarness(array( array( 'status' => 'ok', 'projected_free_bytes' => 150, 'effective_refuse_bytes' => 100 ) )); +$result = $harness->reclaim($before); + +capacity_auto_reclaim_assert_same('ok', $result['after']['status'] ?? null, 'Reclaim crossing the capacity floor must use the recomputed capacity.'); +capacity_auto_reclaim_assert_same(true, $result['evidence']['attempted'] ?? null, 'Refused admission must attempt artifact-only reclaim.'); +capacity_auto_reclaim_assert_same(60, $result['evidence']['reclaimed_bytes'] ?? null, 'Reclaim evidence must retain measured bytes.'); +capacity_auto_reclaim_assert_same(array( 'dirty_worktree' => 1, 'unpushed_commits' => 2 ), $result['evidence']['skipped'] ?? null, 'Reclaim evidence must retain protected skip categories.'); +capacity_auto_reclaim_assert_same('admitted_after_reclaim', $result['evidence']['final_decision'] ?? null, 'Crossing the floor must admit after safe reclaim.'); + +echo "worktree-capacity-auto-reclaim: ok\n";