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
120 changes: 118 additions & 2 deletions inc/Workspace/WorkspaceAbandonedCleanupOrchestrator.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ class WorkspaceAbandonedCleanupOrchestrator {
/** @var callable */
private $clock;

private ?object $active_no_signal_report_ability = null;

/**
* @param callable|null $ability_resolver Optional resolver receiving an ability name.
* @param callable|null $clock Optional clock returning microtime-style seconds.
Expand Down Expand Up @@ -69,7 +71,7 @@ public function run( array $input ): array|\WP_Error {
$deadline = $this->now() + $budget_seconds;
}

$abilities = $this->resolve_required_abilities();
$abilities = $this->resolve_required_abilities($active_no_signal_drain);
if ( is_wp_error($abilities) ) {
return $abilities;
}
Expand Down Expand Up @@ -217,7 +219,7 @@ private function stage_order(): array {
}

/** @return array<string,mixed>|\WP_Error */
private function resolve_required_abilities(): array|\WP_Error {
private function resolve_required_abilities( bool $active_no_signal_drain = false ): array|\WP_Error {
$required = array(
'reconcile_metadata' => 'datamachine-code/workspace-worktree-reconcile-metadata',
'finalized' => 'datamachine-code/workspace-worktree-active-no-signal-finalized-apply',
Expand All @@ -227,6 +229,9 @@ private function resolve_required_abilities(): array|\WP_Error {
'bounded_apply' => 'datamachine-code/workspace-worktree-bounded-cleanup-eligible-apply',
'prune' => 'datamachine-code/workspace-worktree-prune',
);
if ( $active_no_signal_drain ) {
$required['active_no_signal_report'] = 'datamachine-code/workspace-worktree-active-no-signal-report';
}

$abilities = array();
foreach ( $required as $key => $ability_name ) {
Expand All @@ -236,6 +241,7 @@ private function resolve_required_abilities(): array|\WP_Error {
}
$abilities[ $key ] = $ability;
}
$this->active_no_signal_report_ability = $abilities['active_no_signal_report'] ?? null;

return $abilities;
}
Expand Down Expand Up @@ -334,12 +340,122 @@ private function finalize_result( array $result, bool $apply, bool $force, int $
if ( empty($result['continuation']) && ! $force && ! $active_no_signal_drain ) {
$result['next_commands'][] = sprintf('studio wp datamachine-code workspace worktree abandoned --apply --force --limit=%d --passes=%d%s --format=json', $limit, $passes, '' !== $until_budget ? ' --until-budget=' . $until_budget : '');
}
if ( $active_no_signal_drain && empty($result['continuation']) && empty($result['evidence']['budget_exhausted']) ) {
$this->append_active_no_signal_backlog_summary($result, min($limit, 25));
}

$result['evidence']['elapsed_ms'] = (int) round(( $this->now() - $started_at ) * 1000);

return $result;
}

private function append_active_no_signal_backlog_summary( array &$result, int $limit ): void {
if ( null === $this->active_no_signal_report_ability ) {
return;
}

$limit = max(1, $limit);
$report = $this->execute_ability(
$this->active_no_signal_report_ability,
array(
'limit' => $limit,
'offset' => 0,
'until_budget' => '15s',
)
);
if ( is_wp_error($report) ) {
$result['remaining_active_no_signal_backlog'] = array(
'available' => false,
'reason' => (string) $report->get_error_code(),
'message' => $report->get_error_message(),
'next_commands' => array(
sprintf('studio wp datamachine-code workspace worktree active-no-signal-report --limit=%d --offset=0 --format=json', $limit),
),
);
return;
}

$result['remaining_active_no_signal_backlog'] = $this->build_active_no_signal_backlog_summary($report, $limit);
foreach ( (array) ( $result['remaining_active_no_signal_backlog']['next_commands'] ?? array() ) as $command ) {
$result['next_commands'][] = (string) $command;
}
$result['next_commands'] = array_values(array_unique(array_filter(array_map('strval', (array) $result['next_commands']))));
}

/** @return array<string,mixed> */
private function build_active_no_signal_backlog_summary( array $report, int $limit ): array {
$rows = (array) ( $report['rows'] ?? array() );
$summary = (array) ( $report['summary'] ?? array() );
$pagination = (array) ( $report['pagination'] ?? array() );
$total = (int) ( $summary['total_active_no_signal'] ?? $pagination['total'] ?? 0 );
$sampled = (int) ( $summary['inspected'] ?? count($rows) );
$buckets = array();

foreach ( (array) ( $summary['by_suggested_action'] ?? array() ) as $reason => $count ) {
$buckets[ (string) $reason ] = array(
'count' => (int) $count,
'examples' => array(),
);
}

foreach ( $rows as $row ) {
if ( ! is_array($row) ) {
continue;
}
$reason = (string) ( $row['suggested_action'] ?? 'insufficient_signal' );
$buckets[ $reason ] ??= array(
'count' => 0,
'examples' => array(),
);
if ( ! isset($summary['by_suggested_action'][ $reason ]) ) {
++$buckets[ $reason ]['count'];
}
if ( count($buckets[ $reason ]['examples']) < 3 ) {
$buckets[ $reason ]['examples'][] = $this->active_no_signal_backlog_example($row);
}
}
ksort($buckets);

$commands = array(
sprintf('studio wp datamachine-code workspace worktree active-no-signal-report --limit=%d --offset=0 --format=json', $limit),
sprintf('studio wp datamachine-code workspace worktree active-no-signal-report --limit=%d --offset=0 --verbose --format=json', $limit),
);
if ( ! empty($pagination['next_command']) ) {
$commands[] = (string) $pagination['next_command'];
}

return array(
'available' => true,
'total_active_no_signal' => $total,
'sampled' => $sampled,
'unreviewed_count' => max(0, $total - $sampled),
'by_actionable_reason' => $buckets,
'counts_scope' => 'bounded_post_drain_sample_only',
'limitation' => 'Counts by actionable reason cover only this bounded post-drain sample; active-no-signal report has pagination but no safe bucket filter, so full per-bucket totals are not scanned by default.',
'pagination' => $pagination,
'next_commands' => array_values(array_unique(array_filter($commands))),
);
}

/** @return array<string,mixed> */
private function active_no_signal_backlog_example( array $row ): array {
$example = array(
'handle' => (string) ( $row['handle'] ?? '' ),
);
foreach ( array( 'repo', 'branch', 'path', 'reason' ) as $field ) {
if ( isset($row[ $field ]) && '' !== (string) $row[ $field ] ) {
$example[ $field ] = (string) $row[ $field ];
}
}
if ( isset($row['dirty']) ) {
$example['dirty'] = (int) $row['dirty'];
}
if ( isset($row['unpushed']) ) {
$example['unpushed'] = (int) $row['unpushed'];
}
return $example;
}

private function stage_incomplete( array $step ): bool {
$pagination = (array) ( $step['pagination'] ?? $step['continuation'] ?? array() );
if ( empty($pagination) || ! empty($pagination['complete']) || ! isset($pagination['next_offset']) ) {
Expand Down
45 changes: 45 additions & 0 deletions tests/smoke-abandoned-cleanup-orchestrator.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ public function __construct( string $code = '', string $message = '', array $dat
$this->message = $message;
$this->data = $data;
}

public function get_error_code(): string {
return $this->code;
}

public function get_error_message(): string {
return $this->message;
}
}
}

Expand Down Expand Up @@ -57,6 +65,7 @@ public function execute( array $input ): array {
'summary' => $this->summary,
'pagination' => $this->pagination,
'skipped' => $this->skipped,
'rows' => $this->skipped,
);
}
}
Expand All @@ -82,6 +91,7 @@ public function execute( array $input ): array {
'summary' => array(),
'pagination' => array( 'complete' => true ),
'skipped' => array(),
'rows' => array(),
),
$response
);
Expand Down Expand Up @@ -171,6 +181,34 @@ function abandoned_cleanup_assert( bool $condition, string $label ): void {
'datamachine-code/workspace-worktree-active-no-signal-equivalent-clean-apply' => new AbandonedCleanupFakeAbility('equivalent_clean', array(), array( 'complete' => true )),
'datamachine-code/workspace-worktree-active-no-signal-merged-apply' => new AbandonedCleanupFakeAbility('merged', array(), array( 'complete' => true )),
'datamachine-code/workspace-worktree-active-no-signal-remote-clean-apply' => new AbandonedCleanupFakeAbility('remote_clean', array(), array( 'complete' => true )),
'datamachine-code/workspace-worktree-active-no-signal-report' => new AbandonedCleanupFakeAbility(
'active_no_signal_report',
array(
'total_active_no_signal' => 5,
'inspected' => 2,
'by_suggested_action' => array(
'inspect_unpushed_or_dirty' => 1,
'insufficient_signal' => 1,
),
),
array( 'complete' => false, 'total' => 5, 'next_command' => 'studio wp datamachine-code workspace worktree active-no-signal-report --limit=10 --offset=2 --format=json' ),
array(
array(
'handle' => 'repo@dirty',
'repo' => 'repo',
'branch' => 'dirty',
'suggested_action' => 'inspect_unpushed_or_dirty',
'dirty' => 1,
'unpushed' => 0,
),
array(
'handle' => 'repo@unknown',
'repo' => 'repo',
'branch' => 'unknown',
'suggested_action' => 'insufficient_signal',
),
)
),
'datamachine-code/workspace-worktree-bounded-cleanup-eligible-apply' => new AbandonedCleanupFakeAbility('bounded', array( 'processed' => 1, 'removed' => 1 ), array( 'complete' => true )),
'datamachine-code/workspace-worktree-prune' => new AbandonedCleanupFakeAbility('prune'),
);
Expand All @@ -184,6 +222,12 @@ function abandoned_cleanup_assert( bool $condition, string $label ): void {
abandoned_cleanup_assert(0 === count($active_abilities['datamachine-code/workspace-worktree-reconcile-metadata']->calls), 'active/no-signal drain skips reconcile metadata');
abandoned_cleanup_assert(1 === $active_result['summary']['marked_cleanup_eligible'], 'active/no-signal drain counts metadata promotions');
abandoned_cleanup_assert(2 === $active_result['summary']['removed'], 'active/no-signal drain removes bounded eligible rows before and after classification');
abandoned_cleanup_assert(5 === $active_result['remaining_active_no_signal_backlog']['total_active_no_signal'], 'active/no-signal drain summarizes remaining backlog total');
abandoned_cleanup_assert(2 === $active_result['remaining_active_no_signal_backlog']['sampled'], 'active/no-signal drain summarizes sampled backlog rows');
abandoned_cleanup_assert(1 === $active_result['remaining_active_no_signal_backlog']['by_actionable_reason']['inspect_unpushed_or_dirty']['count'], 'active/no-signal drain groups backlog by actionable reason');
abandoned_cleanup_assert(3 === $active_result['remaining_active_no_signal_backlog']['unreviewed_count'], 'active/no-signal drain reports unreviewed backlog count');
abandoned_cleanup_assert('bounded_post_drain_sample_only' === $active_result['remaining_active_no_signal_backlog']['counts_scope'], 'active/no-signal drain documents bounded counts scope');
abandoned_cleanup_assert(in_array('studio wp datamachine-code workspace worktree active-no-signal-report --limit=10 --offset=2 --format=json', $active_result['next_commands'], true), 'active/no-signal drain includes next report page command');

$force_result = $orchestrator->run(array( 'active_no_signal_drain' => true, 'apply' => true, 'force' => true ));
abandoned_cleanup_assert(is_wp_error($force_result), 'active/no-signal drain refuses force');
Expand All @@ -200,6 +244,7 @@ function abandoned_cleanup_assert( bool $condition, string $label ): void {
'datamachine-code/workspace-worktree-active-no-signal-equivalent-clean-apply' => new AbandonedCleanupFakeAbility('equivalent_clean', array(), array( 'complete' => true )),
'datamachine-code/workspace-worktree-active-no-signal-merged-apply' => new AbandonedCleanupFakeAbility('merged', array(), array( 'complete' => true )),
'datamachine-code/workspace-worktree-active-no-signal-remote-clean-apply' => new AbandonedCleanupFakeAbility('remote_clean', array(), array( 'complete' => true )),
'datamachine-code/workspace-worktree-active-no-signal-report' => new AbandonedCleanupFakeAbility('active_no_signal_report', array( 'total_active_no_signal' => 0, 'inspected' => 0, 'by_suggested_action' => array() ), array( 'complete' => true, 'total' => 0 )),
'datamachine-code/workspace-worktree-bounded-cleanup-eligible-apply' => new AbandonedCleanupFakeAbility('bounded', array( 'processed' => 0, 'removed' => 0 ), array( 'complete' => true )),
'datamachine-code/workspace-worktree-prune' => new AbandonedCleanupFakeAbility('prune'),
);
Expand Down
Loading