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
88 changes: 86 additions & 2 deletions inc/Workspace/WorkspaceMetadataReconciliation.php
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,10 @@ public function worktree_reconcile_metadata( array $opts = array() ): array|\WP_
$plan['summary']['prefiltered'] = $prefilter['summary'];
if ( null !== $pagination ) {
$plan['pagination'] = $pagination;
$plan['evidence'] = array(
if ( ! empty($pagination['complete']) ) {
$plan['remaining_blockers'] = $this->build_worktree_metadata_reconciliation_remaining_blockers($proposals, array(), $skipped, $dry_run, $limit, null !== $budget_context ? (string) $budget_context['label'] : '');
}
$plan['evidence'] = array(
'scope' => 'paginated metadata reconciliation dry-run',
'note' => 'Only candidate rows with missing, incomplete, invalid, or finalizable metadata ran per-worktree dirty, unpushed, merge-signal, and GitHub probes. Run the next_offset page until complete for full inventory review.',
'fields_skipped_by_listing' => (array) ( $listing['fields_skipped'] ?? array() ),
Expand Down Expand Up @@ -252,7 +255,7 @@ private function drain_worktree_metadata_reconciliation_budget( int $limit, int
$pagination['next_command'] = $next_command;
}

return array(
$result = array(
'success' => true,
'dry_run' => false,
'applied' => true,
Expand Down Expand Up @@ -280,6 +283,11 @@ private function drain_worktree_metadata_reconciliation_budget( int $limit, int
)
),
);
if ( $complete ) {
$result['remaining_blockers'] = $this->build_worktree_metadata_reconciliation_remaining_blockers($proposals, $written, $skipped, false, $limit, $budget_label);
}

return $result;
}

/**
Expand Down Expand Up @@ -1498,6 +1506,9 @@ private function apply_worktree_metadata_reconciliation_plan( array $plan ): arr
if ( ! empty($plan['direct_apply']) && count($written) > 0 ) {
$result['pagination'] = $this->restart_worktree_metadata_reconciliation_pagination( (array) $result['pagination'] );
}
if ( ! empty($result['pagination']['complete']) ) {
$result['remaining_blockers'] = $this->build_worktree_metadata_reconciliation_remaining_blockers($planned, $written, $skipped, false, (int) ( $result['pagination']['limit'] ?? self::METADATA_RECONCILE_DEFAULT_LIMIT ), '');
}
}
if ( isset($plan['evidence']) && is_array($plan['evidence']) ) {
$result['evidence'] = array_merge(
Expand Down Expand Up @@ -1691,6 +1702,79 @@ private function build_worktree_metadata_reconciliation_summary( int $inspected,
);
}

/**
* Explain why metadata reconciliation blockers remain after a complete bounded pass.
*
* @param array<int,array<string,mixed>> $proposals Proposal rows.
* @param array<int,array<string,mixed>> $written Written rows.
* @param array<int,array<string,mixed>> $skipped Skipped rows.
* @param bool $dry_run Whether this was a dry-run page.
* @param int $limit Page size for generated commands.
* @param string $budget Optional budget label.
* @return array<string,mixed>
*/
private function build_worktree_metadata_reconciliation_remaining_blockers( array $proposals, array $written, array $skipped, bool $dry_run, int $limit, string $budget ): array {
$written_handles = array_flip(array_map(fn( $row ) => (string) ( $row['handle'] ?? '' ), $written));
$pending_proposals = array_values(array_filter($proposals, fn( $row ) => '' === (string) ( $row['handle'] ?? '' ) || ! isset($written_handles[ (string) $row['handle'] ] )));

$proposed_by_reason = array();
$proposed_by_state = array();
foreach ( $pending_proposals as $row ) {
$reason = (string) ( $row['reason_code'] ?? 'unknown' );
$state = (string) ( $row['proposed_metadata']['lifecycle_state'] ?? 'unknown' );
$proposed_by_reason[ $reason ] = (int) ( $proposed_by_reason[ $reason ] ?? 0 ) + 1;
$proposed_by_state[ $state ] = (int) ( $proposed_by_state[ $state ] ?? 0 ) + 1;
}
ksort($proposed_by_reason);
ksort($proposed_by_state);

$skipped_by_reason = array();
$manual_repair = 0;
$retry_may_help = 0;
foreach ( $skipped as $row ) {
$reason = (string) ( $row['reason_code'] ?? 'unknown' );
$skipped_by_reason[ $reason ] = (int) ( $skipped_by_reason[ $reason ] ?? 0 ) + 1;
if ( in_array($reason, array( 'probe_timeout', 'git_probe_failed', 'github_unknown' ), true) ) {
++$retry_may_help;
} else {
++$manual_repair;
}
}
ksort($skipped_by_reason);

$budget_arg = '' !== $budget ? ' --until-budget=' . $budget : '';
if ( $dry_run && array() !== $pending_proposals ) {
$next_action = 'apply_reviewed_plan';
$next_command = sprintf('studio wp datamachine-code workspace worktree reconcile-metadata --apply --limit=%d --offset=0%s --format=json', $limit, $budget_arg);
$why = 'The complete dry-run found repairable rows; applying the reviewed plan can reduce these blockers.';
} elseif ( $retry_may_help > 0 ) {
$next_action = 'retry_after_transient_probe_failure';
$next_command = sprintf('studio wp datamachine-code workspace worktree reconcile-metadata --apply --limit=%d --offset=0%s --format=json', $limit, $budget_arg);
$why = 'Some rows were skipped because probes were unavailable or timed out; another pass may help after the underlying probe issue clears.';
} elseif ( $manual_repair > 0 ) {
$next_action = 'manual_repair_required';
$next_command = null;
$why = 'The bounded pass is complete and remaining skipped rows need operator repair before reconciliation can classify them.';
} else {
$next_action = 'none';
$next_command = null;
$why = 'The bounded pass is complete and no metadata reconciliation blockers remain in the scanned candidate set.';
}

return array(
'total' => count($pending_proposals) + count($skipped),
'needs_apply' => count($pending_proposals),
'manual_repair' => $manual_repair,
'retry_may_help' => $retry_may_help,
'proposed_by_reason' => $proposed_by_reason,
'proposed_by_state' => $proposed_by_state,
'skipped_by_reason' => $skipped_by_reason,
'next_action' => $next_action,
'next_command' => $next_command,
'why' => $why,
);
}

/**
* Summarize the slowest worktree rows from an expensive report page.
*
Expand Down
29 changes: 16 additions & 13 deletions inc/Workspace/WorkspaceWorktreeCleanupEngine.php
Original file line number Diff line number Diff line change
Expand Up @@ -266,19 +266,22 @@ public function worktree_cleanup_merged( array $opts = array() ): array|\WP_Erro
}
$skipped[] = array_merge(
array(
'handle' => $handle,
'repo' => $repo,
'branch' => $branch,
'path' => $wt_path,
'reason_code' => 'missing_metadata',
'reason' => 'missing repo/branch/path',
'missing_fields' => $missing_fields,
'hydrated_fields' => $identity['hydrated_fields'],
'identity_conflicts' => $identity['conflicts'],
'stored_identity' => $identity['stored_identity'],
'hint' => 'Run workspace worktree prune if this is a stale registry entry; inspect manually if the path still exists.',
'created_at' => $created_at,
'metadata' => $metadata,
'handle' => $handle,
'repo' => $repo,
'branch' => $branch,
'path' => $wt_path,
'reason_code' => 'missing_metadata',
'reason' => 'missing repo/branch/path',
'missing_fields' => $missing_fields,
'hydrated_fields' => $identity['hydrated_fields'],
'identity_conflicts' => $identity['conflicts'],
'stored_identity' => $identity['stored_identity'],
'reconcile_reason_code' => array() === $identity['conflicts'] ? 'missing_identity' : 'manual_review_identity_metadata',
'reconcile_skipped_state' => 'manual_repair_required',
'reconcile_next_action' => 'repair the missing identity fields or stale registry row before another reconcile pass can classify cleanup safety',
'hint' => 'Run workspace worktree prune if this is a stale registry entry; inspect manually if the path still exists.',
'created_at' => $created_at,
'metadata' => $metadata,
), $disk_fields
);
continue;
Expand Down
9 changes: 6 additions & 3 deletions inc/Workspace/WorkspaceWorktreeInventoryCleanup.php
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,12 @@ private function worktree_cleanup_inventory_only( string $older_than, string $so
if ( ! is_array($metadata) || array() === $metadata ) {
$skipped[] = array_merge(
$base_row, array(
'reason_code' => 'needs_metadata_reconcile',
'reason' => 'inventory row has no lifecycle metadata; metadata reconciliation is required before cleanup planning can classify it',
'hint' => 'Run workspace worktree reconcile-metadata --dry-run --limit=25 --offset=0 --until-budget=30s --format=json to generate reviewed metadata reconciliation rows.',
'reason_code' => 'needs_metadata_reconcile',
'reason' => 'inventory row has no lifecycle metadata; metadata reconciliation is required before cleanup planning can classify it',
'reconcile_reason_code' => 'missing_metadata',
'reconcile_skipped_state' => 'not_yet_reconciled',
'reconcile_next_action' => 'run complete reconcile dry-run, then apply reviewed repairable rows; if complete output lists this row under skipped_by_reason, follow that reason before retrying cleanup',
'hint' => 'Run workspace worktree reconcile-metadata --dry-run --limit=25 --offset=0 --until-budget=30s --format=json to generate reviewed metadata reconciliation rows.',
)
);
continue;
Expand Down
79 changes: 79 additions & 0 deletions tests/worktree-metadata-reconciliation-remaining-blockers.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<?php

declare(strict_types=1);

if ( ! defined('ABSPATH') ) {
define('ABSPATH', __DIR__ . '/fixtures/');
}

require_once dirname(__DIR__) . '/inc/Workspace/WorkspaceMetadataReconciliation.php';

function worktree_metadata_reconciliation_remaining_blockers_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)));
}
}

$reconciler = new class {
use DataMachineCode\Workspace\WorkspaceMetadataReconciliation;
};

$method = new ReflectionMethod($reconciler, 'build_worktree_metadata_reconciliation_remaining_blockers');

$dry_run = $method->invoke(
$reconciler,
array(
array(
'handle' => 'repo@one',
'reason_code' => 'metadata_backfill',
'proposed_metadata' => array( 'lifecycle_state' => 'active' ),
),
),
array(),
array(
array(
'handle' => 'repo@manual',
'reason_code' => 'missing_identity',
),
),
true,
25,
'30s'
);

worktree_metadata_reconciliation_remaining_blockers_assert_same(2, $dry_run['total'], 'dry-run complete pass counts proposals and skips as remaining blockers');
worktree_metadata_reconciliation_remaining_blockers_assert_same(1, $dry_run['needs_apply'], 'dry-run complete pass reports repairable rows needing apply');
worktree_metadata_reconciliation_remaining_blockers_assert_same(array( 'metadata_backfill' => 1 ), $dry_run['proposed_by_reason'], 'dry-run groups repairable blockers by reconcile reason');
worktree_metadata_reconciliation_remaining_blockers_assert_same(array( 'active' => 1 ), $dry_run['proposed_by_state'], 'dry-run groups repairable blockers by proposed state');
worktree_metadata_reconciliation_remaining_blockers_assert_same(array( 'missing_identity' => 1 ), $dry_run['skipped_by_reason'], 'dry-run groups skipped blockers by reconcile reason');
worktree_metadata_reconciliation_remaining_blockers_assert_same('apply_reviewed_plan', $dry_run['next_action'], 'dry-run next action points at apply when it can help');
worktree_metadata_reconciliation_remaining_blockers_assert_same('studio wp datamachine-code workspace worktree reconcile-metadata --apply --limit=25 --offset=0 --until-budget=30s --format=json', $dry_run['next_command'], 'dry-run next command is actionable');

$manual = $method->invoke(
$reconciler,
array(
array(
'handle' => 'repo@written',
'reason_code' => 'metadata_backfill',
'proposed_metadata' => array( 'lifecycle_state' => 'active' ),
),
),
array( array( 'handle' => 'repo@written' ) ),
array( array( 'handle' => 'repo@manual', 'reason_code' => 'manual_review_identity_metadata' ) ),
false,
25,
''
);

worktree_metadata_reconciliation_remaining_blockers_assert_same(1, $manual['total'], 'apply complete pass excludes written proposals from remaining blockers');
worktree_metadata_reconciliation_remaining_blockers_assert_same(0, $manual['needs_apply'], 'apply complete pass has no pending proposals after write');
worktree_metadata_reconciliation_remaining_blockers_assert_same(1, $manual['manual_repair'], 'apply complete pass identifies manual repair blockers');
worktree_metadata_reconciliation_remaining_blockers_assert_same('manual_repair_required', $manual['next_action'], 'apply complete pass does not suggest another pass for manual blockers');
worktree_metadata_reconciliation_remaining_blockers_assert_same(null, $manual['next_command'], 'manual repair blockers do not get a misleading next command');

$source = file_get_contents(dirname(__DIR__) . '/inc/Workspace/WorkspaceWorktreeInventoryCleanup.php');
if ( false === $source || ! str_contains($source, "'reconcile_reason_code' => 'missing_metadata'") || ! str_contains($source, "'reconcile_skipped_state' => 'not_yet_reconciled'") ) {
throw new RuntimeException('Inventory needs_metadata_reconcile rows must expose reconcile reason and skipped state.');
}

echo "worktree-metadata-reconciliation-remaining-blockers: ok\n";
Loading