scripts/internal/bootstrap/HookJournalAccessor.ps1

<#
.SYNOPSIS
  Read the advisory bootstrap journal (JSONL) the SessionBootstrapManager appends.
.DESCRIPTION
  Resource accessor (IDesign): reads the one-record-per-bootstrap JSONL journal so tests and
  tooling can assert SC-007 - that each bootstrap mode (full | welcome-back | cleared-anchor)
  and signal (handover_valid, concurrent_session, dedupe_key) produces a DISTINGUISHABLE record.
  Fail open: a missing journal or an unparseable line yields no record, never an error. Feature 174
  (SC-007, observability decision 2).
#>


function Get-SpecrewBootstrapJournal {
    [CmdletBinding()]
    [OutputType([object[]])]
    param([Parameter(Mandatory)][string] $JournalPath)
    if (-not (Test-Path -LiteralPath $JournalPath)) { return @() }
    $records = New-Object System.Collections.Generic.List[object]
    foreach ($line in (Get-Content -LiteralPath $JournalPath)) {
        if ([string]::IsNullOrWhiteSpace($line)) { continue }
        try { $records.Add(($line | ConvertFrom-Json)) } catch { $null = $_ }
    }
    return , $records.ToArray()
}

function Add-SpecrewBootstrapJournalRecord {
    <#
    .SYNOPSIS
      Append one bootstrap record without losing a concurrent provider fire.
    .DESCRIPTION
      Atomically creates a short-lived sidecar claim, seeks the journal to EOF, and writes one UTF-8
      JSONL record. CreateNew is the same cross-platform single-winner primitive used by the render
      claim. A competing writer retries within one short bounded allowance with per-process jitter so
      simultaneous providers do not remain phase-aligned and starve one contender. A claim older than
      the stale threshold is reclaimed by atomic rename. The journal is advisory, so exhaustion or an
      unexpected I/O failure returns false instead of blocking or failing the host bootstrap.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory)][string] $JournalPath,
        [Parameter(Mandatory)][psobject] $Record,
        [Parameter()][ValidateRange(1, 1000)][int] $RetryCount = 120,
        [Parameter()][ValidateRange(0, 1000)][int] $RetryDelayMilliseconds = 25,
        [Parameter()][ValidateRange(1, 3600)][int] $StaleClaimSeconds = 30
    )

    try {
        $directory = Split-Path -Parent $JournalPath
        if ($directory -and -not (Test-Path -LiteralPath $directory -PathType Container)) {
            $null = New-Item -ItemType Directory -Path $directory -Force
        }

        $claimPath = "$JournalPath.append.lock"
        $claimStream = $null
        $ownsClaim = $false
        for ($attempt = 0; $attempt -lt $RetryCount; $attempt++) {
            try {
                $claimStream = [System.IO.File]::Open(
                    $claimPath,
                    [System.IO.FileMode]::CreateNew,
                    [System.IO.FileAccess]::Write,
                    [System.IO.FileShare]::None
                )
                $claimBytes = [System.Text.UTF8Encoding]::new($false).GetBytes(
                    "$PID|$([DateTime]::UtcNow.ToString('o'))"
                )
                $claimStream.Write($claimBytes, 0, $claimBytes.Length)
                $claimStream.Flush()
                $ownsClaim = $true
                break
            }
            catch [System.IO.IOException] {
                # A killed owner can leave a claim behind. Only a sufficiently old path is eligible, and
                # atomic rename elects one reclaimer. On Windows a live owner's open handle rejects the move;
                # on POSIX the age threshold prevents renaming a normal millisecond-scale live append.
                try {
                    if ([System.IO.File]::Exists($claimPath)) {
                        # The path can disappear between these two calls when its owner completes. File's static
                        # timestamp reader is race-safe here (it returns the platform sentinel, never emits a
                        # PowerShell error record that would poison a background job's result stream).
                        $lastWriteUtc = [System.IO.File]::GetLastWriteTimeUtc($claimPath)
                        $ageSeconds = ([DateTime]::UtcNow - $lastWriteUtc).TotalSeconds
                        if ($ageSeconds -ge $StaleClaimSeconds) {
                            $stalePath = "$claimPath.stale.$([guid]::NewGuid().ToString('N'))"
                            [System.IO.File]::Move($claimPath, $stalePath)
                            Remove-Item -LiteralPath $stalePath -Force -ErrorAction SilentlyContinue
                            continue
                        }
                    }
                }
                catch { $null = $_ }

                if ($attempt -ge ($RetryCount - 1)) { return $false }
                if ($RetryDelayMilliseconds -gt 0) {
                    # A fixed interval keeps simultaneous processes phase-aligned: the same unlucky writer
                    # can lose every CreateNew race until its allowance expires. PID + attempt yields a
                    # deterministic cross-process jitter without creating another shared resource.
                    $jitterWindow = [Math]::Min(25, $RetryDelayMilliseconds)
                    $jitter = [Math]::Abs(($PID + (17 * $attempt)) % ($jitterWindow + 1))
                    Start-Sleep -Milliseconds ($RetryDelayMilliseconds + $jitter)
                }
            }
            catch { return $false }
        }

        if (-not $ownsClaim) { return $false }

        $line = ($Record | ConvertTo-Json -Compress) + [Environment]::NewLine
        $bytes = [System.Text.UTF8Encoding]::new($false).GetBytes($line)
        $stream = $null
        try {
            $stream = [System.IO.File]::Open(
                $JournalPath,
                [System.IO.FileMode]::OpenOrCreate,
                [System.IO.FileAccess]::Write,
                [System.IO.FileShare]::Read
            )
            $null = $stream.Seek(0, [System.IO.SeekOrigin]::End)
            $stream.Write($bytes, 0, $bytes.Length)
            $stream.Flush()
            return $true
        }
        finally {
            if ($null -ne $stream) { $stream.Dispose() }
            if ($null -ne $claimStream) { $claimStream.Dispose() }
            if ($ownsClaim -and (Test-Path -LiteralPath $claimPath -PathType Leaf)) {
                Remove-Item -LiteralPath $claimPath -Force -ErrorAction SilentlyContinue
            }
        }
    }
    catch {
        return $false
    }

    return $false
}