Skip to content

Commit e97f269

Browse files
Bound cleanup planning scans (#744)
* fix: bound cleanup planning scans * fix: align cleanup plan formatting --------- Co-authored-by: homeboy-ci[bot] <266378653+homeboy-ci[bot]@users.noreply.github.com>
1 parent 9bf7395 commit e97f269

8 files changed

Lines changed: 253 additions & 78 deletions

inc/Abilities/WorkspaceAbilities.php

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2361,6 +2361,10 @@ private function registerAbilities(): void {
23612361
'include_worktrees' => array( 'type' => 'boolean' ),
23622362
'include_resolvers' => array( 'type' => 'boolean' ),
23632363
'force_artifact_cleanup' => array( 'type' => 'boolean' ),
2364+
'limit' => array( 'type' => 'integer' ),
2365+
'offset' => array( 'type' => 'integer' ),
2366+
'until_budget' => array( 'type' => 'string' ),
2367+
'full_workspace' => array( 'type' => 'boolean' ),
23642368
'worktree_older_than' => array( 'type' => 'string' ),
23652369
'worktree_sort' => array( 'type' => 'string' ),
23662370
'worktree_stale_only' => array( 'type' => 'boolean' ),
@@ -4187,11 +4191,19 @@ public static function workspaceCleanupPlan( array $input ): array|\WP_Error {
41874191
'mode' => (string) ( $input['mode'] ?? 'cleanup_plan' ),
41884192
'worktree_stale_only' => ! empty($input['worktree_stale_only']),
41894193
);
4190-
foreach ( array( 'include_artifacts', 'include_worktrees' ) as $key ) {
4194+
foreach ( array( 'include_artifacts', 'include_worktrees', 'full_workspace' ) as $key ) {
41914195
if ( array_key_exists($key, $input) ) {
41924196
$opts[ $key ] = (bool) $input[ $key ];
41934197
}
41944198
}
4199+
foreach ( array( 'limit', 'offset' ) as $key ) {
4200+
if ( isset($input[ $key ]) ) {
4201+
$opts[ $key ] = (int) $input[ $key ];
4202+
}
4203+
}
4204+
if ( isset($input['until_budget']) && '' !== trim( (string) $input['until_budget']) ) {
4205+
$opts['until_budget'] = trim( (string) $input['until_budget']);
4206+
}
41954207
if ( isset($input['worktree_older_than']) && '' !== trim( (string) $input['worktree_older_than']) ) {
41964208
$opts['worktree_older_than'] = trim( (string) $input['worktree_older_than']);
41974209
}

inc/Cli/Commands/WorkspaceCommand.php

Lines changed: 29 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -593,12 +593,12 @@ public function adopt_repo( array $args, array $assoc_args ): void {
593593
* [--force]
594594
* : Pass force=true into the cleanup task params for modes that support it.
595595
*
596-
* [--include-artifacts]
597-
* : For `plan --mode=retention`, include artifact cleanup rows. Retention
598-
* planning includes a full-workspace artifact inventory by default; this flag
599-
* remains accepted for explicitness and `--mode=artifacts` still creates an
600-
* artifact-only plan. `--mode=stale-worktrees` never includes artifacts unless
601-
* this flag is passed.
596+
* [--include-artifacts]
597+
* : For `plan --mode=retention`, include artifact cleanup rows. Retention
598+
* planning includes a bounded artifact inventory page by default; this flag
599+
* remains accepted for explicitness and `--mode=artifacts` still creates an
600+
* artifact-only plan. `--mode=stale-worktrees` never includes artifacts unless
601+
* this flag is passed.
602602
*
603603
* [--older-than=<duration>]
604604
* : Pass an age gate such as 7d or 24h into cleanup task params.
@@ -607,23 +607,22 @@ public function adopt_repo( array $args, array $assoc_args ): void {
607607
* : For `plan`, number of largest reclaimable paths to show in the upfront
608608
* summary. Defaults to 10.
609609
*
610-
* [--limit=<count>]
611-
* : For DB-backed `apply` / `resume`, maximum pending rows to process in this
612-
* invocation (default 25, max 100). For `--mode=artifacts` pages, maximum
613-
* worktrees to scan; dry-run reviews scan this bounded page synchronously,
614-
* and apply runs freeze eligible candidates from the same bounded page.
615-
* Artifact page scans default to 100. Use 0 to disable the artifact scan cap
616-
* (combine with --exhaustive for a full audit).
610+
* [--limit=<count>]
611+
* : For DB-backed `apply` / `resume`, maximum pending rows to process in this
612+
* invocation (default 25, max 100). For `plan`, maximum worktrees to scan in
613+
* each cleanup lane page. Plan pages default to 100 so huge workspaces return
614+
* actionable JSON quickly. Use --exhaustive for a full audit.
617615
*
618-
* [--offset=<count>]
619-
* : Pagination offset (0-indexed) for `--mode=artifacts` dry-run and apply
620-
* pages. Walk huge workspaces by feeding the previous response's
621-
* `pagination.next_offset` until `pagination.complete` is true.
616+
* [--offset=<count>]
617+
* : Pagination offset (0-indexed) for bounded plan pages and artifact dry-run
618+
* pages. Walk huge workspaces by feeding the previous response's
619+
* `continuation.next_offset` until `continuation.complete` is true.
622620
*
623-
* [--exhaustive]
624-
* : For `--mode=artifacts --dry-run`, scan every worktree AND run per-worktree
625-
* git status / unpushed-commit safety probes. Slow on huge workspaces; use
626-
* sparingly for full audits.
621+
* [--exhaustive]
622+
* : For `plan`, request a full unbounded audit instead of the default bounded
623+
* inventory-first page. For `--mode=artifacts --dry-run`, scan every worktree
624+
* AND run per-worktree git status / unpushed-commit safety probes. Slow on
625+
* huge workspaces; use sparingly for full audits.
627626
*
628627
* [--safety-probes]
629628
* : For `--mode=artifacts --dry-run`, run the per-worktree git safety probes
@@ -1066,6 +1065,15 @@ private function cleanup_plan_input( string $mode, array $assoc_args ): array {
10661065
$input['artifact_sort'] = $sort;
10671066
$input['worktree_sort'] = $sort;
10681067
}
1068+
if ( isset($assoc_args['limit']) ) {
1069+
$input['limit'] = (int) $assoc_args['limit'];
1070+
}
1071+
if ( isset($assoc_args['offset']) ) {
1072+
$input['offset'] = (int) $assoc_args['offset'];
1073+
}
1074+
if ( ! empty($assoc_args['exhaustive']) ) {
1075+
$input['full_workspace'] = true;
1076+
}
10691077
if ( 'stale-worktrees' === $mode ) {
10701078
$input['worktree_stale_only'] = true;
10711079
if ( empty($input['worktree_older_than']) ) {

inc/Workspace/Workspace.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,16 @@ class Workspace {
8080
*/
8181
public const ARTIFACT_CLEANUP_DEFAULT_LIMIT = 100;
8282

83+
/**
84+
* Default cleanup plan page size for high-level retention planning.
85+
*/
86+
public const CLEANUP_PLAN_DEFAULT_LIMIT = 100;
87+
88+
/**
89+
* Default wall-clock budget for high-level worktree retention review pages.
90+
*/
91+
public const CLEANUP_PLAN_DEFAULT_BUDGET = '30s';
92+
8393
/**
8494
* Default cap on top-level workspace entries sized by hygiene reports.
8595
*/

inc/Workspace/WorkspaceCleanupPlan.php

Lines changed: 93 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,13 @@ public function workspace_cleanup_plan( array $opts = array() ): array|\WP_Error
3434
'include_worktrees' => array_key_exists('include_worktrees', $opts) ? (bool) $opts['include_worktrees'] : true,
3535
'include_resolvers' => ! empty($opts['include_resolvers']),
3636
'top_n' => isset($opts['top_n']) ? max(1, min(50, (int) $opts['top_n'])) : 10,
37+
'limit' => isset($opts['limit']) ? max(1, (int) $opts['limit']) : self::CLEANUP_PLAN_DEFAULT_LIMIT,
38+
'offset' => isset($opts['offset']) ? max(0, (int) $opts['offset']) : 0,
39+
'until_budget' => isset($opts['until_budget']) && '' !== trim( (string) $opts['until_budget']) ? trim( (string) $opts['until_budget']) : self::CLEANUP_PLAN_DEFAULT_BUDGET,
40+
'full_workspace' => ! empty($opts['full_workspace']),
3741
'worktree_older_than' => isset($opts['worktree_older_than']) ? trim( (string) $opts['worktree_older_than']) : '',
38-
'worktree_sort' => isset($opts['worktree_sort']) && '' !== trim( (string) $opts['worktree_sort']) ? trim( (string) $opts['worktree_sort']) : 'size',
39-
'artifact_sort' => isset($opts['artifact_sort']) && '' !== trim( (string) $opts['artifact_sort']) ? trim( (string) $opts['artifact_sort']) : 'size',
42+
'worktree_sort' => isset($opts['worktree_sort']) && '' !== trim( (string) $opts['worktree_sort']) ? trim( (string) $opts['worktree_sort']) : '',
43+
'artifact_sort' => isset($opts['artifact_sort']) && '' !== trim( (string) $opts['artifact_sort']) ? trim( (string) $opts['artifact_sort']) : '',
4044
'worktree_stale_only' => ! empty($opts['worktree_stale_only']),
4145
);
4246

@@ -46,14 +50,13 @@ public function workspace_cleanup_plan( array $opts = array() ): array|\WP_Error
4650
'summary' => array(),
4751
);
4852
if ( $inputs['include_artifacts'] ) {
49-
// Workspace cleanup plan is the source-of-truth orchestrator that later
50-
// chunks/jobs consume. Use whole-workspace inventory planning so hundreds
51-
// of worktrees are normal; apply still revalidates every row before delete.
5253
$artifact_plan = $this->worktree_cleanup_artifacts(
5354
array(
5455
'dry_run' => true,
5556
'force' => $inputs['force_artifact_cleanup'],
56-
'full_workspace' => true,
57+
'full_workspace' => $inputs['full_workspace'],
58+
'limit' => $inputs['limit'],
59+
'offset' => $inputs['offset'],
5760
'sort' => $inputs['artifact_sort'],
5861
)
5962
);
@@ -68,15 +71,20 @@ public function workspace_cleanup_plan( array $opts = array() ): array|\WP_Error
6871
'summary' => array(),
6972
);
7073
if ( $inputs['include_worktrees'] ) {
71-
$worktree_plan = $this->worktree_cleanup_merged(
72-
array(
73-
'dry_run' => true,
74-
'skip_github' => true,
75-
'older_than' => $inputs['worktree_older_than'],
76-
'sort' => $inputs['worktree_sort'],
77-
'stale_liveness_only' => $inputs['worktree_stale_only'],
78-
)
74+
$worktree_args = array(
75+
'dry_run' => true,
76+
'skip_github' => true,
77+
'inventory_only' => ! $inputs['full_workspace'],
78+
'older_than' => $inputs['worktree_older_than'],
79+
'sort' => $inputs['worktree_sort'],
80+
'stale_liveness_only' => $inputs['worktree_stale_only'],
7981
);
82+
if ( ! $inputs['full_workspace'] ) {
83+
$worktree_args['limit'] = $inputs['limit'];
84+
$worktree_args['offset'] = $inputs['offset'];
85+
$worktree_args['until_budget'] = $inputs['until_budget'];
86+
}
87+
$worktree_plan = $this->worktree_cleanup_merged($worktree_args);
8088
if ( $worktree_plan instanceof \WP_Error ) {
8189
return $worktree_plan;
8290
}
@@ -98,12 +106,16 @@ public function workspace_cleanup_plan( array $opts = array() ): array|\WP_Error
98106
'resolve_signal' => $rows['resolver'],
99107
);
100108

109+
$continuation = $this->build_cleanup_plan_continuation($artifact_plan, $worktree_plan, $inputs);
101110
$summary = $this->build_cleanup_plan_summary($rows, $blocked, $artifact_plan, $worktree_plan, $inputs);
102111
$summary['rows_by_action'] = array(
103112
'remove_artifacts' => count($action_rows['remove_artifacts']),
104113
'remove_worktree' => count($action_rows['remove_worktree']),
105114
'resolve_signal' => count($action_rows['resolve_signal']),
106115
);
116+
if ( array() !== $continuation ) {
117+
$summary['continuation'] = $continuation;
118+
}
107119

108120
$plan = array(
109121
'success' => true,
@@ -127,6 +139,9 @@ public function workspace_cleanup_plan( array $opts = array() ): array|\WP_Error
127139
'action_rows' => $action_rows,
128140
'summary' => $summary,
129141
);
142+
if ( array() !== $continuation ) {
143+
$plan['continuation'] = $continuation;
144+
}
130145

131146
$plan['plan_id'] = $this->stable_cleanup_hash(
132147
array(
@@ -369,6 +384,66 @@ private function build_cleanup_plan_summary( array $rows, array $blocked = array
369384
);
370385
}
371386

387+
/**
388+
* Build operator continuation evidence from bounded child cleanup plans.
389+
*
390+
* @param array<string,mixed> $artifact_plan Artifact cleanup child plan.
391+
* @param array<string,mixed> $worktree_plan Worktree cleanup child plan.
392+
* @param array<string,mixed> $inputs Normalized plan inputs.
393+
* @return array<string,mixed>
394+
*/
395+
private function build_cleanup_plan_continuation( array $artifact_plan, array $worktree_plan, array $inputs ): array {
396+
$limit = max(1, (int) ( $inputs['limit'] ?? self::CLEANUP_PLAN_DEFAULT_LIMIT ));
397+
$offset = max(0, (int) ( $inputs['offset'] ?? 0 ));
398+
$next_offset = null;
399+
$lanes = array();
400+
401+
$plans = array(
402+
'artifact_cleanup' => $artifact_plan,
403+
'worktree_removal' => $worktree_plan,
404+
);
405+
406+
foreach ( $plans as $lane => $plan ) {
407+
$pagination = is_array($plan['pagination'] ?? null) ? $plan['pagination'] : ( is_array($plan['summary']['pagination'] ?? null) ? $plan['summary']['pagination'] : null );
408+
if ( null === $pagination ) {
409+
continue;
410+
}
411+
412+
$lane_next = $pagination['next_offset'] ?? null;
413+
$lanes[ $lane ] = array(
414+
'complete' => ! empty($pagination['complete']),
415+
'partial' => ! empty($pagination['partial']),
416+
'offset' => (int) ( $pagination['offset'] ?? $offset ),
417+
'limit' => isset($pagination['limit']) ? (int) $pagination['limit'] : $limit,
418+
'scanned' => (int) ( $pagination['scanned'] ?? 0 ),
419+
'total' => (int) ( $pagination['total'] ?? 0 ),
420+
'next_offset' => null === $lane_next ? null : (int) $lane_next,
421+
'budget_stopped' => ! empty($pagination['budget_stopped']),
422+
);
423+
if ( null !== $lane_next ) {
424+
$next_offset = null === $next_offset ? (int) $lane_next : min($next_offset, (int) $lane_next);
425+
}
426+
}
427+
428+
if ( array() === $lanes ) {
429+
return array();
430+
}
431+
432+
$complete = null === $next_offset;
433+
return array(
434+
'bounded' => empty($inputs['full_workspace']),
435+
'complete' => $complete,
436+
'partial' => ! $complete,
437+
'limit' => $limit,
438+
'offset' => $offset,
439+
'next_offset' => $next_offset,
440+
'lanes' => $lanes,
441+
'next_command' => null === $next_offset ? null : sprintf('studio wp datamachine-code workspace cleanup plan --mode=retention --limit=%d --offset=%d --format=json', $limit, $next_offset),
442+
'full_audit_command' => 'studio wp datamachine-code workspace cleanup plan --mode=retention --exhaustive --format=json',
443+
'operator_note' => empty($inputs['full_workspace']) ? 'Default cleanup planning is bounded for large workspaces; review/apply this page or continue with next_command for the next page.' : 'Full-workspace cleanup audit requested explicitly.',
444+
);
445+
}
446+
372447
/**
373448
* Return the bytes a cleanup row is expected to reclaim.
374449
*
@@ -584,8 +659,8 @@ private function cleanup_plan_recommended_commands( array $inputs ): array {
584659
array(
585660
'label' => 'inspect_full_plan_json',
586661
'risk' => 'none',
587-
'command' => 'studio wp datamachine-code workspace cleanup plan --mode=retention --format=json',
588-
'when' => 'export the full plan for review or archival',
662+
'command' => 'studio wp datamachine-code workspace cleanup plan --mode=retention --exhaustive --format=json',
663+
'when' => 'operator explicitly wants a full unbounded audit for review or archival',
589664
),
590665
array(
591666
'label' => 'resolve_metadata_blockers',
@@ -596,8 +671,8 @@ private function cleanup_plan_recommended_commands( array $inputs ): array {
596671
array(
597672
'label' => 'refresh_merge_signals',
598673
'risk' => 'none',
599-
'command' => 'studio wp datamachine-code workspace worktree cleanup --dry-run --format=json',
600-
'when' => 'active or lifecycle rows need full merge/PR signal review',
674+
'command' => 'studio wp datamachine-code workspace worktree cleanup --dry-run --limit=100 --offset=0 --until-budget=30s --format=json',
675+
'when' => 'active or lifecycle rows need deeper merge/PR signal review after the cheap inventory pass',
601676
),
602677
);
603678

inc/Workspace/WorkspaceWorktreeCleanupEngine.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ public function worktree_cleanup_merged( array $opts = array() ): array|\WP_Erro
9898
return new \WP_Error('inventory_cleanup_apply_plan_unsupported', 'Inventory-only cleanup cannot apply a plan because it intentionally skips full safety revalidation.', array( 'status' => 400 ));
9999
}
100100

101-
return $this->worktree_cleanup_inventory_only($older_than, $sort, $include_repaired_metadata);
101+
return $this->worktree_cleanup_inventory_only($older_than, $sort, $include_repaired_metadata, $limit, $offset);
102102
}
103103

104104
$planned_candidates = null;

inc/Workspace/WorkspaceWorktreeInventoryCleanup.php

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,14 @@ trait WorkspaceWorktreeInventoryCleanup {
2121
* Only explicit lifecycle cleanup signals become candidates; every ambiguous
2222
* worktree is skipped with stable reason codes for review.
2323
*
24-
* @param string $older_than Optional age filter duration.
25-
* @param string $sort Optional candidate sort.
24+
* @param string $older_than Optional age filter duration.
25+
* @param string $sort Optional candidate sort.
26+
* @param bool $include_repaired_metadata Whether repaired metadata rows can be candidates.
27+
* @param int|null $limit Optional worktree page size.
28+
* @param int $offset Optional worktree page offset.
2629
* @return array<string,mixed>|\WP_Error
2730
*/
28-
private function worktree_cleanup_inventory_only( string $older_than, string $sort, bool $include_repaired_metadata = false ): array|\WP_Error {
31+
private function worktree_cleanup_inventory_only( string $older_than, string $sort, bool $include_repaired_metadata = false, ?int $limit = null, int $offset = 0 ): array|\WP_Error {
2932
$age_filter = null;
3033
if ( '' !== $older_than ) {
3134
$duration_seconds = $this->parse_worktree_cleanup_duration($older_than);
@@ -48,7 +51,14 @@ private function worktree_cleanup_inventory_only( string $older_than, string $so
4851
$candidates = array();
4952
$skipped = array();
5053

51-
foreach ( $this->build_workspace_inventory_rows() as $wt ) {
54+
$inventory_rows = array_values(array_filter($this->build_workspace_inventory_rows(), fn( $wt ) => ! empty($wt['is_worktree']) ));
55+
$total = count($inventory_rows);
56+
$offset = max(0, $offset);
57+
$page_rows = null === $limit ? array_slice($inventory_rows, $offset) : array_slice($inventory_rows, $offset, max(1, $limit));
58+
$processed = 0;
59+
60+
foreach ( $page_rows as $wt ) {
61+
++$processed;
5262
if ( empty($wt['is_worktree']) ) {
5363
continue;
5464
}
@@ -234,13 +244,17 @@ private function worktree_cleanup_inventory_only( string $older_than, string $so
234244
}
235245

236246
$candidates = $this->sort_worktree_cleanup_rows($candidates, $sort);
247+
$pagination = $this->build_worktree_cleanup_pagination($offset, $limit, $processed, $total, false, null);
237248
$summary = $this->build_worktree_cleanup_summary($candidates, array(), $skipped, $age_filter);
249+
if ( null !== $pagination ) {
250+
$summary['pagination'] = $pagination;
251+
}
238252
if ( ! empty($candidates) ) {
239253
$summary['bounded_cleanup_eligible_apply'] = $this->build_bounded_cleanup_eligible_apply_hint(count($candidates), $older_than, $sort, $include_repaired_metadata);
240254
$summary['apply_command'] = $summary['bounded_cleanup_eligible_apply']['apply_command'];
241255
}
242256

243-
return array(
257+
$response = array(
244258
'success' => true,
245259
'dry_run' => true,
246260
'inventory_only' => true,
@@ -249,6 +263,10 @@ private function worktree_cleanup_inventory_only( string $older_than, string $so
249263
'skipped' => $skipped,
250264
'summary' => $summary,
251265
);
266+
if ( null !== $pagination ) {
267+
$response['pagination'] = $pagination;
268+
}
269+
return $response;
252270
}
253271

254272
/**

0 commit comments

Comments
 (0)