diff --git a/inc/Cli/Commands/WorkspaceCommand.php b/inc/Cli/Commands/WorkspaceCommand.php index 2bfd61d1..ea474975 100644 --- a/inc/Cli/Commands/WorkspaceCommand.php +++ b/inc/Cli/Commands/WorkspaceCommand.php @@ -874,6 +874,7 @@ private function cleanup_run_control_job_ids( string $operation, int $job_id ): } private function render_cleanup_control_result( array $result, array $assoc_args ): void { + $result = $this->attach_current_workspace_lock_status($result); $format = (string) ( $assoc_args['format'] ?? 'table' ); if ( 'json' === $format ) { WP_CLI::log( (string) wp_json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); @@ -896,11 +897,37 @@ private function render_cleanup_control_result( array $result, array $assoc_args if ( ! empty($result['remaining_work_summary']) && is_array($result['remaining_work_summary']) ) { $this->render_cleanup_remaining_work_summary( (array) $result['remaining_work_summary']); } + if ( ! empty($result['locks']['stale_locks']) && is_array($result['locks']['stale_locks']) ) { + $this->render_stale_lock_followup( (array) $result['locks']['stale_locks']); + } if ( ! empty($result['evidence']) ) { WP_CLI::log( (string) wp_json_encode($result['evidence'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); } } + /** + * Attach live workspace lock status to cleanup triage surfaces when available. + * + * @param array $result Cleanup result. + * @return array + */ + private function attach_current_workspace_lock_status( array $result ): array { + if ( isset($result['locks']) || ! class_exists(Workspace::class) || ! class_exists(WorkspaceMutationLock::class) ) { + return $result; + } + + try { + $workspace = new Workspace(); + $result['locks'] = WorkspaceMutationLock::status($workspace->get_path()); + } catch ( \Throwable $e ) { + $result['locks'] = array( + 'error' => $e->getMessage(), + ); + } + + return $result; + } + /** * Render compact cleanup remaining-work summary. * @@ -3918,6 +3945,30 @@ static function ( array $lock ): array { $this->format_items($items, array( 'lock_key', 'scope', 'state', 'age_seconds', 'safe_to_prune', 'owner_source', 'path' ), $assoc_args, 'lock_key'); } + $db_locks = (array) ( $db['locks'] ?? array() ); + if ( ! empty($db_locks) ) { + $items = array_map( + static function ( array $lock ): array { + return array( + 'lock_key' => (string) ( $lock['lock_key'] ?? '' ), + 'scope' => (string) ( $lock['scope'] ?? '' ), + 'state' => (string) ( $lock['state'] ?? $lock['status'] ?? '' ), + 'owner' => (string) ( $lock['owner'] ?? '' ), + 'age_seconds' => $lock['age_seconds'] ?? null, + 'expires_at' => (string) ( $lock['expires_at'] ?? '' ), + ); + }, + $db_locks + ); + WP_CLI::log(''); + WP_CLI::log('Database lock rows:'); + $this->format_items($items, array( 'lock_key', 'scope', 'state', 'owner', 'age_seconds', 'expires_at' ), $assoc_args, 'lock_key'); + } + + if ( ! empty($status['stale_locks']) && is_array($status['stale_locks']) ) { + $this->render_stale_lock_followup( (array) $status['stale_locks']); + } + $guidance = (array) ( $fs['guidance'] ?? $status['recovery_guidance'] ?? array() ); if ( ! empty($guidance) ) { WP_CLI::log(sprintf('Status: %s', (string) ( $guidance['status_command'] ?? 'wp datamachine-code workspace worktree locks --format=json' ))); @@ -3926,6 +3977,57 @@ static function ( array $lock ): array { } } + /** + * Render stale lock follow-up rows with exact preview/apply commands. + * + * @param array $report Stale lock report. + */ + private function render_stale_lock_followup( array $report ): void { + if ( (int) ( $report['count'] ?? 0 ) <= 0 ) { + return; + } + + WP_CLI::log(''); + WP_CLI::log('Stale workspace locks:'); + WP_CLI::log(sprintf('Preview: %s', (string) ( $report['preview_command'] ?? 'wp datamachine-code workspace worktree locks --prune-stale --dry-run --format=json' ))); + WP_CLI::log(sprintf('Apply: %s', (string) ( $report['apply_command'] ?? 'wp datamachine-code workspace worktree locks --prune-stale --format=json' ))); + WP_CLI::log((string) ( $report['safety'] ?? 'Active filesystem flocks are reported and protected.' )); + + $rows = array(); + foreach ( (array) ( $report['database'] ?? array() ) as $row ) { + if ( ! is_array($row) ) { + continue; + } + $rows[] = array( + 'source' => 'database', + 'lock_key' => (string) ( $row['lock_key'] ?? '' ), + 'scope' => (string) ( $row['scope'] ?? '' ), + 'owner' => (string) ( $row['owner'] ?? '' ), + 'session' => (string) ( $row['session'] ?? '' ), + 'age_seconds' => $row['age_seconds'] ?? null, + 'live_flock_present' => ! empty($row['live_flock_present']) ? 'yes' : 'no', + 'safe_to_prune' => ! empty($row['safe_to_prune']) ? 'yes' : 'no', + ); + } + foreach ( (array) ( $report['filesystem'] ?? array() ) as $row ) { + if ( ! is_array($row) ) { + continue; + } + $rows[] = array( + 'source' => 'filesystem', + 'lock_key' => (string) ( $row['lock_key'] ?? '' ), + 'scope' => (string) ( $row['scope'] ?? '' ), + 'owner' => '', + 'session' => '', + 'age_seconds' => $row['age_seconds'] ?? null, + 'live_flock_present' => ! empty($row['live_flock_present']) ? 'yes' : 'no', + 'safe_to_prune' => ! empty($row['safe_to_prune']) ? 'yes' : 'no', + ); + } + + $this->format_items($rows, array( 'source', 'lock_key', 'scope', 'owner', 'session', 'age_seconds', 'live_flock_present', 'safe_to_prune' ), array( 'format' => 'table' ), 'lock_key'); + } + private function render_workspace_error( \WP_Error $error ): void { $data = (array) $error->get_error_data(); if ( 'workspace_repo_busy' !== $error->get_error_code() && ! empty($data['next_commands']) && is_array($data['next_commands']) ) { @@ -4165,6 +4267,10 @@ private function render_workspace_hygiene_report( array $report, array $assoc_ar 'metric' ); + if ( ! empty($locks['stale_locks']) && is_array($locks['stale_locks']) ) { + $this->render_stale_lock_followup( (array) $locks['stale_locks']); + } + $duplicates = (array) ( $worktrees['duplicates'] ?? array() ); if ( array() !== $duplicates ) { WP_CLI::log(''); diff --git a/inc/Workspace/WorkspaceHygieneReport.php b/inc/Workspace/WorkspaceHygieneReport.php index 47b0488d..e84d8f6d 100644 --- a/inc/Workspace/WorkspaceHygieneReport.php +++ b/inc/Workspace/WorkspaceHygieneReport.php @@ -365,7 +365,7 @@ private function build_workspace_inventory_rows(): array { $rows = array(); foreach ( $entries as $entry ) { - if ( '.' === $entry || '..' === $entry ) { + if ( '.' === $entry || '..' === $entry || str_starts_with((string) $entry, '.') ) { continue; } @@ -444,7 +444,7 @@ private function build_workspace_size_report( int $limit ): array { $dirs = array_values( array_filter( $entries, - fn( $entry ) => '.' !== $entry && '..' !== $entry && is_dir($this->workspace_path . '/' . $entry) + fn( $entry ) => '.' !== $entry && '..' !== $entry && ! str_starts_with((string) $entry, '.') && is_dir($this->workspace_path . '/' . $entry) ) ); sort($dirs, SORT_NATURAL); diff --git a/inc/Workspace/WorkspaceLockStore.php b/inc/Workspace/WorkspaceLockStore.php index 3661ca39..e4cc6cfd 100644 --- a/inc/Workspace/WorkspaceLockStore.php +++ b/inc/Workspace/WorkspaceLockStore.php @@ -141,6 +141,7 @@ public static function status(): array { 'active_keys' => self::lock_keys_for_status('active', false), 'stale' => (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$table} WHERE status = %s AND expires_at < %s", 'active', $now)), 'stale_keys' => self::lock_keys_for_status('active', true), + 'locks' => array_merge(self::lock_rows_for_status('active', false), self::lock_rows_for_status('active', true)), 'released' => (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$table} WHERE status = %s", 'released')), 'total' => (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table}"), ); @@ -211,7 +212,7 @@ public static function active_lock( string $lock_key, string $scope ): array|nul * * @return array */ - public static function prune_expired(): array { + public static function prune_expired( array $protected_lock_keys = array() ): array { $status = self::status(); if ( empty($status['available']) ) { return array( @@ -224,22 +225,35 @@ public static function prune_expired(): array { } global $wpdb; - $table = self::table_name(); - $now = gmdate('Y-m-d H:i:s'); - $released_cutoff = gmdate('Y-m-d H:i:s', time() - self::released_ttl_seconds()); + $table = self::table_name(); + $now = gmdate('Y-m-d H:i:s'); + $released_cutoff = gmdate('Y-m-d H:i:s', time() - self::released_ttl_seconds()); + $protected_lock_keys = array_values(array_unique(array_filter(array_map('strval', $protected_lock_keys)))); + $protected_sql = ''; + $protected_args = array(); + if ( array() !== $protected_lock_keys ) { + $protected_sql = ' AND lock_key NOT IN (' . implode(', ', array_fill(0, count($protected_lock_keys), '%s')) . ')'; + $protected_args = $protected_lock_keys; + } - // phpcs:disable WordPress.DB.PreparedSQL -- Table name from $wpdb->prefix, not user input. - $wpdb->query($wpdb->prepare("UPDATE {$table} SET status = %s WHERE status = %s AND expires_at < %s", 'stale', 'active', $now)); + // phpcs:disable WordPress.DB.PreparedSQL -- Table name from $wpdb->prefix, not user input. + $mark_sql = "UPDATE {$table} SET status = %s WHERE status = %s AND expires_at < %s{$protected_sql}"; + $mark_args = array_merge(array( 'stale', 'active', $now ), $protected_args); + $wpdb->query(call_user_func_array(array( $wpdb, 'prepare' ), array_merge(array( $mark_sql ), $mark_args))); $marked = (int) $wpdb->rows_affected; - $wpdb->query($wpdb->prepare("DELETE FROM {$table} WHERE status IN (%s, %s) AND COALESCE(released_at, expires_at) < %s", 'released', 'stale', $released_cutoff)); - // phpcs:enable WordPress.DB.PreparedSQL + $delete_sql = "DELETE FROM {$table} WHERE status IN (%s, %s) AND COALESCE(released_at, expires_at) < %s{$protected_sql}"; + $delete_args = array_merge(array( 'released', 'stale', $released_cutoff ), $protected_args); + $wpdb->query(call_user_func_array(array( $wpdb, 'prepare' ), array_merge(array( $delete_sql ), $delete_args))); + // phpcs:enable WordPress.DB.PreparedSQL $deleted = (int) $wpdb->rows_affected; return array( 'available' => true, 'active_marked_stale' => $marked, 'released_deleted' => $deleted, + 'protected_active' => count($protected_lock_keys), + 'protected_keys' => $protected_lock_keys, 'before' => $status, 'after' => self::status(), ); @@ -322,6 +336,60 @@ private static function lock_keys_for_status( string $status, bool $expired ): a ); } + /** + * Return lock rows for owner/session/age diagnostics. + * + * @return array> + */ + private static function lock_rows_for_status( string $status, bool $expired ): array { + global $wpdb; + if ( ! is_object($wpdb) || ! is_callable(array( $wpdb, 'prepare' )) || ! is_callable(array( $wpdb, 'get_results' )) ) { + return array(); + } + + $table = self::table_name(); + $now = gmdate('Y-m-d H:i:s'); + $operator = $expired ? '<' : '>='; + + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from $wpdb->prefix, operator is constant-selected above. + $query = call_user_func(array( $wpdb, 'prepare' ), "SELECT id, lock_key, purpose, scope, owner, run_id, job_id, status, acquired_at, heartbeat_at, expires_at, released_at, metadata_json FROM {$table} WHERE status = %s AND expires_at {$operator} %s ORDER BY acquired_at DESC, id DESC LIMIT 25", $status, $now); + $rows = call_user_func(array( $wpdb, 'get_results' ), $query, ARRAY_A); + if ( ! is_array($rows) ) { + return array(); + } + + return array_values(array_map(static fn( array $row ): array => self::normalize_lock_row($row, $expired ? 'stale' : 'active'), $rows)); + } + + /** + * @param array $row Raw DB row. + * @return array + */ + private static function normalize_lock_row( array $row, string $state ): array { + $row['id'] = (int) ( $row['id'] ?? 0 ); + $row['job_id'] = isset($row['job_id']) ? (int) $row['job_id'] : null; + $row['state'] = $state; + $row['metadata'] = self::decode_metadata( (string) ( $row['metadata_json'] ?? '' ) ); + unset($row['metadata_json']); + + $acquired = self::timestamp_seconds( (string) ( $row['acquired_at'] ?? '' ) ); + $heartbeat = self::timestamp_seconds( (string) ( $row['heartbeat_at'] ?? '' ) ); + $expires = self::timestamp_seconds( (string) ( $row['expires_at'] ?? '' ) ); + $time = time(); + if ( null !== $acquired ) { + $row['age_seconds'] = max(0, $time - $acquired); + } + if ( null !== $heartbeat ) { + $row['heartbeat_age_seconds'] = max(0, $time - $heartbeat); + } + if ( null !== $expires ) { + $row['expires_age_seconds'] = 'stale' === $state ? max(0, $time - $expires) : 0; + $row['expires_in_seconds'] = 'active' === $state ? max(0, $expires - $time) : 0; + } + + return $row; + } + private static function expires_seconds(): int { $seconds = self::DEFAULT_EXPIRES_SECONDS; if ( function_exists('apply_filters') ) { diff --git a/inc/Workspace/WorkspaceMutationLock.php b/inc/Workspace/WorkspaceMutationLock.php index 01f40b16..fd6fabdd 100644 --- a/inc/Workspace/WorkspaceMutationLock.php +++ b/inc/Workspace/WorkspaceMutationLock.php @@ -171,12 +171,18 @@ public function release(): void { public static function status( string $workspace_path ): array { $filesystem = self::filesystem_status($workspace_path); $database = WorkspaceLockStore::status(); + $stale = self::stale_lock_report($database, $filesystem); return array( 'database' => $database, 'filesystem' => $filesystem, 'active' => self::logical_lock_count($database, $filesystem, 'active'), 'stale' => self::logical_lock_count($database, $filesystem, 'stale'), + 'stale_locks' => $stale, + 'prune_commands' => array( + 'preview' => 'wp datamachine-code workspace worktree locks --prune-stale --dry-run --format=json', + 'apply' => 'wp datamachine-code workspace worktree locks --prune-stale --format=json', + ), 'retention_enabled' => true, 'policy' => self::retention_policy(), ); @@ -189,7 +195,13 @@ public static function status( string $workspace_path ): array { */ public static function prune_stale( string $workspace_path, bool $dry_run = false ): array { $before = self::status($workspace_path); - $db_pruned = $dry_run ? array( 'dry_run' => true ) : WorkspaceLockStore::prune_expired(); + $protected = self::active_filesystem_lock_keys((array) ( $before['filesystem'] ?? array() )); + $db_pruned = $dry_run ? array( + 'dry_run' => true, + 'protected_active' => count($protected), + 'protected_keys' => $protected, + 'candidate_count' => (int) ( $before['database']['stale'] ?? 0 ), + ) : WorkspaceLockStore::prune_expired($protected); $fs_pruned = self::prune_stale_filesystem_locks($workspace_path, $dry_run); $after = self::status($workspace_path); @@ -252,6 +264,117 @@ private static function lock_status_keys( array $status, string $state ): array ); } + /** + * Build the operator-facing stale lock follow-up report. + * + * @param array $database DB lock status. + * @param array $filesystem Filesystem lock status. + * @return array + */ + private static function stale_lock_report( array $database, array $filesystem ): array { + $active_filesystem_keys = self::active_filesystem_lock_keys($filesystem); + $database_rows = array(); + foreach ( (array) ( $database['locks'] ?? array() ) as $lock ) { + if ( ! is_array($lock) || 'stale' !== (string) ( $lock['state'] ?? '' ) ) { + continue; + } + $lock_key = (string) ( $lock['lock_key'] ?? '' ); + $live_flock_present = in_array($lock_key, $active_filesystem_keys, true); + $owner_context = (array) ( $lock['metadata']['owner_context'] ?? array() ); + $database_rows[] = array( + 'source' => 'database', + 'lock_key' => $lock_key, + 'scope' => (string) ( $lock['scope'] ?? '' ), + 'state' => 'stale', + 'owner' => (string) ( $lock['owner'] ?? '' ), + 'session' => self::owner_context_session_id($owner_context), + 'run_id' => (string) ( $lock['run_id'] ?? '' ), + 'job_id' => $lock['job_id'] ?? null, + 'acquired_at' => (string) ( $lock['acquired_at'] ?? '' ), + 'heartbeat_at' => (string) ( $lock['heartbeat_at'] ?? '' ), + 'expires_at' => (string) ( $lock['expires_at'] ?? '' ), + 'age_seconds' => $lock['age_seconds'] ?? null, + 'heartbeat_age_seconds' => $lock['heartbeat_age_seconds'] ?? null, + 'expires_age_seconds' => $lock['expires_age_seconds'] ?? null, + 'live_flock_present' => $live_flock_present, + 'safe_to_prune' => ! $live_flock_present, + 'preview_command' => 'wp datamachine-code workspace worktree locks --prune-stale --dry-run --format=json', + 'apply_command' => 'wp datamachine-code workspace worktree locks --prune-stale --format=json', + 'active_lock_refusal_note' => $live_flock_present ? 'Matching filesystem lock has a live flock; DB row is reported but protected from stale pruning.' : '', + ); + } + + $filesystem_rows = array(); + foreach ( (array) ( $filesystem['locks'] ?? array() ) as $lock ) { + if ( ! is_array($lock) || 'stale' !== (string) ( $lock['state'] ?? '' ) ) { + continue; + } + $filesystem_rows[] = array( + 'source' => 'filesystem', + 'lock_key' => (string) ( $lock['lock_key'] ?? '' ), + 'scope' => (string) ( $lock['scope'] ?? '' ), + 'state' => 'stale', + 'path' => (string) ( $lock['path'] ?? '' ), + 'mtime' => $lock['mtime'] ?? null, + 'age_seconds' => $lock['age_seconds'] ?? null, + 'live_flock_present' => false, + 'safe_to_prune' => ! empty($lock['safe_to_prune']), + 'preview_command' => 'wp datamachine-code workspace worktree locks --prune-stale --dry-run --format=json', + 'apply_command' => 'wp datamachine-code workspace worktree locks --prune-stale --format=json', + ); + } + + $count = count($database_rows) + count($filesystem_rows); + return array( + 'count' => $count, + 'database_count' => count($database_rows), + 'filesystem_count' => count($filesystem_rows), + 'active_filesystem_keys' => $active_filesystem_keys, + 'preview_command' => 'wp datamachine-code workspace worktree locks --prune-stale --dry-run --format=json', + 'apply_command' => 'wp datamachine-code workspace worktree locks --prune-stale --format=json', + 'safety' => 'Preview is non-destructive. Apply prunes expired DB rows and old unlocked filesystem lock files only; live filesystem flocks are reported and protected.', + 'database' => $database_rows, + 'filesystem' => $filesystem_rows, + ); + } + + /** + * @param array $filesystem Filesystem lock status. + * @return array + */ + private static function active_filesystem_lock_keys( array $filesystem ): array { + return array_values( + array_filter( + array_map('strval', (array) ( $filesystem['active_keys'] ?? array() )), + static fn( string $key ): bool => '' !== $key + ) + ); + } + + /** + * @param array $owner_context Decoded DB lock owner context. + */ + private static function owner_context_session_id( array $owner_context ): string { + $runtime_ids = (array) ( $owner_context['runtime_ids'] ?? array() ); + foreach ( $runtime_ids as $entry ) { + if ( is_array($entry) && '' !== trim( (string) ( $entry['session_id'] ?? '' )) ) { + return (string) $entry['session_id']; + } + } + foreach ( $runtime_ids as $entry ) { + if ( ! is_array($entry) ) { + continue; + } + foreach ( $entry as $value ) { + if ( '' !== trim( (string) $value) ) { + return (string) $value; + } + } + } + + return ''; + } + /** * @return array */ diff --git a/tests/smoke-workspace-hygiene-report.php b/tests/smoke-workspace-hygiene-report.php index 293c78ac..758390cc 100644 --- a/tests/smoke-workspace-hygiene-report.php +++ b/tests/smoke-workspace-hygiene-report.php @@ -29,9 +29,11 @@ public static function get() mkdir($workspace_root . '/beta@missing-metadata', 0755, true); mkdir($workspace_root . '/gamma@eligible', 0755, true); mkdir($workspace_root . '/cache', 0755, true); + mkdir($workspace_root . '/.locks', 0755, true); file_put_contents($workspace_root . '/alpha/payload.bin', str_repeat('a', 1024)); file_put_contents($workspace_root . '/alpha@feat-one/payload.bin', str_repeat('b', 2048)); file_put_contents($workspace_root . '/cache/payload.bin', str_repeat('c', 3072)); + touch($workspace_root . '/.locks/worktree-stale.lock', time() - 172800); file_put_contents($workspace_root . '/not-a-dir.txt', 'ignored'); define('ABSPATH', __DIR__ . '/'); @@ -177,6 +179,12 @@ public function worktree_list( ?string $repo = null, ?string $state = null, arra datamachine_code_hygiene_report_assert(true === ( $minimal['remote_backend']['registered_state'] ?? false ), 'hygiene reports registered remote workspace state'); datamachine_code_hygiene_report_assert('remote_or_fallback' === ( $minimal['remote_backend']['mode'] ?? '' ), 'hygiene reports remote backend fallback mode'); datamachine_code_hygiene_report_assert(in_array('Remote workspace state is registered; local checkout commands should fall back to local workspace discovery when the remote backend misses a handle.', $minimal['notes'] ?? array(), true), 'hygiene notes remote backend local fallback expectation'); + datamachine_code_hygiene_report_assert(1 === (int) ( $minimal['locks']['stale_locks']['filesystem_count'] ?? 0 ), 'hygiene report surfaces stale filesystem locks'); + datamachine_code_hygiene_report_assert('wp datamachine-code workspace worktree locks --prune-stale --dry-run --format=json' === (string) ( $minimal['locks']['stale_locks']['preview_command'] ?? '' ), 'hygiene stale lock report includes exact prune preview command'); + datamachine_code_hygiene_report_assert('wp datamachine-code workspace worktree locks --prune-stale --format=json' === (string) ( $minimal['locks']['stale_locks']['apply_command'] ?? '' ), 'hygiene stale lock report includes exact prune apply command'); + $hygiene_stale_filesystem_rows = (array) ( $minimal['locks']['stale_locks']['filesystem'] ?? array() ); + datamachine_code_hygiene_report_assert(false === (bool) ( $hygiene_stale_filesystem_rows[0]['live_flock_present'] ?? true ), 'hygiene stale filesystem row reports no live flock'); + datamachine_code_hygiene_report_assert(true === (bool) ( $hygiene_stale_filesystem_rows[0]['safe_to_prune'] ?? false ), 'hygiene stale filesystem row is safe to prune'); echo "\n[1a] Size report exposes top offenders and kind grouping\n"; $with_sizes = $workspace->workspace_hygiene_report( diff --git a/tests/smoke-workspace-mutation-lock.php b/tests/smoke-workspace-mutation-lock.php index 90573286..1ec3ab74 100644 --- a/tests/smoke-workspace-mutation-lock.php +++ b/tests/smoke-workspace-mutation-lock.php @@ -131,10 +131,32 @@ public function get_row( string $sql, string $output ): ?array // phpcs:ignore return empty($rows) ? null : $rows[count($rows) - 1]; } + /** + * @return array> + */ + public function get_results( string $sql, string $output ): array // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + { + return $this->matching_rows($sql); + } + public function query( string $sql ): int // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed { $this->rows_affected = 0; - return 0; + $now = gmdate('Y-m-d H:i:s'); + if (str_starts_with(trim($sql), 'UPDATE') ) { + foreach ( $this->rows as $id => $row ) { + $lock_key = (string) ( $row['lock_key'] ?? '' ); + if ('active' !== (string) ( $row['status'] ?? '' ) || (string) ( $row['expires_at'] ?? '' ) >= $now ) { + continue; + } + if (str_contains($sql, 'lock_key NOT IN') && str_contains($sql, "'" . str_replace("'", "''", $lock_key) . "'") ) { + continue; + } + $this->rows[ $id ]['status'] = 'stale'; + $this->rows_affected++; + } + } + return $this->rows_affected; } public function prepare( string $query, mixed ...$args ): string @@ -161,6 +183,9 @@ static function ( array $row ) use ( $sql, $now ): bool { if (str_contains($sql, "status = 'active'") && 'active' !== $status ) { return false; } + if (str_contains($sql, "status = 'stale'") && 'stale' !== $status ) { + return false; + } if (str_contains($sql, "status = 'released'") ) { return 'released' === $status; } @@ -338,6 +363,67 @@ function () use ( $db_tmp ) { $assert(true, isset($db_data['active_lock']['retry_after_seconds']), 'busy failure includes retry-after seconds'); $assert(true, isset($db_data['active_lock']['age_seconds']), 'busy failure includes age seconds'); $assert(true, isset($db_data['active_lock']['metadata']['owner_context']), 'busy failure includes owner context metadata'); + + $active_row_id = array_key_last($GLOBALS['wpdb']->rows); + $GLOBALS['wpdb']->rows[ $active_row_id ]['expires_at'] = gmdate('Y-m-d H:i:s', time() - 3600); + $GLOBALS['wpdb']->rows[ $active_row_id ]['metadata_json'] = wp_json_encode( + array( + 'owner_context' => array( + 'runtime_ids' => array( + 'opencode' => array( + 'session_id' => 'ses-db-active', + ), + ), + ), + ) + ); + $GLOBALS['wpdb']->insert( + $GLOBALS['wpdb']->prefix . 'datamachine_code_locks', + array( + 'lock_key' => 'worktree-db-stale', + 'purpose' => 'workspace_repo_mutation', + 'scope' => 'db-stale', + 'owner' => 'owner:123', + 'run_id' => 'run-123', + 'job_id' => 456, + 'status' => 'active', + 'acquired_at' => gmdate('Y-m-d H:i:s', time() - 7200), + 'heartbeat_at' => gmdate('Y-m-d H:i:s', time() - 7100), + 'expires_at' => gmdate('Y-m-d H:i:s', time() - 3600), + 'released_at' => null, + 'metadata_json' => wp_json_encode( + array( + 'owner_context' => array( + 'runtime_ids' => array( + 'opencode' => array( + 'session_id' => 'ses-db-stale', + ), + ), + ), + ) + ), + ), + array() + ); + + $db_stale_status = \DataMachineCode\Workspace\WorkspaceMutationLock::status($db_tmp); + $db_stale_report = (array) ( $db_stale_status['stale_locks'] ?? array() ); + $assert(2, (int) ( $db_stale_report['database_count'] ?? 0 ), 'stale DB rows are included in stale lock report'); + $assert('wp datamachine-code workspace worktree locks --prune-stale --dry-run --format=json', (string) ( $db_stale_report['preview_command'] ?? '' ), 'stale lock report exposes exact prune preview command'); + $assert('wp datamachine-code workspace worktree locks --prune-stale --format=json', (string) ( $db_stale_report['apply_command'] ?? '' ), 'stale lock report exposes exact prune apply command'); + $db_rows = array_column((array) ( $db_stale_report['database'] ?? array() ), null, 'lock_key'); + $assert(true, (bool) ( $db_rows['worktree-demo']['live_flock_present'] ?? false ), 'expired DB row with live filesystem flock is reported as live'); + $assert(false, (bool) ( $db_rows['worktree-demo']['safe_to_prune'] ?? true ), 'expired DB row with live filesystem flock is not safe to prune'); + $assert('ses-db-active', (string) ( $db_rows['worktree-demo']['session'] ?? '' ), 'stale DB report includes owner session evidence'); + $assert(true, isset($db_rows['worktree-demo']['age_seconds']), 'stale DB report includes owner age'); + $assert(false, (bool) ( $db_rows['worktree-db-stale']['live_flock_present'] ?? true ), 'stale DB row without filesystem flock reports no live flock'); + $assert(true, (bool) ( $db_rows['worktree-db-stale']['safe_to_prune'] ?? false ), 'stale DB row without live flock is safe to prune'); + + $db_prune = \DataMachineCode\Workspace\WorkspaceMutationLock::prune_stale($db_tmp, false); + $assert(1, (int) ( $db_prune['database']['protected_active'] ?? 0 ), 'DB prune protects rows with live filesystem flocks'); + $assert(1, (int) ( $db_prune['database']['active_marked_stale'] ?? 0 ), 'DB prune marks only unprotected expired rows stale'); + $assert('active', (string) ( $GLOBALS['wpdb']->rows[ $active_row_id ]['status'] ?? '' ), 'DB prune refuses active filesystem lock row'); + if (! is_wp_error($db_first) ) { $db_first->release(); } diff --git a/tests/smoke-worktree-cleanup-cli.php b/tests/smoke-worktree-cleanup-cli.php index 443db4a0..f49e0024 100644 --- a/tests/smoke-worktree-cleanup-cli.php +++ b/tests/smoke-worktree-cleanup-cli.php @@ -770,6 +770,29 @@ public function execute( array $input ): array 'success' => true, 'run_id' => (string) ( $input['run_id'] ?? '' ), 'state' => 'planned', + 'locks' => array( + 'stale_locks' => array( + 'count' => 1, + 'database_count' => 1, + 'filesystem_count' => 0, + 'preview_command' => 'wp datamachine-code workspace worktree locks --prune-stale --dry-run --format=json', + 'apply_command' => 'wp datamachine-code workspace worktree locks --prune-stale --format=json', + 'safety' => 'Preview is non-destructive. Apply prunes expired DB rows and old unlocked filesystem lock files only; live filesystem flocks are reported and protected.', + 'database' => array( + array( + 'source' => 'database', + 'lock_key' => 'worktree-demo', + 'scope' => 'demo', + 'owner' => 'owner:123', + 'session' => 'ses-demo', + 'age_seconds' => 3600, + 'live_flock_present' => false, + 'safe_to_prune' => true, + ), + ), + 'filesystem' => array(), + ), + ), ); } } @@ -1090,6 +1113,18 @@ public function execute( array $input ): array $db_status_json = json_decode(WP_CLI::$logs[0] ?? '', true); datamachine_code_cleanup_assert('cleanup-run-20260504193024-abc123' === ( $cleanup_status_ability->last_input['run_id'] ?? '' ), 'DB cleanup run IDs are routed to cleanup status ability'); datamachine_code_cleanup_assert('planned' === ( $db_status_json['state'] ?? '' ), 'DB cleanup run status does not route to job-backed status parser'); + datamachine_code_cleanup_assert(1 === (int) ( $db_status_json['locks']['stale_locks']['database_count'] ?? 0 ), 'cleanup status JSON surfaces stale DB locks'); + datamachine_code_cleanup_assert('wp datamachine-code workspace worktree locks --prune-stale --dry-run --format=json' === (string) ( $db_status_json['locks']['stale_locks']['preview_command'] ?? '' ), 'cleanup status JSON includes exact stale lock preview command'); + datamachine_code_cleanup_assert('wp datamachine-code workspace worktree locks --prune-stale --format=json' === (string) ( $db_status_json['locks']['stale_locks']['apply_command'] ?? '' ), 'cleanup status JSON includes exact stale lock apply command'); + datamachine_code_cleanup_assert('ses-demo' === (string) ( $db_status_json['locks']['stale_locks']['database'][0]['session'] ?? '' ), 'cleanup status JSON includes stale DB lock session'); + datamachine_code_cleanup_assert(3600 === (int) ( $db_status_json['locks']['stale_locks']['database'][0]['age_seconds'] ?? 0 ), 'cleanup status JSON includes stale DB lock age'); + + WP_CLI::$logs = array(); + WP_CLI::$successes = array(); + $command->cleanup(array( 'status', 'cleanup-run-20260504193024-abc123' ), array()); + datamachine_code_cleanup_assert(in_array('Stale workspace locks:', WP_CLI::$logs, true), 'human cleanup status renders stale lock follow-up section'); + datamachine_code_cleanup_assert(in_array('Preview: wp datamachine-code workspace worktree locks --prune-stale --dry-run --format=json', WP_CLI::$logs, true), 'human cleanup status renders exact prune preview command'); + datamachine_code_cleanup_assert(in_array('Apply: wp datamachine-code workspace worktree locks --prune-stale --format=json', WP_CLI::$logs, true), 'human cleanup status renders exact prune apply command'); WP_CLI::$logs = array(); WP_CLI::$successes = array();