# 404-solution/4.2.0/includes/ViewBuildHostFailurePolicy.php

404 Solution, version 4.2.0. 647 lines.

- Page: https://pluginprobe.com/plugins/404-solution/4.2.0/code/includes/ViewBuildHostFailurePolicy.php
- Raw: https://pluginprobe.com/plugins/404-solution/4.2.0/raw/includes/ViewBuildHostFailurePolicy.php
- Modified: 2026-05-24T08:07:28+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/404-solution/4.2.0/code/includes/ViewBuildHostFailurePolicy.php#L10-L20`.

```php
<?php

if (!defined('ABSPATH')) {
    exit;
}

/**
 * Host-failure policy + degraded-build state for the staged view-build pipeline.
 *
 * Triggered when a stage callback raises an error the classifier identifies
 * as a permanent host-side environmental constraint (access denied, read-only,
 * disk-full, quota) rather than a resumable kill. The orchestrator uses this
 * trait to:
 *
 *   1. Mark optional stages permanently skipped (S3 indexes, S9 hits aggregate,
 *      S10 sort indexes) so subsequent cron ticks do NOT re-attempt the same
 *      denied DDL forever (gastroinovace.cz: 60 wasted attempts in 3 days).
 *
 *   2. Mark the build halted when a critical stage (S1/S2/S4-S8/S11) hits a
 *      permanent host failure, so the build does not loop on unrecoverable
 *      errors. The halt is dedup-windowed via transient (24h); a force
 *      rebuild explicitly clears it so admin can retry after fixing the
 *      host config.
 *
 *   3. Surface ONE admin notice per failure type per 24h on the plugin's
 *      own admin screen (`abj404_solution`). Per CLAUDE.md self-healing
 *      reliability rules: never email, never wp-admin-wide banner.
 *
 *   4. Track floor-kill streaks on batched stages: when a stage's batch
 *      is killed by the host while the adaptive shrink is already at
 *      VIEW_BUILD_MIN_BATCH_SIZE, the host cannot finish the plugin's
 *      smallest unit of work; halt rather than loop forever.
 *
 * Skip markers persist across normal invalidations (redirect edits) but
 * are cleared by an explicit force rebuild and on plugin reactivation.
 * Stored as standalone WP options outside the staged-build progress option
 * registry: a redirect-edit watermark bump must NOT clear them or every
 * redirect edit would re-arm the same denied DDL on the next cron tick.
 *
 * @property ABJ_404_Solution_DatabaseCore $dbCore
 * @property ABJ_404_Solution_Functions $f
 * @property ABJ_404_Solution_Logging $logger
 * @property ABJ_404_Solution_ViewReadService|null $viewReadService
 * @property ABJ_404_Solution_LogsRepository|null $logsRepo
 * @property int $stagedQueryTimeoutSeconds
 * @property string $lastBatchProgressDetail
 * @property bool $viewBuildStageOpenForShutdown
 * @property int $viewBuildShutdownStageNumber
 * @property string $viewBuildShutdownStageKey
 * @property bool|null $namedLockSupportedThisRequest
 * @property bool $fallbackLockLoggedThisRequest
 * @property bool $usingTransientFallbackLock
 * @property string $lastNamedLockUnsupportedReason
 * @property string $lastNamedLockUnsupportedError
 * @method void abortStagedBuildForMutationWatermarkAdvance(...$arguments)
 * @method bool acquireTransientFallbackLock(...$arguments)
 * @method bool acquireViewBuildLock(...$arguments)
 * @method string activeBuildStartedWatermarkOptionName(...$arguments)
 * @method bool adminMutationGateBlocks(...$arguments)
 * @method array<mixed> advanceViewBuildOnce(...$arguments)
 * @method void assertBuildBufferExistsOrHalt(...$arguments)
 * @method ?bool attemptRelaxSqlModeForBuildConnection(...$arguments)
 * @method bool bufferIntegrityPassesForPromote(...$arguments)
 * @method string buildHaltTransientKey(...$arguments)
 * @method string buildViewDoneCountQuery(...$arguments)
 * @method string builtWatermarkOptionName(...$arguments)
 * @method int bumpMutationWatermark(...$arguments)
 * @method int bumpStageNoProgressStreak(...$arguments)
 * @method string capturedPrefixForLog(...$arguments)
 * @method void capturePrefixAtBuildStart(...$arguments)
 * @method void claimForegroundViewBuildLease(...$arguments)
 * @method string classifyAndHandleStageFailure(...$arguments)
 * @method array<mixed> classifySessionVariableWarnings(...$arguments)
 * @method string classifyStageFailure(...$arguments)
 * @method void clearActiveBuildStartedWatermark(...$arguments)
 * @method void clearAdminMutationGateOptions(...$arguments)
 * @method void clearAllProgressOptions(...$arguments)
 * @method void clearPhpEnvironmentProbeCache(...$arguments)
 * @method void clearPrefixAtStageOne(...$arguments)
 * @method void clearSessionVariablesProbeCache(...$arguments)
 * @method void clearSqlModeProbeCache(...$arguments)
 * @method void clearStagedBuildDegradedState(...$arguments)
 * @method void clearViewBuildOpenStageForShutdown(...$arguments)
 * @method void clearViewDoneHardStaleNotice(...$arguments)
 * @method ABJ_404_Solution_Clock clock(...$arguments)
 * @method int countLiveRedirects(...$arguments)
 * @method int countViewBuildRows(...$arguments)
 * @method string describeBuildProgressForNotice(...$arguments)
 * @method string describeDegradedNotice(...$arguments)
 * @method string describeStagedSqlFailure(...$arguments)
 * @method array<mixed> detectAndAdjustSqlMode(...$arguments)
 * @method float detectHostStagedQueryLimitSeconds(...$arguments)
 * @method string doTableNameReplacements(...$arguments)
 * @method void dropDeletemeTable(...$arguments)
 * @method void dropTransientBuffersIfPresent(...$arguments)
 * @method void dropTransientStagedTables(...$arguments)
 * @method void ensureConnection(...$arguments)
 * @method void ensureFallbackLockNoticeAndLog(...$arguments)
 * @method int extendedTimeoutForKilledNonBatchedStage(...$arguments)
 * @method array<mixed> fetchSessionVariablesRowOrEmpty(...$arguments)
 * @method string filesystemEnvironmentProbeOptionName(...$arguments)
 * @method bool forceRestartViewBuild(...$arguments)
 * @method bool foregroundViewBuildLeaseActive(...$arguments)
 * @method string formatPhpMemoryBytesHuman(...$arguments)
 * @method bool gateAbortIfMutationWatermarkAdvanced(...$arguments)
 * @method string getColumnCollationString(...$arguments)
 * @method int getCronStuckHours(...$arguments)
 * @method string getLowercasePrefix(...$arguments)
 * @method array<string, mixed> getViewBuildProgress(...$arguments)
 * @method array<mixed> getViewBuildProgressFingerprint(...$arguments)
 * @method int getViewDoneBuiltAtTimestamp(...$arguments)
 * @method bool haltIfPrefixChangedSinceStageOne(...$arguments)
 * @method string humanBatchProgress(...$arguments)
 * @method float intelligentStagedQueryTimeoutSeconds(...$arguments)
 * @method void invalidateViewDoneServeableCache(...$arguments)
 * @method bool isBuildHaltedForHostFailure(...$arguments)
 * @method bool isCurrentStageOptionName(...$arguments)
 * @method bool isNamedLockUnsupportedError(...$arguments)
 * @method bool isResumableStagedKill(...$arguments)
 * @method bool isStageMarkedSkipped(...$arguments)
 * @method bool isTransientConnectionError(...$arguments)
 * @method string lastBuildStartedWatermarkOptionName(...$arguments)
 * @method string legacyStartedWatermarkOptionName(...$arguments)
 * @method string localizeOrDefaultViewBuildNotice(...$arguments)
 * @method bool logsHitsTableExists(...$arguments)
 * @method void logTimedViewBuildStage(...$arguments)
 * @method void logViewBuildProgressOptionWrite(...$arguments)
 * @method void logViewBuildShutdownDiagnostics(...$arguments)
 * @method void markBuildHaltedForHostFailure(...$arguments)
 * @method void markBuildStage(...$arguments)
 * @method void markStageSkippedForHostFailure(...$arguments)
 * @method void markViewBuildStageCompleted(...$arguments)
 * @method void markViewBuildStageStarted(...$arguments)
 * @method void markViewDoneBuildCompleted(...$arguments)
 * @method void markViewDoneInvalidatedByAdminMutation(...$arguments)
 * @method int maxBuildBufferId(...$arguments)
 * @method void maybeRaiseViewDoneHardStaleNotice(...$arguments)
 * @method bool mutationWatermarkAdvancedSinceBuildStart(...$arguments)
 * @method int mutationWatermarkObservedByAdminAction(...$arguments)
 * @method int mutationWatermarkObservedByAdminActionAt(...$arguments)
 * @method string mutationWatermarkObservedByAdminActionAtOptionName(...$arguments)
 * @method string mutationWatermarkObservedByAdminActionOptionName(...$arguments)
 * @method string normalizePathPrefix(...$arguments)
 * @method bool optionReadBackMatches(...$arguments)
 * @method int parsePhpMemoryLimitToBytes(...$arguments)
 * @method bool pathFallsWithinAny(...$arguments)
 * @method void performFreshStartCleanup(...$arguments)
 * @method array<mixed> phpDisabledFunctionsList(...$arguments)
 * @method string phpEnvironmentProbeOptionName(...$arguments)
 * @method float phpTimeRemainingSeconds(...$arguments)
 * @method string prefixAtStageOneOptionName(...$arguments)
 * @method array<mixed> probeFilesystemEnvironmentForBuild(...$arguments)
 * @method float probeFloatFromValues(...$arguments)
 * @method int probeIntFromValues(...$arguments)
 * @method int probeMemoryLimitForS9(...$arguments)
 * @method array<mixed> probePhpEnvironmentForBuild(...$arguments)
 * @method array<mixed> probeSessionVariablesAtS1Entry(...$arguments)
 * @method bool probeSetTimeLimitAvailability(...$arguments)
 * @method array<mixed> probeSqlModeForBuild(...$arguments)
 * @method string probeStringFromValues(...$arguments)
 * @method string progressOptionName(...$arguments)
 * @method void publishBuiltWatermarkFromActiveBuildStartedWatermark(...$arguments)
 * @method array<mixed> queryAndGetResults(...$arguments)
 * @method int readActiveBuildStartedWatermark(...$arguments)
 * @method array<int, array<string, mixed>> readFromViewDone(...$arguments)
 * @method int readProgressOption(...$arguments)
 * @method int readWatermarkOption(...$arguments)
 * @method void rebuildViewDoneInBackground(...$arguments)
 * @method bool reconcilePostStageElevenState(...$arguments)
 * @method string reconcileStagedTablesAtRunnerStartup(...$arguments)
 * @method int recordStageBatchKilled(...$arguments)
 * @method void registerViewBuildShutdownDiagnostics(...$arguments)
 * @method bool releaseAndReacquireBetweenStages(...$arguments)
 * @method void releaseViewBuildLock(...$arguments)
 * @method void resetStageNoProgressStreak(...$arguments)
 * @method string resolveColumnCollationForStagedBuild(...$arguments)
 * @method void runForceRestartCleanupInsideLock(...$arguments)
 * @method bool runIdRangeBatchedUpdate(...$arguments)
 * @method int runInsertBatch(...$arguments)
 * @method mixed runNonBatchedStageWithKillStreakEscape(...$arguments)
 * @method array{ran: bool, reason: string, progress: array<string, mixed>} runPageLoadFallbackAdvance(...$arguments)
 * @method int runRedirectsForViewCountStaged(...$arguments)
 * @method array<int, array<string, mixed>> runRedirectsForViewStaged(...$arguments)
 * @method bool runS11SwapWithPreRenameWatermarkRecheck(...$arguments)
 * @method bool runStagedBuildOnce(...$arguments)
 * @method bool runStagedBuildStages6Through11(...$arguments)
 * @method void runStagedSqlFile(...$arguments)
 * @method void runStagedSqlFileTolerantOfDuplicateKey(...$arguments)
 * @method mixed runTimedViewBuildStage(...$arguments)
 * @method int safeCurrentMutationWatermark(...$arguments)
 * @method string sanitizeUrlBeforeInsert(...$arguments)
 * @method void scheduleViewDoneRebuild(...$arguments)
 * @method string sessionVariablesProbeOptionName(...$arguments)
 * @method void setFilesystemEnvAdminNotice(...$arguments)
 * @method void setLowMemoryLimitAdminNotice(...$arguments)
 * @method void setSessionEnvAdminNotice(...$arguments)
 * @method void setStagedBuildDegradedNotice(...$arguments)
 * @method void setStagedBuildHaltNotice(...$arguments)
 * @method void setViewBuildCronStuckNotice(...$arguments)
 * @method void setViewBuildScheduleFailedNotice(...$arguments)
 * @method void setViewDoneHardStaleNotice(...$arguments)
 * @method array<mixed> splitOpenBasedirPaths(...$arguments)
 * @method string sqlModeProbeOptionName(...$arguments)
 * @method void stageAddPreJoinIndexes(...$arguments)
 * @method void stageAddSortIndexes(...$arguments)
 * @method void stageCreateBuildTable(...$arguments)
 * @method array<string, mixed> stagedQueryOptions(...$arguments)
 * @method bool stagedTableExists(...$arguments)
 * @method bool stageInsertRedirectsBatched(...$arguments)
 * @method string stageNoProgressStreakOptionName(...$arguments)
 * @method void stageRenameSwap(...$arguments)
 * @method string stageSkipOptionName(...$arguments)
 * @method void stageUpdateExternal(...$arguments)
 * @method void stageUpdateHits(...$arguments)
 * @method void stageUpdateHome(...$arguments)
 * @method bool stageUpdatePostsBatched(...$arguments)
 * @method void stageUpdateSpecial(...$arguments)
 * @method bool stageUpdateTermsBatched(...$arguments)
 * @method void stampStartedWatermarksAtS1Entry(...$arguments)
 * @method void sweepStaleRebuildTransients(...$arguments)
 * @method string transientFallbackLockOptionName(...$arguments)
 * @method bool verifyBuildLockSerializesWriter(...$arguments)
 * @method bool verifyOptionWriteCoherent(...$arguments)
 * @method bool verifyPrefixUnchangedSinceStageOne(...$arguments)
 * @method int viewBuildBatchSize(...$arguments)
 * @method int viewBuildBatchSizeForStage(...$arguments)
 * @method array<mixed> viewBuildOnlyTranslations(...$arguments)
 * @method float viewBuildPerStageBudgetSeconds(...$arguments)
 * @method string viewBuildTableName(...$arguments)
 * @method string viewDeletemeTableName(...$arguments)
 * @method int viewDoneBuiltAt(...$arguments)
 * @method int viewDoneBuiltWatermark(...$arguments)
 * @method int viewDoneDataBuiltAt(...$arguments)
 * @method string viewDoneDataBuiltAtOptionName(...$arguments)
 * @method string viewDoneFreshnessOptionName(...$arguments)
 * @method bool viewDoneHasRows(...$arguments)
 * @method bool viewDoneIsFresh(...$arguments)
 * @method bool viewDoneIsServeable(...$arguments)
 * @method int viewDoneMutationInvalidatedAt(...$arguments)
 * @method string viewDoneMutationInvalidatedAtOptionName(...$arguments)
 * @method bool viewDoneTableExists(...$arguments)
 * @method string viewDoneTableName(...$arguments)
 * @method void writeProgressOption(...$arguments)
 * @method void writeWatermarkOption(...$arguments)
 */
class ABJ_404_Solution_ViewBuildHostFailurePolicy extends ABJ_404_Solution_ViewBuildCollaborator {

    /**
     * @param int $stageNumber
     * @return string  Site-prefixed option name for the stage skip marker.
     */
    public function stageSkipOptionName(int $stageNumber): string {
        return $this->getLowercasePrefix() . 'abj404_view_build_s' . $stageNumber . '_skipped';
    }

    /**
     * Read whether the named stage is permanently skipped on this site.
     *
     * @param int $stageNumber
     * @return bool
     */
    public function isStageMarkedSkipped(int $stageNumber): bool {
        if (!function_exists('get_option')) {
            return false;
        }
        $value = get_option($this->stageSkipOptionName($stageNumber), 0);
        return is_scalar($value) && intval($value) > 0;
    }

    /**
     * Mark the named stage permanently skipped due to a host-side
     * environmental constraint and surface a deduplicated admin notice.
     * Idempotent: multiple calls with the same stage number write the
     * same marker and reset the notice TTL.
     *
     * @param int    $stageNumber
     * @param string $errorText  Original $wpdb->last_error / exception message.
     * @return void
     */
    public function markStageSkippedForHostFailure(int $stageNumber, string $errorText): void {
        if (function_exists('update_option')) {
            update_option($this->stageSkipOptionName($stageNumber), $this->clock()->now(), false);
        }
        $this->setStagedBuildDegradedNotice($stageNumber, 'skipped', $errorText);
        $this->logger->warn(sprintf(
            '[staged] stage %d permanently skipped (host-side environmental '
            . 'constraint, will not retry until force rebuild). Reason: %s',
            $stageNumber,
            substr($errorText, 0, 240)
        ));
    }

    /**
     * Mark the build halted at the named critical stage. The orchestrator
     * checks isBuildHaltedForHostFailure() at entry and skips the run, so
     * cron ticks during the dedup window are no-ops rather than retrying
     * the same denied DDL forever.
     *
     * @param int    $stageNumber
     * @param string $errorText
     * @return void
     */
    public function markBuildHaltedForHostFailure(int $stageNumber, string $errorText): void {
        if (function_exists('set_transient')) {
            // allow-cache-empty: host-failure halt marker intentionally stores error context, not query data.
            set_transient(
                $this->buildHaltTransientKey(),
                array(
                    'stage' => $stageNumber,
                    'error' => $errorText,
                    'when'  => $this->clock()->now(),
                ),
                ABJ_404_Solution_ViewBuildConfig::VIEW_BUILD_DEGRADED_NOTICE_TTL_SECONDS
            );
        }
        $this->setStagedBuildDegradedNotice($stageNumber, 'halted', $errorText);
        $this->logger->warn(sprintf(
            '[staged] critical stage %d halted (host-side environmental '
            . 'constraint, will not retry until force rebuild or 24h dedup '
            . 'window expires). Reason: %s',
            $stageNumber,
            substr($errorText, 0, 240)
        ));
    }

    /**
     * @return string  Transient key for the build-halted gate.
     */
    public function buildHaltTransientKey(): string {
        return 'abj404_view_build_halted';
    }

    /**
     * True if a prior tick halted the build for a permanent host failure
     * and the dedup window has not yet expired. The advance entry point
     * checks this so it does NOT re-run a build the host cannot finish,
     * avoiding the same waste pattern that motivated the original fix
     * (60 identical access-denied errors in 3 days at gastroinovace.cz).
     *
     * @return bool
     */
    public function isBuildHaltedForHostFailure(): bool {
        if (!function_exists('get_transient')) {
            return false;
        }
        $value = get_transient($this->buildHaltTransientKey());
        return is_array($value);
    }

    /**
     * Surface a deduplicated admin notice for a degraded-build event.
     * Stored as a transient on the plugin's own notice channel so the
     * admin Redirects screen can render it; falls back to a long-lived
     * option when no transient API is available.
     *
     * Notice keys are descriptive on purpose so the matching test seam
     * (StagedBuildPermanentFailureDegradesTest) can verify the right
     * notice fired without coupling to internal hash details.
     *
     * @param int    $stageNumber
     * @param string $kind        'skipped' or 'halted'.
     * @param string $errorText
     * @return void
     */
    public function setStagedBuildDegradedNotice(int $stageNumber, string $kind, string $errorText): void {
        $key = sprintf(
            'abj404_view_build_s%d_%s_notice',
            $stageNumber,
            $kind === 'halted' ? 'halted' : 'skipped'
        );
        $payload = array(
            'stage'   => $stageNumber,
            'kind'    => $kind,
            'error'   => $errorText,
            'message' => $this->describeDegradedNotice($stageNumber, $kind, $errorText),
            'when'    => $this->clock()->now(),
        );
        if (function_exists('set_transient')) {
            // allow-cache-empty: degraded-state notice marker intentionally stores diagnostics, not query data.
            set_transient(
                $key,
                $payload,
                ABJ_404_Solution_ViewBuildConfig::VIEW_BUILD_DEGRADED_NOTICE_TTL_SECONDS
            );
        } elseif (function_exists('update_option')) {
            update_option($key, $payload, false);
        }
    }

    /**
     * Set a halt notice for a non-stage failure (e.g. floor-kill streak
     * detection). Same dedup TTL as setStagedBuildDegradedNotice() but
     * with a descriptive scenario key so the admin can tell why the
     * build halted.
     *
     * @param string $scenarioKey  e.g. 's2_floor_kill_streak'.
     * @param string $errorText
     * @return void
     */
    public function setStagedBuildHaltNotice(string $scenarioKey, string $errorText): void {
        $key = 'abj404_view_build_' . $scenarioKey . '_halt_notice';
        $payload = array(
            'scenario' => $scenarioKey,
            'kind'     => 'halted',
            'error'    => $errorText,
            'when'     => $this->clock()->now(),
        );
        if (function_exists('set_transient')) {
            // allow-cache-empty: scenario halt marker intentionally stores diagnostics, not query data.
            set_transient(
                $key,
                $payload,
                ABJ_404_Solution_ViewBuildConfig::VIEW_BUILD_DEGRADED_NOTICE_TTL_SECONDS
            );
        } elseif (function_exists('update_option')) {
            update_option($key, $payload, false);
        }
    }

    /**
     * Build a user-facing message describing the degraded build event
     * and the host-side action the admin needs to take. Specificity
     * matters: a vague "view build degraded" notice with no remediation
     * path is exactly the silent-error pattern CLAUDE.md prohibits.
     *
     * @param int    $stageNumber
     * @param string $kind         'skipped' or 'halted'.
     * @param string $errorText
     * @return string
     */
    public function describeDegradedNotice(int $stageNumber, string $kind, string $errorText): string {
        $errorSnippet = substr(trim($errorText), 0, 200);
        $base = $kind === 'halted'
            ? sprintf('The 404 Solution view-build pipeline halted at stage %d/11.', $stageNumber)
            : sprintf('The 404 Solution view-build pipeline skipped optional stage %d/11.', $stageNumber);

        $hint = '';
        if (stripos($errorText, 'create temporary') !== false || stripos($errorText, "to database '") !== false
            || ($stageNumber === 9 && stripos($errorText, 'access denied') !== false)) {
            $hint = ' Ask your host to grant the CREATE TEMPORARY TABLES privilege to your WordPress database user '
                . 'so the hits aggregate column can be populated.';
        } elseif (stripos($errorText, 'alter command denied') !== false) {
            $hint = ' Ask your host to grant the ALTER privilege to your WordPress database user.';
        } elseif (stripos($errorText, 'rename') !== false || $stageNumber === 11) {
            $hint = ' Ask your host to grant ALTER + DROP + CREATE on the database used by WordPress so the '
                . 'view-build swap can complete.';
        } elseif (stripos($errorText, 'access denied') !== false || stripos($errorText, 'command denied') !== false) {
            $hint = ' Ask your host to review your WordPress database user privileges.';
        }

        return $base . $hint . ' Original error: ' . $errorSnippet;
    }

    /**
     * Clear all skip markers + halt gate. Called from the explicit force
     * rebuild path so an admin who has fixed their host configuration
     * can retry the previously denied stages. NOT called from the
     * source-mutation watermark bump path (regular redirect-edit
     * invalidation): every redirect change would otherwise re-arm a
     * denied DDL on the next cron tick, undoing the entire
     * skip-persistence contract.
     *
     * @return void
     */
    public function clearStagedBuildDegradedState(): void {
        if (function_exists('delete_option')) {
            for ($s = 1; $s <= 11; $s++) {
                delete_option($this->stageSkipOptionName($s));
            }
        }
        if (function_exists('delete_transient')) {
            delete_transient($this->buildHaltTransientKey());
        }
        // A force rebuild explicitly restarts the pipeline; the captured
        // prefix is per-build, not per-host, so wipe it so the fresh S1
        // re-captures from the (presumably correct) current $wpdb->prefix.
        $this->clearPrefixAtStageOne();
    }

    /**
     * Classify a stage exception and apply side effects (skip / halt /
     * streak). Called from the catch block inside runTimedViewBuildStage()
     * so the orchestrator stays focused on stage sequencing.
     *
     * Returns one of:
     *   - 'resumable_yield': caller should yield the stage (return false).
     *   - 'skipped'        : caller should record stage as skipped.
     *   - 'halted'         : caller should bail out of the build.
     *   - 'completed'      : post-S11 reconciliation succeeded; treat
     *                         as completion (return null).
     *   - 'rethrow'        : programmer-class or unknown error; caller
     *                         should rethrow so the dev mailbox carries
     *                         actionable context.
     *
     * The 'resumable_yield' branch also bumps the per-stage no-progress
     * streak; if the streak reaches
     * VIEW_BUILD_FLOOR_KILL_STREAK_HALT_THRESHOLD it converts to 'halted'
     * with a host_unfit notice. That is what stops the test pattern where
     * a stage is killed every tick and the build never converges.
     *
     * @param int    $stageNumber
     * @param string $stageKey
     * @param string $errMsg
     * @param float  $started
     * @return string
     */
    public function classifyAndHandleStageFailure(int $stageNumber, string $stageKey, string $errMsg, float $started): string {
        $classification = $this->classifyStageFailure($stageNumber, $errMsg);
        if ($classification === 'resumable') {
            $streak = $this->bumpStageNoProgressStreak($stageNumber);
            if ($streak >= ABJ_404_Solution_ViewBuildConfig::VIEW_BUILD_FLOOR_KILL_STREAK_HALT_THRESHOLD) {
                $this->setStagedBuildHaltNotice('floor_kill_streak', sprintf(
                    'stage %d: %d consecutive resumable kills with no progress (host_unfit). %s',
                    $stageNumber, $streak, substr($errMsg, 0, 200)
                ));
                $this->markBuildHaltedForHostFailure(
                    $stageNumber,
                    'floor_kill_streak (host_unfit): stage ' . $stageNumber
                    . ' killed ' . $streak . ' consecutive ticks: ' . substr($errMsg, 0, 200)
                );
                $this->logTimedViewBuildStage($stageNumber, $stageKey, 'halted_floor_kill_streak', $started);
                return 'halted';
            }
            $this->logTimedViewBuildStage($stageNumber, $stageKey, 'killed_resumable', $started);
            return 'resumable_yield';
        }
        if ($classification === 'skip') {
            $this->markStageSkippedForHostFailure($stageNumber, $errMsg);
            $this->logTimedViewBuildStage($stageNumber, $stageKey, 'skipped_host_failure', $started);
            return 'skipped';
        }
        if ($classification === 'halt') {
            if ($stageNumber === 11 && $this->reconcilePostStageElevenState()) {
                // RENAME committed server-side, error was a connection
                // artifact. Treat as success.
                $this->logTimedViewBuildStage($stageNumber, $stageKey, 'completed_after_reconcile', $started);
                return 'completed';
            }
            $this->markBuildHaltedForHostFailure($stageNumber, $errMsg);
            $this->logTimedViewBuildStage($stageNumber, $stageKey, 'halted_host_failure', $started);
            return 'halted';
        }
        return 'rethrow';
    }

    /**
     * Increment the per-stage consecutive no-progress kill streak. Called
     * when a resumable-kill error fires before any forward progress is
     * observed in the tick. Returns the new streak value so the caller
     * can decide whether the floor-kill halt threshold has been reached.
     *
     * Inlined option access (not via writeProgressOption) so the streak
     * tracking does not require the helpers trait, which keeps the
     * trait composable in test contexts that do not pull the full DAO.
     *
     * @param int $stageNumber  1..11
     * @return int  New streak value, or 0 when option API is unavailable.
     */
    public function bumpStageNoProgressStreak(int $stageNumber): int {
        if (!function_exists('get_option') || !function_exists('update_option')) {
            return 0;
        }
        $optName = $this->stageNoProgressStreakOptionName($stageNumber);
        if ($optName === '') {
            return 0;
        }
        $current = get_option($optName, 0);
        $next = (is_scalar($current) ? max(0, intval($current)) : 0) + 1;
        update_option($optName, $next, false);
        return $next;
    }

    /**
     * Reset the per-stage no-progress kill streak. Called whenever a
     * stage tick finishes without a resumable-kill exception so
     * legitimate slow stages do not eventually accumulate enough strikes
     * to trip the halt.
     *
     * @param int $stageNumber
     * @return void
     */
    public function resetStageNoProgressStreak(int $stageNumber): void {
        if (!function_exists('update_option')) {
            return;
        }
        $optName = $this->stageNoProgressStreakOptionName($stageNumber);
        if ($optName !== '') {
            update_option($optName, 0, false);
        }
    }

    /**
     * Build the site-prefixed option name for the per-stage no-progress
     * streak counter. Falls back to a fixed prefix when getLowercasePrefix()
     * is not composed.
     *
     * @param int $stageNumber
     * @return string  Empty when the stage is outside 1..11.
     */
    public function stageNoProgressStreakOptionName(int $stageNumber): string {
        if ($stageNumber < 1 || $stageNumber > 11) {
            return '';
        }
        $prefix = $this->getLowercasePrefix();
        return $prefix . 'abj404_view_build_s' . $stageNumber . '_no_progress';
    }

    /**
     * Reconcile post-S11 state when a RENAME swap raised an error AFTER
     * the rename committed but the client lost the connection (Codex
     * finding #3 in StagedBuildPermanentFailureDegradesTest). RENAME TABLE
     * is atomic on the server; if view_done now exists with the buffer's
     * row count and view_build is gone, the swap actually succeeded and
     * the error was a connection-level artifact, not a real failure.
     *
     * Returns true when reconciliation finds the swap committed (caller
     * treats as success: write freshness, clear progress, return ready).
     * Returns false when view_done is genuinely missing or partial; the
     * caller falls through to the regular failure handling.
     *
     * Intentionally tolerant on probe failures: when SHOW TABLES errors
     * out, we cannot verify either way, so we conservatively report
     * "not reconciled" and let the next tick retry.
     *
     * @return bool
     */
    public function reconcilePostStageElevenState(): bool {
        $viewDoneTable  = $this->viewDoneTableName();
        $viewBuildTable = $this->viewBuildTableName();

        if (!$this->stagedTableExists($viewDoneTable)) {
            return false;
        }
        if ($this->stagedTableExists($viewBuildTable)) {
            return false;
        }
        // RENAME swap committed: view_done exists, view_build was renamed
        // away. Treat as success even though the request flow saw an error.
        if (function_exists('update_option')) {
            update_option($this->viewDoneFreshnessOptionName(), $this->clock()->now(), false);
        }
        $this->clearAllProgressOptions();
        $this->invalidateViewDoneServeableCache();
        return true;
    }
}

```
