← Files NightshiftARCHIVED FILE
runtime/windows/doctor.ps1
28.9 KB · Oct 4, 2026 · 12:30 UTC
param(
[string]$Project = [Environment]::CurrentDirectory
)
Set-StrictMode -Version 2.0
$ErrorActionPreference = 'Stop'
$pluginRoot = Resolve-Path (Join-Path $PSScriptRoot '../..')
Import-Module (Join-Path $pluginRoot 'lib/Nightshift.psm1') -Force -DisableNameChecking
$here = $PSScriptRoot
$facts = New-Object Collections.Generic.List[string]
$warns = New-Object Collections.Generic.List[string]
$actions = New-Object Collections.Generic.List[string]
$policyLines = New-Object Collections.Generic.List[string]
function Add-NSFact { param([string]$Message) $null = $script:facts.Add($Message) }
function Add-NSWarn { param([string]$Message) $null = $script:warns.Add($Message) }
function Add-NSAct {
param([ValidateSet('safe', 'confirm', 'blocked')][string]$Kind, [string]$Message)
$null = $script:actions.Add("[$Kind] $Message")
}
try {
$hostPath = Resolve-NSCanonicalPath $Project
}
catch {
[Console]::Error.WriteLine("doctor: cannot cd to $Project")
exit 1
}
$linkState = 'absent'
$workspace = $hostPath
$link = Join-Path $hostPath '.nightshift-link'
if (Test-NSPathEntry $link) {
try {
$workspace = Resolve-NSWorkspaceRoot $hostPath
$linkState = 'valid'
Add-NSFact "task root $hostPath links to workspace $workspace"
}
catch {
$linkState = 'invalid'
$workspace = $hostPath
Add-NSWarn 'invalid .nightshift-link - Nightshift will not guess a workspace'
Add-NSAct confirm "replace .nightshift-link with an absolute path to a directory that already contains .nightshift/, using $(Join-Path $here 'link-workspace.ps1')"
}
}
else {
Add-NSFact "task root is the workspace: $hostPath"
}
$ns = Join-Path $workspace '.nightshift'
$stateKind = Get-NSStateKind $workspace
$stateVer = Get-NSStateVersion $workspace
function Write-NSDoctorReport {
param(
[string]$NightshiftLabel,
[string]$Target = '',
[string]$HostRec = 'none',
[string]$LeaseState = 'absent',
[string]$LeaseGeneration = '',
[int]$Armed = 0,
[int]$Open = 0,
[int]$Ticked = 0,
[int]$Stop = 0,
[int]$Ended = 0
)
Write-Output 'Nightshift Doctor'
Write-Output "Host: $hostPath"
Write-Output "Workspace: $workspace"
Write-Output "Link: $linkState"
if (-not [string]::IsNullOrEmpty($Target)) {
Write-Output "Work target: $Target"
Write-Output "Recorded: $HostRec"
$leaseLine = $LeaseState
if (-not [string]::IsNullOrEmpty($LeaseGeneration)) {
$leaseLine = "$LeaseState (generation $LeaseGeneration)"
}
Write-Output "Lease: $leaseLine"
$ver = if ([string]::IsNullOrEmpty($stateVer)) { '-' } else { $stateVer }
Write-Output "State: $ver ($stateKind)"
Write-Output "Armed: $Armed Open: $Open Ticked: $Ticked STOP: $Stop Ended: $Ended"
}
else {
Write-Output "Nightshift: $NightshiftLabel"
}
Write-Output ''
Write-Output 'Facts'
if ($facts.Count -eq 0) { Write-Output 'none' } else { $facts | ForEach-Object { Write-Output $_ } }
Write-Output ''
Write-Output 'resolved policy'
if ($policyLines.Count -eq 0) { Write-Output 'none' } else { $policyLines | ForEach-Object { Write-Output $_ } }
Write-Output ''
Write-Output 'Warnings'
if ($warns.Count -eq 0) { Write-Output 'none' } else { $warns | ForEach-Object { Write-Output $_ } }
Write-Output ''
Write-Output 'Actions (Doctor does not perform these)'
if ($actions.Count -eq 0) { Write-Output 'none' } else { $actions | ForEach-Object { Write-Output $_ } }
}
if (-not (Test-Path -LiteralPath $ns -PathType Container)) {
Add-NSWarn "no .nightshift/ at $workspace"
Add-NSAct confirm 'run Nightshift setup from the project you want to change (not a ChatGPT scratch workspace)'
Write-NSDoctorReport -NightshiftLabel 'missing'
exit 0
}
$unusableRecv = $false
$reportedMode = ''
try {
$reportedMode = Get-NSWorkMode $workspace
Add-NSFact "work mode $reportedMode"
$modeRecord = Get-NSLayoutPath $ns 'work-mode'
if (-not (Test-Path -LiteralPath $modeRecord -PathType Leaf)) {
try {
if ((Get-NSProposedWorkMode $workspace) -eq 'artifact') {
Add-NSWarn 'work mode is unset; Setup would propose artifact'
Add-NSAct confirm 'persist the proposed artifact mode with Setup; Doctor does not write work-mode'
}
}
catch {
}
}
if ($reportedMode -eq 'artifact') {
Add-NSFact "artifact receipts $(Get-NSReceiptsCount $workspace)"
$latest = Get-NSLatestReceipt $workspace
if (-not [string]::IsNullOrEmpty($latest)) {
Add-NSFact "latest artifact receipt $([IO.Path]::GetFileName($latest))"
}
$recv = Get-NSReceiptsDir $workspace
if ((Test-NSPathEntry $recv) -and -not (Test-NSUsableReceiptsDir $workspace)) {
$unusableRecv = $true
Add-NSWarn 'artifact receipts path is not a usable directory'
Add-NSAct confirm 'replace the unusable receipts path with a real directory so receipts can land; Doctor does not rewrite it'
}
}
}
catch {
Add-NSWarn 'work mode is malformed; treating the site as unusable until Setup rewrites it'
}
$target = $workspace
try {
$target = Resolve-NSWorkTarget $workspace
Add-NSFact "work target $target"
}
catch {
$target = $workspace
if ($_.Exception.Message -match 'scratch') {
Add-NSWarn 'work target is a disposable scratch workspace'
}
else {
Add-NSWarn 'work target could not be resolved; treating workspace as the code root'
}
}
# The one resolver renders the one view: every effective setting, where it came
# from, and when it expires. Doctor reads it and never re-derives precedence.
$resolution = $null
try {
$resolution = Get-NSPolicyResolution $workspace
}
catch {
Add-NSWarn 'the shift policy could not be resolved; treat the shift as built-in defaults plus rules'
}
if ($null -ne $resolution) {
foreach ($policyLine in (Format-NSPolicyTable $resolution)) { $null = $policyLines.Add($policyLine) }
if ($resolution['policyState'] -ceq 'malformed') {
Add-NSWarn "shift-policy.json is malformed ($($resolution['policyError'])); the shift resolves to built-in defaults and rules only"
Add-NSAct confirm 'repair the named field in shift-policy.json, or delete the file so the next Start writes safe defaults'
}
$deadlineFileEpoch = $resolution['deadlineFile']
$deadlinePolicyEpoch = $resolution['deadlinePolicy']
if ($null -ne $deadlineFileEpoch -and $null -ne $deadlinePolicyEpoch -and [long]$deadlineFileEpoch -ne [long]$deadlinePolicyEpoch) {
Add-NSWarn "deadline file $deadlineFileEpoch does not match shift-policy deadlineEpoch $deadlinePolicyEpoch; the gate honours the earlier value"
Add-NSAct confirm 'synchronize the deadline projection with the policy before the next arm; Doctor rewrites neither'
}
}
# An interrupted provisioning transaction is the one piece of state that can
# leave a half-installed tool behind. Doctor reads the same diagnosis the
# recovery helper prints, and restores nothing.
$provisionRepair = "inspect $(Get-NSLayoutName $ns 'provision-transaction') and $(Get-NSLayoutName $ns 'provision-baseline')/, restore by hand or run provision.ps1 rollback after fixing the target, then Start again"
$provision = $null
try {
$provision = Get-NSProvisionDiagnosis $workspace
}
catch {
Add-NSWarn 'provision-transaction.json cannot be read; Start will refuse to arm'
Add-NSAct confirm $provisionRepair
}
if (($null -ne $provision) -and $provision['present']) {
$provisionLine = Get-NSProvisionDiagnosisLine $provision
if ((Get-NSProvisionDiagnosisClass $provision) -ceq 'provable') {
Add-NSFact $provisionLine
Add-NSAct confirm "run $(Join-Path $here 'provision.ps1') -Project $hostPath recover before the next Start; Doctor never recovers"
}
else {
Add-NSWarn ($provisionLine + '; Start will refuse to arm')
Add-NSAct confirm $provisionRepair
}
}
$punch = Get-NSLayoutPath $ns 'punch-list'
$open = 0
$ticked = 0
if (Test-Path -LiteralPath $punch -PathType Leaf) {
$counts = Get-NSBoxCounts $punch
$open = [int]$counts.Open
$ticked = [int]$counts.Ticked
Add-NSFact "punch list open=$open ticked=$ticked"
}
else {
Add-NSWarn 'punch-list.md is missing'
}
try {
if (Test-NSReceiptsEnabled $workspace) {
Add-NSFact 'completion record per-item receipt'
if (-not $unusableRecv) {
$missing = @(Get-NSReceiptsMissingNns $workspace)
if ($missing.Count -gt 0) {
Add-NSFact ('receipts missing model text ' + $missing.Count)
Add-NSWarn ($missing.Count.ToString() + ' ticked items have no receipt text; each item completes through its receipt file')
Add-NSAct confirm "write the missing receipts under $(Get-NSLayoutName $ns 'receipts')/; Doctor does not rewrite the punch list"
}
}
}
else {
Add-NSFact 'completion record none; the owner disabled receipts'
}
}
catch {
}
$orders = Get-NSOpenBoxesInFile (Get-NSLayoutPath $ns 'work-orders')
if ($orders -gt 0) {
Add-NSFact "pending Hunt work orders=$orders"
}
$drafts = Get-NSOpenDrafts (Get-NSLayoutPath $ns 'drafting-table')
if ($drafts -gt 0) {
Add-NSFact "staged drafting-table items=$drafts"
}
# Archive files only `- ` bullets, so an inbox entry written any other way stays live for good.
$inboxStrays = 0
foreach ($inboxKey in @('parking-lot', 'snag-log')) {
foreach ($stray in (Get-NSInboxStrays (Get-NSLayoutPath $ns $inboxKey))) {
Add-NSWarn ('{0} line {1} is not a `- ` bullet, so Archive never files it: {2}' -f (Get-NSLayoutName $ns $inboxKey), $stray.Line, $stray.Text)
$inboxStrays++
}
}
if ($inboxStrays -gt 0) {
Add-NSAct confirm 'rewrite each inbox entry Doctor names as one `- ` bullet, keeping its text; Doctor does not edit the inbox'
}
$armed = [int](Test-Path -LiteralPath (Get-NSLayoutPath $ns 'armed') -PathType Leaf)
$endedPath = Get-NSLayoutPath $ns 'ended'
$ended = 0
if (Test-NSReparsePoint $endedPath) {
Add-NSWarn 'ended path is not a usable file'
}
elseif (Test-Path -LiteralPath $endedPath -PathType Leaf) {
$ended = 1
}
$stop = [int](Test-Path -LiteralPath (Get-NSLayoutPath $ns 'stop') -PathType Leaf)
$sessionEndPath = Get-NSLayoutPath $ns 'session-end'
$sessionEnd = 0
if (Test-NSReparsePoint $sessionEndPath) {
Add-NSWarn 'session-end path is not a usable file'
}
elseif (Test-Path -LiteralPath $sessionEndPath -PathType Leaf) {
$sessionEnd = 1
}
$pulsePath = Get-NSLayoutPath $ns 'pulse'
$pulse = 0
if (Test-NSReparsePoint $pulsePath) {
Add-NSWarn 'shift-pulse path is not a usable file'
}
elseif (Test-Path -LiteralPath $pulsePath -PathType Leaf) {
$pulse = 1
}
$stall = ''
$stallPath = Get-NSLayoutPath $ns 'stall'
if (Test-NSReparsePoint $stallPath) {
Add-NSWarn 'stall path is not a usable file'
}
elseif (Test-Path -LiteralPath $stallPath -PathType Leaf) {
try {
$stall = (([IO.File]::ReadAllText($stallPath)) -replace '\s', '')
}
catch {
$stall = ''
}
}
if ($armed -eq 1) { Add-NSFact 'shift is armed' } else { Add-NSFact 'shift is not armed' }
switch ($stateKind) {
{ $_ -eq 'current' -or $_ -eq 'legacy' } {
$current = [string](Get-NSCurrentStateVersion)
if ($stateKind -eq 'current') {
$ver = if ([string]::IsNullOrEmpty($stateVer)) { $current } else { $stateVer }
Add-NSFact "state version $ver (current)"
}
elseif ([string]::IsNullOrEmpty($stateVer) -or $stateVer -eq '0') {
Add-NSFact 'state version 0 (legacy - no state-version marker; every state file sits at the top of .nightshift/)'
}
else {
Add-NSFact "state version $stateVer (every state file sits at the top of .nightshift/)"
}
# The move into the current layout, described from the plan migrate-state would carry out.
$migrator = Join-Path $here 'migrate-state.ps1'
$plan = Get-NSMigrationPlan $workspace
$offer = ''
if ($plan.Code -eq 0) { $offer = Get-NSMigrationOffer -Records $plan.Records -Command $migrator }
if ($offer.Length -gt 0) {
$refusals = @($plan.Records | Where-Object { $_.StartsWith("refuse`t", [StringComparison]::Ordinal) })
$conflicts = @($plan.Records | Where-Object { $_.StartsWith("conflict`t", [StringComparison]::Ordinal) })
if ($refusals.Count -gt 0) {
Add-NSWarn ("the move into layout $current waits: " + $refusals[0].Substring(7))
Add-NSAct blocked $offer
}
elseif ($conflicts.Count -gt 0) {
$named = foreach ($record in $conflicts) {
$f = $record.Split("`t")
if ($f.Count -gt 3 -and $f[3].Length -gt 0) { $f[1] + ' ' + $f[3] } else { $f[1] + ' and ' + $f[2] + ' differ' }
}
Add-NSWarn ("the move into layout $current is blocked: " + (@($named) -join '; '))
Add-NSAct confirm "keep one copy of each conflicting file by hand, then preview the move with $migrator"
}
else {
Add-NSAct confirm $offer
}
}
}
'future' {
$ver = if ([string]::IsNullOrEmpty($stateVer)) { 'unknown' } else { $stateVer }
Add-NSWarn "state version $ver is newer than this plugin supports ($(Get-NSCurrentStateVersion))"
Add-NSAct blocked 'upgrade Nightshift; never rewrite or downgrade a newer state-version'
}
'malformed' {
Add-NSWarn 'state-version is malformed'
Add-NSAct confirm "inspect $(Get-NSLayoutPath $ns 'state-version') and replace it with a single integer while unarmed - never guess"
}
}
if ($ended -eq 1) { Add-NSFact 'gate has clocked the shift out (.ended)' }
if ($stop -eq 1) { Add-NSFact 'STOP is present' }
if ($sessionEnd -eq 1) { Add-NSFact 'clean session-end marker is present' }
if ($pulse -eq 1) { Add-NSFact 'shift-pulse marker is present' }
if (-not [string]::IsNullOrEmpty($stall)) { Add-NSFact "stall count $stall" }
$deadlinePath = Get-NSLayoutPath $ns 'deadline'
if (Test-NSReparsePoint $deadlinePath) {
Add-NSWarn 'deadline path is not a usable file'
}
elseif (-not (Test-Path -LiteralPath $deadlinePath -PathType Leaf)) {
Add-NSFact 'deadline=none'
}
else {
$dlRaw = ''
try {
$dlRaw = ([IO.File]::ReadAllText($deadlinePath)).Trim()
}
catch {
$dlRaw = ''
}
if ($dlRaw -match '^[0-9]+$') {
$now = Get-NSUnixTime
$dl = [long]$dlRaw
if ($now -ge $dl) {
Add-NSFact "deadline=$dlRaw remaining=0s (elapsed)"
}
else {
$rem = $dl - $now
Add-NSFact "deadline=$dlRaw remaining=${rem}s"
}
}
else {
Add-NSWarn 'deadline is not a UNIX epoch - watchmen compare integer seconds'
}
}
if ($armed -eq 1 -and $open -eq 0 -and $ended -eq 0) {
Add-NSWarn 'armed with no open boxes and no .ended - clock-out may still be due'
Add-NSAct confirm 'ask Nightshift for status, or start so the watchman can spawn the clock-out'
}
if ($stop -eq 1 -and $armed -eq 1) {
Add-NSWarn 'stop-work order is pending until the next stop attempt'
Add-NSAct confirm "run $(Join-Path $here 'stop-shift.ps1') -Project $hostPath to disarm immediately; a bare STOP file waits for the next Stop event; do not delete STOP by hand"
}
if ($stop -eq 1 -and $armed -eq 0) {
Add-NSWarn 'STOP leftover while no shift is armed - start will clear it'
Add-NSAct confirm 'run start when you want a new shift, which clears stale STOP'
}
if ((Test-Path -LiteralPath $punch -PathType Leaf) -and $open -eq 0) {
Add-NSFact 'punch list has no open items - leftover Shift contract and Gates still bind the next Hunt or Start cut'
if ($armed -eq 0) {
Add-NSWarn 'empty punch list will inherit the current contract'
Add-NSAct confirm 'review punch-list.md contract and Gates before composing a new campaign; Archive files ticked items but never resets them'
}
}
# Staged work is only an offer when there is nothing already approved to do. An open checkbox
# under ## Items is the shift, so Doctor reports what is staged and stops there - the same
# precedence Start applies, said the same way.
if ($orders -gt 0 -and $armed -eq 0 -and $open -eq 0) {
Add-NSAct confirm 'start to promote a parked Hunt order, or hunt to compose a new one'
}
if ($drafts -gt 0 -and $armed -eq 0 -and $open -eq 0) {
Add-NSAct confirm 'promote agreed drafting-table items into punch-list.md, or start to be offered them'
}
if ($open -gt 0 -and ($orders -gt 0 -or $drafts -gt 0)) {
Add-NSFact "staged work is informational while $open punch-list items are open - start works the current list, and drafts and Hunt orders stay staged for a later shift"
}
$rulesPath = Get-NSLayoutPath $ns 'rules'
if (-not (Test-Path -LiteralPath $rulesPath -PathType Leaf)) {
Add-NSWarn 'rules.json is missing - watchman will refuse to arm'
Add-NSAct confirm 're-run setup and accept the shipped rules template'
}
else {
$rules = Get-NSRulesObject $workspace
if ($null -eq $rules) {
Add-NSWarn 'rules.json is unreadable or not a JSON object'
Add-NSAct confirm "fix $rulesPath or re-run setup - never half-apply a broken file"
}
else {
Add-NSFact 'rules.json is a JSON object'
$wm = Get-NSRule $workspace 'watchMinutes' ''
if ($wm -notmatch '^[0-9]+$') {
Add-NSWarn 'watchMinutes missing or not a whole number'
Add-NSAct confirm 'restore watchMinutes from the shipped template (10, or 0 to disarm)'
}
else {
Add-NSFact "watchMinutes $wm"
}
$retrySpacing = Get-NSRule $workspace 'watchRetrySeconds' ([string]$env:NIGHTSHIFT_WATCH_RETRY)
$revivalPrompt = Expand-NSInjectedPaths $workspace (Get-NSRule $workspace 'revivalPrompt' ([string]$env:NIGHTSHIFT_REVIVAL_PROMPT))
$freshPrompt = Expand-NSInjectedPaths $workspace (Get-NSRule $workspace 'freshRevivalPrompt' ([string]$env:NIGHTSHIFT_FRESH_PROMPT))
if ([string]::IsNullOrEmpty($retrySpacing)) {
Add-NSWarn 'watchRetrySeconds is empty - watchman will refuse to arm'
Add-NSAct confirm 'restore watchRetrySeconds from the shipped template'
}
if ([string]::IsNullOrEmpty($revivalPrompt)) {
Add-NSWarn 'revivalPrompt is empty - watchman will refuse to arm'
Add-NSAct confirm 'restore revivalPrompt from the shipped template'
}
if ([string]::IsNullOrEmpty($freshPrompt)) {
Add-NSWarn 'freshRevivalPrompt is empty - watchman will refuse to arm'
Add-NSAct confirm 'restore freshRevivalPrompt from the shipped template'
}
$toolDeny = $null
if ($null -ne $rules.PSObject.Properties['toolDeny']) {
$toolDeny = $rules.toolDeny
}
foreach ($tool in @('AskUserQuestion', 'request_user_input', 'AskQuestion')) {
$toolState = 'invalid'
if ($null -eq $toolDeny -or $toolDeny -is [Array] -or $toolDeny -is [string] -or $toolDeny -is [ValueType]) {
$toolState = 'invalid'
}
elseif ($null -eq $toolDeny.PSObject.Properties[$tool]) {
$toolState = 'missing'
}
elseif ($toolDeny.PSObject.Properties[$tool].Value -isnot [string]) {
$toolState = 'invalid'
}
elseif ([string]$toolDeny.PSObject.Properties[$tool].Value -eq '') {
$toolState = 'allow'
}
else {
$toolState = 'deny'
}
switch ($toolState) {
'allow' { Add-NSFact "toolDeny.$tool explicitly allows the question tool" }
'deny' { Add-NSFact "toolDeny.$tool explicitly denies the question tool" }
default {
Add-NSWarn "toolDeny.$tool is $toolState - question behavior has no explicit policy"
Add-NSAct confirm "re-run setup and review the shipped $tool entry (non-empty denies; empty allows)"
}
}
}
}
}
$hostRec = 'none'
$sid = ''
$tpath = ''
$spid = ''
$sstart = ''
$sessionPath = Get-NSLayoutPath $ns 'session'
if (Test-NSReparsePoint $sessionPath) {
Add-NSWarn 'shift-session path is not a usable file'
}
elseif (Test-Path -LiteralPath $sessionPath -PathType Leaf) {
try {
$sessionLines = [IO.File]::ReadAllLines($sessionPath)
if ($sessionLines.Count -gt 0) { $sid = [string]$sessionLines[0] }
if ($sessionLines.Count -gt 1) { $tpath = [string]$sessionLines[1] }
if ($sessionLines.Count -gt 2) { $spid = ([string]$sessionLines[2] -replace '\s', '') }
if ($sessionLines.Count -gt 3) { $sstart = [string]$sessionLines[3] }
if ($sessionLines.Count -gt 4 -and -not [string]::IsNullOrEmpty(([string]$sessionLines[4]).Trim())) {
$hostRec = ([string]$sessionLines[4]).Trim()
}
else {
$hostRec = 'claude'
}
}
catch {
$hostRec = 'claude'
}
Add-NSFact "recorded host $hostRec"
if (-not [string]::IsNullOrEmpty($sid)) {
Add-NSFact 'session id is present (not printed)'
if ($hostRec -eq 'codex') {
$kind = Get-NSCodexIdentityKind $sid
Add-NSFact "Codex identity kind $kind"
if ($kind -ne 'resumable') {
Add-NSWarn "recorded Codex identity is $kind - watchman must not claim it resumed that thread"
Add-NSAct blocked 'capture a resumable Codex session id before relying on overnight revival'
}
}
}
else {
Add-NSWarn 'session id line is empty'
Add-NSAct confirm 'let the next tool call record identity, or accept a fresh-session fallback'
}
if ($spid -match '^[0-9]+$') {
$liveness = Test-NSRecordedProcess $spid $sstart
if ($liveness -eq 'Alive') {
Add-NSFact "recorded pid $spid is alive"
}
elseif ($liveness -eq 'Dead') {
Add-NSFact "recorded pid $spid is dead"
}
}
}
else {
Add-NSFact 'no .shift-session yet'
if ($armed -eq 1 -and $open -gt 0) {
Add-NSWarn 'armed shift has no session record - a 500 can land before first work'
}
}
$leaseState = 'absent'
$leaseGeneration = ''
$leasePath = Get-NSLayoutPath $ns 'lease'
if (Test-NSPathEntry $leasePath) {
$lease = Read-NSLease $ns
if ($null -ne $lease) {
$leaseState = 'valid'
$leaseGeneration = [string]$lease.Generation
Add-NSFact "process lease host $($lease.HostName) generation $leaseGeneration"
if (-not [string]::IsNullOrEmpty([string]$lease.Nonce)) {
Add-NSFact 'process lease belongs to a watchman recovery (capability not printed)'
}
else {
Add-NSFact 'process lease belongs to the interactive shift process'
}
if ($hostRec -ne 'none' -and [string]$lease.HostName -ne $hostRec) {
Add-NSWarn "process lease host $($lease.HostName) disagrees with recorded session host $hostRec"
Add-NSAct blocked "run $(Join-Path $here 'stop-shift.ps1') -Project $hostPath, then Start again; do not rewrite the lease by hand"
}
if (-not [string]::IsNullOrEmpty([string]$lease.ProcessId)) {
$holder = Test-NSRecordedProcess ([string]$lease.ProcessId) ([string]$lease.Start)
if ($holder -eq 'Alive') {
Add-NSFact "lease holder pid $($lease.ProcessId) is alive"
}
else {
Add-NSFact "lease holder pid $($lease.ProcessId) is not confirmed alive"
}
}
$rcodeEarly = Get-NSReasonCode $ns
$noncePresent = -not [string]::IsNullOrEmpty([string]$lease.Nonce)
$holderAlive = -not [string]::IsNullOrEmpty([string]$lease.ProcessId) `
-and ((Test-NSRecordedProcess ([string]$lease.ProcessId) ([string]$lease.Start)) -eq 'Alive')
$holderDead = -not [string]::IsNullOrEmpty([string]$lease.ProcessId) `
-and ((Test-NSRecordedProcess ([string]$lease.ProcessId) ([string]$lease.Start)) -eq 'Dead')
if ($rcodeEarly -eq 'clock-out-failed') {
Add-NSWarn 'terminal clock-out failed without releasing the shift'
if ($noncePresent) {
if ($holderAlive) {
Add-NSFact 'recovery worker is alive; the recorded conversation cannot reclaim yet'
Add-NSAct confirm "wait until the recovery worker exits, or run $(Join-Path $here 'stop-shift.ps1') -Project $hostPath; reopening the recorded conversation stays blocked while that worker holds the lease"
}
else {
Add-NSFact "recovery worker is not confirmed alive after a failed clock-out; run $(Join-Path $here 'stop-shift.ps1') -Project $hostPath, then Start"
}
}
else {
Add-NSFact 'process lease restored to the interactive shift; the recorded conversation can operate'
Add-NSAct confirm "reopen the recorded conversation to continue or run $(Join-Path $here 'stop-shift.ps1') -Project $hostPath; Start re-arms after Stop"
}
}
elseif ($noncePresent -and $holderAlive) {
Add-NSFact 'recovery worker is alive; the recorded conversation cannot reclaim yet'
Add-NSAct confirm "wait until the recovery worker exits, or run $(Join-Path $here 'stop-shift.ps1') -Project $hostPath; reopening the recorded conversation stays blocked while that worker holds the lease"
}
elseif ($noncePresent -and $holderDead -and -not [string]::IsNullOrEmpty($sid)) {
Add-NSFact "lease held by a dead recovery attempt (generation $leaseGeneration, pid $($lease.ProcessId)); the recorded conversation reclaims it on its next tool call"
}
}
else {
$leaseState = 'malformed'
Add-NSWarn 'process lease is malformed - ownership cannot be proven'
Add-NSAct blocked "run $(Join-Path $here 'stop-shift.ps1') -Project $hostPath, then Start again; never guess or edit .shift-lease"
}
}
elseif ($armed -eq 1 -and -not [string]::IsNullOrEmpty($sid)) {
Add-NSWarn "armed shift has no process lease - the bound session's next tool call must bootstrap it"
}
$wpid = ''
$wstart = ''
$watchmanUnusable = $false
$watchmanAlive = $false
$watchmanPath = Get-NSLayoutPath $ns 'watchman'
if (Test-NSReparsePoint $watchmanPath) {
Add-NSWarn 'watchman pidfile path is not a usable file'
$watchmanUnusable = $true
}
elseif (Test-Path -LiteralPath $watchmanPath -PathType Leaf) {
try {
$watchLines = [IO.File]::ReadAllLines($watchmanPath)
$wpid = if ($watchLines.Count -gt 0) { ([string]$watchLines[0] -replace '\s', '') } else { '' }
$wstart = if ($watchLines.Count -gt 1) { [string]$watchLines[1] } else { '' }
}
catch {
$wpid = ''
$wstart = ''
}
}
if ($wpid -match '^[0-9]+$') {
$watchLive = Test-NSRecordedProcess $wpid $wstart
if ($watchLive -eq 'Alive') {
Add-NSFact "watchman pid $wpid is alive"
$watchmanAlive = $true
}
else {
Add-NSFact "watchman pid $wpid is stale"
if ($armed -eq 0) {
Add-NSAct safe 'remove leftover .watchman - the recorded process is gone and no shift is armed'
}
}
}
else {
if (-not $watchmanUnusable) {
Add-NSFact 'no live watchman pid file'
}
}
$rcode = Get-NSReasonCode $ns
if (-not [string]::IsNullOrEmpty($rcode)) {
Add-NSFact "watchman reason $rcode ($(Get-NSReasonLabel $rcode))"
}
# An armed shift with work left and nothing watching it is not revived after a crash or a usage
# limit. Only watchMinutes 0 means that on purpose; a missing or unreadable value stops the watchman
# arming too, so it is flagged the same way.
$watchMinutes = [string](Get-NSRule $workspace 'watchMinutes' ([string]$env:NIGHTSHIFT_WATCH))
if ($armed -eq 1 -and $open -gt 0 -and -not $watchmanAlive -and -not $watchmanUnusable -and $watchMinutes -cne '0') {
Add-NSWarn 'shift is armed with open boxes and no watchman - a crash or usage limit will not be revived'
Add-NSAct confirm ("arm it again with ns start-watchman -HostName <this host>: it changes nothing else about the shift, and says why if it cannot arm (its output is in " +
(Get-NSLayoutName $ns 'watchman-log') + '); or work the list in the live session')
}
if (-not [string]::IsNullOrEmpty($tpath) -and -not (Test-Path -LiteralPath $tpath -PathType Leaf)) {
Add-NSWarn 'recorded transcript/rollout path is not a readable file'
}
Add-NSAct confirm "export a local support bundle with $(Join-Path $here 'export-support.ps1') - written under $(Get-NSLayoutPath $ns 'support'), never uploaded"
Add-NSAct blocked 'Doctor never repairs, arms, stops, revives, or deletes'
Write-NSDoctorReport -NightshiftLabel 'present' -Target $target -HostRec $hostRec `
-LeaseState $leaseState -LeaseGeneration $leaseGeneration `
-Armed $armed -Open $open -Ticked $ticked -Stop $stop -Ended $ended
exit 0
SHA-256: 01f17e84e28fe6d7e92889b685a99b570ca7cbda09c76d9e186ac56eb6e60bd4