prizmkit 1.1.91 → 1.1.93

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/bundled/VERSION.json +3 -3
  2. package/bundled/dev-pipeline/.env.example +0 -4
  3. package/bundled/dev-pipeline/README.md +9 -39
  4. package/bundled/dev-pipeline/SCHEMA_ANALYSIS.md +0 -4
  5. package/bundled/dev-pipeline/assets/prizm-dev-team-integration.md +0 -2
  6. package/bundled/dev-pipeline/launch-bugfix-daemon.sh +0 -1
  7. package/bundled/dev-pipeline/launch-feature-daemon.sh +2 -3
  8. package/bundled/dev-pipeline/launch-refactor-daemon.sh +0 -1
  9. package/bundled/dev-pipeline/lib/branch.sh +8 -5
  10. package/bundled/dev-pipeline/lib/common.sh +122 -0
  11. package/bundled/dev-pipeline/lib/heartbeat.sh +13 -3
  12. package/bundled/dev-pipeline/run-bugfix.sh +149 -98
  13. package/bundled/dev-pipeline/run-feature.sh +90 -103
  14. package/bundled/dev-pipeline/run-recovery.sh +1 -32
  15. package/bundled/dev-pipeline/run-refactor.sh +148 -99
  16. package/bundled/dev-pipeline/scripts/update-bug-status.py +39 -4
  17. package/bundled/dev-pipeline/scripts/update-feature-status.py +43 -6
  18. package/bundled/dev-pipeline/scripts/update-refactor-status.py +36 -4
  19. package/bundled/dev-pipeline/tests/test-deploy-safety.sh +3 -3
  20. package/bundled/dev-pipeline/tests/test_auto_skip.py +92 -3
  21. package/bundled/dev-pipeline-windows/.env.example +0 -4
  22. package/bundled/dev-pipeline-windows/SCHEMA_ANALYSIS.md +0 -4
  23. package/bundled/dev-pipeline-windows/assets/prizm-dev-team-integration.md +0 -2
  24. package/bundled/dev-pipeline-windows/lib/branch.ps1 +17 -4
  25. package/bundled/dev-pipeline-windows/lib/common.ps1 +9 -0
  26. package/bundled/dev-pipeline-windows/lib/pipeline.ps1 +89 -108
  27. package/bundled/dev-pipeline-windows/run-recovery.ps1 +4 -19
  28. package/bundled/dev-pipeline-windows/scripts/update-bug-status.py +39 -4
  29. package/bundled/dev-pipeline-windows/scripts/update-feature-status.py +43 -6
  30. package/bundled/dev-pipeline-windows/scripts/update-refactor-status.py +36 -4
  31. package/bundled/skills/_metadata.json +1 -1
  32. package/bundled/skills/bugfix-pipeline-launcher/SKILL.md +1 -1
  33. package/bundled/skills/bugfix-pipeline-launcher/references/configuration.md +5 -15
  34. package/bundled/skills/feature-pipeline-launcher/SKILL.md +4 -4
  35. package/bundled/skills/feature-pipeline-launcher/references/configuration.md +3 -13
  36. package/bundled/skills/refactor-pipeline-launcher/SKILL.md +3 -3
  37. package/bundled/skills/refactor-pipeline-launcher/references/configuration.md +4 -14
  38. package/bundled/skills-windows/bugfix-pipeline-launcher/SKILL.md +1 -1
  39. package/bundled/skills-windows/bugfix-pipeline-launcher/references/configuration.md +4 -14
  40. package/bundled/skills-windows/feature-pipeline-launcher/SKILL.md +4 -4
  41. package/bundled/skills-windows/feature-pipeline-launcher/references/configuration.md +2 -12
  42. package/bundled/skills-windows/refactor-pipeline-launcher/SKILL.md +3 -3
  43. package/bundled/skills-windows/refactor-pipeline-launcher/references/configuration.md +4 -14
  44. package/package.json +1 -1
  45. package/bundled/dev-pipeline/scripts/cleanup-logs.py +0 -192
  46. package/bundled/dev-pipeline-windows/scripts/cleanup-logs.py +0 -192
@@ -34,8 +34,17 @@ function Invoke-PrizmPipeline {
34
34
  $envMaxRetryArgs = @('--max-retries', [string]$parsedEnvMaxRetries)
35
35
  }
36
36
 
37
+ $envMaxInfraRetryArgs = @()
38
+ if ($env:MAX_INFRA_RETRIES) {
39
+ $parsedEnvMaxInfraRetries = 0
40
+ if (-not [int]::TryParse($env:MAX_INFRA_RETRIES, [ref]$parsedEnvMaxInfraRetries) -or $parsedEnvMaxInfraRetries -lt 1) {
41
+ throw "MAX_INFRA_RETRIES must be a positive integer: $($env:MAX_INFRA_RETRIES)"
42
+ }
43
+ $envMaxInfraRetryArgs = @('--max-infra-retries', [string]$parsedEnvMaxInfraRetries)
44
+ }
45
+
37
46
  if ($command -in @('help','--help','-h')) {
38
- Write-Host "Usage: .\run-$Kind.ps1 run [item-id] [list-path] [--dry-run] [--mode lite|standard|full] [--critic] [--max-retries N] [--timeout seconds]"
47
+ Write-Host "Usage: .\run-$Kind.ps1 run [item-id] [list-path] [--dry-run] [--mode lite|standard|full] [--critic] [--max-retries N] [--max-infra-retries N]"
39
48
  Write-Host " .\run-$Kind.ps1 status [list-path]"
40
49
  Write-Host " .\run-$Kind.ps1 reset"
41
50
  Write-Host ""
@@ -55,7 +64,7 @@ function Invoke-PrizmPipeline {
55
64
 
56
65
  if ($command -eq 'status') {
57
66
  $listPath = if ($remaining.Count -gt 0) { $remaining[0] } else { $defaultList }
58
- Invoke-PrizmPythonText $python (@((Join-Path $paths.ScriptsDir $updateScript), $listOption, $listPath, '--state-dir', $stateDir, '--action', 'status') + $envMaxRetryArgs)
67
+ Invoke-PrizmPythonText $python (@((Join-Path $paths.ScriptsDir $updateScript), $listOption, $listPath, '--state-dir', $stateDir, '--action', 'status') + $envMaxRetryArgs + $envMaxInfraRetryArgs)
59
68
  $global:PRIZM_EXIT_CODE = $LASTEXITCODE
60
69
  return
61
70
  }
@@ -78,15 +87,9 @@ function Invoke-PrizmPipeline {
78
87
  }
79
88
  $maxRetries = $null
80
89
  if ($envMaxRetryArgs.Count -gt 0) { $maxRetries = [int]$envMaxRetryArgs[1] }
90
+ $maxInfraRetries = $null
91
+ if ($envMaxInfraRetryArgs.Count -gt 0) { $maxInfraRetries = [int]$envMaxInfraRetryArgs[1] }
81
92
  $verboseEnabled = $env:VERBOSE -in @('1','true','yes','on')
82
- $timeoutSeconds = 0
83
- if ($env:SESSION_TIMEOUT) {
84
- $parsedEnvTimeout = 0
85
- if (-not [int]::TryParse($env:SESSION_TIMEOUT, [ref]$parsedEnvTimeout) -or $parsedEnvTimeout -lt 0) {
86
- throw "SESSION_TIMEOUT must be a non-negative integer: $($env:SESSION_TIMEOUT)"
87
- }
88
- $timeoutSeconds = $parsedEnvTimeout
89
- }
90
93
  $heartbeatInterval = 30
91
94
  if ($env:HEARTBEAT_INTERVAL) {
92
95
  $parsedHeartbeatInterval = 0
@@ -115,26 +118,6 @@ function Invoke-PrizmPipeline {
115
118
  $enableDeploy = $env:ENABLE_DEPLOY -in @('1','true','yes','on')
116
119
  $stopOnFailure = $env:STOP_ON_FAILURE -in @('1','true','yes','on')
117
120
  $devBranchOverride = if ($env:DEV_BRANCH) { $env:DEV_BRANCH.Trim() } else { '' }
118
- $logCleanupEnabled = $true
119
- if ($env:LOG_CLEANUP_ENABLED) {
120
- $logCleanupEnabled = $env:LOG_CLEANUP_ENABLED -in @('1','true','yes','on')
121
- }
122
- $logRetentionDays = 14
123
- if ($env:LOG_RETENTION_DAYS) {
124
- $parsedLogRetentionDays = 0
125
- if (-not [int]::TryParse($env:LOG_RETENTION_DAYS, [ref]$parsedLogRetentionDays) -or $parsedLogRetentionDays -lt 0) {
126
- throw "LOG_RETENTION_DAYS must be a non-negative integer: $($env:LOG_RETENTION_DAYS)"
127
- }
128
- $logRetentionDays = $parsedLogRetentionDays
129
- }
130
- $logMaxTotalMb = 1024
131
- if ($env:LOG_MAX_TOTAL_MB) {
132
- $parsedLogMaxTotalMb = 0
133
- if (-not [int]::TryParse($env:LOG_MAX_TOTAL_MB, [ref]$parsedLogMaxTotalMb) -or $parsedLogMaxTotalMb -lt 0) {
134
- throw "LOG_MAX_TOTAL_MB must be a non-negative integer: $($env:LOG_MAX_TOTAL_MB)"
135
- }
136
- $logMaxTotalMb = $parsedLogMaxTotalMb
137
- }
138
121
  $featuresFilter = $null
139
122
 
140
123
  for ($i = 0; $i -lt $remaining.Count; $i++) {
@@ -153,14 +136,13 @@ function Invoke-PrizmPipeline {
153
136
  $maxRetries = $parsedMaxRetries
154
137
  continue
155
138
  }
156
- '^--timeout$' {
139
+ '^--max-infra-retries$' {
157
140
  $i++
158
- if ($i -ge $remaining.Count) { throw '--timeout requires a seconds value.' }
159
- $parsedTimeout = 0
160
- if (-not [int]::TryParse($remaining[$i], [ref]$parsedTimeout) -or $parsedTimeout -lt 0) {
161
- throw "--timeout must be a non-negative integer: $($remaining[$i])"
141
+ $parsedMaxInfraRetries = 0
142
+ if (-not [int]::TryParse($remaining[$i], [ref]$parsedMaxInfraRetries) -or $parsedMaxInfraRetries -lt 1) {
143
+ throw "--max-infra-retries must be a positive integer: $($remaining[$i])"
162
144
  }
163
- $timeoutSeconds = $parsedTimeout
145
+ $maxInfraRetries = $parsedMaxInfraRetries
164
146
  continue
165
147
  }
166
148
  '^--features$' { $i++; $featuresFilter = $remaining[$i]; continue }
@@ -177,12 +159,15 @@ function Invoke-PrizmPipeline {
177
159
  }
178
160
  $maxRetryArgs = @()
179
161
  if ($maxRetries -ne $null) { $maxRetryArgs = @('--max-retries', [string]$maxRetries) }
162
+ $maxInfraRetryArgs = @()
163
+ if ($maxInfraRetries -ne $null) { $maxInfraRetryArgs = @('--max-infra-retries', [string]$maxInfraRetries) }
180
164
  if ($verboseEnabled) {
181
165
  $modeLabel = if ($mode) { $mode } else { 'auto' }
182
166
  $criticLabel = if ($critic) { $critic } else { 'plan-default' }
183
167
  $retryLabel = if ($maxRetries -ne $null) { [string]$maxRetries } else { 'default' }
168
+ $infraRetryLabel = if ($maxInfraRetries -ne $null) { [string]$maxInfraRetries } else { 'default' }
184
169
  Write-PrizmInfo "Verbose mode enabled."
185
- Write-PrizmInfo "Effective options: mode=$modeLabel critic=$criticLabel maxRetries=$retryLabel timeoutSeconds=$timeoutSeconds staleKillThreshold=$staleKillThreshold dryRun=$dryRun"
170
+ Write-PrizmInfo "Effective options: mode=$modeLabel critic=$criticLabel maxRetries=$retryLabel maxInfraRetries=$infraRetryLabel staleKillThreshold=$staleKillThreshold dryRun=$dryRun"
186
171
  }
187
172
 
188
173
  # Validate PRIZMKIT_EFFORT early (fail-fast before any sessions are spawned)
@@ -241,17 +226,27 @@ function Invoke-PrizmPipeline {
241
226
 
242
227
  function Get-PrizmGitDirtyPaths {
243
228
  param([string]$ProjectRoot)
244
- $lines = & git -C $ProjectRoot status --porcelain 2>$null
229
+ $hiddenToolWorktreeExcludes = Get-PrizmHiddenToolWorktreeExcludes
230
+ $lines = & git -C $ProjectRoot status --porcelain -- . @hiddenToolWorktreeExcludes 2>$null
245
231
  $paths = @()
246
232
  foreach ($line in $lines) {
247
233
  if ([string]::IsNullOrWhiteSpace($line) -or $line.Length -lt 4) { continue }
248
234
  $path = $line.Substring(3).Trim()
249
235
  if ($path -match ' -> ') { $path = ($path -split ' -> ')[-1].Trim() }
250
- $paths += (($path -replace '\\', '/').Trim('"').TrimStart([char[]]@('.', '/')))
236
+ $paths += (($path -replace '\\', '/').Trim('"').TrimStart([char[]]@('/')))
251
237
  }
252
238
  return @($paths)
253
239
  }
254
240
 
241
+ function Test-PrizmPipelineIgnoredPath {
242
+ param([string]$Path)
243
+ $normalizedPath = (($Path -replace '\\', '/').Trim('"').TrimStart([char[]]@('/')))
244
+ $segments = @($normalizedPath -split '/' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
245
+ if ($segments.Count -lt 2) { return $false }
246
+ if (-not $segments[0].StartsWith('.')) { return $false }
247
+ return $segments[1] -in @('worktree', 'worktrees')
248
+ }
249
+
255
250
  function Test-PrizmPipelineBookkeepingPath {
256
251
  param(
257
252
  [string]$ProjectRoot,
@@ -270,6 +265,7 @@ function Invoke-PrizmPipeline {
270
265
  function Test-PrizmGitWorkDirty {
271
266
  param([string]$ProjectRoot, [string]$StateDir, [string]$ListPath)
272
267
  foreach ($path in (Get-PrizmGitDirtyPaths $ProjectRoot)) {
268
+ if (Test-PrizmPipelineIgnoredPath $path) { continue }
273
269
  if (-not (Test-PrizmPipelineBookkeepingPath $ProjectRoot $path $StateDir $ListPath)) {
274
270
  return $true
275
271
  }
@@ -279,7 +275,8 @@ function Invoke-PrizmPipeline {
279
275
 
280
276
  function Test-PrizmGitDirty {
281
277
  param([string]$ProjectRoot)
282
- $dirty = & git -C $ProjectRoot status --porcelain 2>$null | Select-Object -First 1
278
+ $hiddenToolWorktreeExcludes = Get-PrizmHiddenToolWorktreeExcludes
279
+ $dirty = & git -C $ProjectRoot status --porcelain -- . @hiddenToolWorktreeExcludes 2>$null | Select-Object -First 1
283
280
  return -not [string]::IsNullOrWhiteSpace([string]$dirty)
284
281
  }
285
282
 
@@ -294,12 +291,12 @@ function Invoke-PrizmPipeline {
294
291
  }
295
292
 
296
293
  function Write-PrizmStaleKillMarker {
297
- param([string]$MarkerPath, [int]$StaleSeconds, [int]$Threshold)
294
+ param([string]$MarkerPath, [int]$StaleSeconds, [int]$Threshold, [string]$Reason = 'stale_session')
298
295
  $markerDir = Split-Path $MarkerPath -Parent
299
296
  if ($markerDir) { New-Item -ItemType Directory -Force -Path $markerDir | Out-Null }
300
297
  [ordered]@{
301
298
  killed_at = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
302
- reason = 'stale_session'
299
+ reason = $Reason
303
300
  stale_seconds = $StaleSeconds
304
301
  threshold = $Threshold
305
302
  } | ConvertTo-Json -Compress | Set-Content -Path $MarkerPath -Encoding UTF8
@@ -307,14 +304,16 @@ function Invoke-PrizmPipeline {
307
304
 
308
305
  function Invoke-PrizmGitAutoCommit {
309
306
  param([string]$ProjectRoot, [string]$Message)
310
- & git -C $ProjectRoot add -A *> $null
307
+ $hiddenToolWorktreeExcludes = Get-PrizmHiddenToolWorktreeExcludes
308
+ & git -C $ProjectRoot add -A -- . @hiddenToolWorktreeExcludes *> $null
311
309
  & git -C $ProjectRoot commit --no-verify -m $Message *> $null
312
310
  return $LASTEXITCODE -eq 0
313
311
  }
314
312
 
315
313
  function Invoke-PrizmGitIncludeRemainingArtifacts {
316
314
  param([string]$ProjectRoot, [string]$ItemId)
317
- & git -C $ProjectRoot add -A *> $null
315
+ $hiddenToolWorktreeExcludes = Get-PrizmHiddenToolWorktreeExcludes
316
+ & git -C $ProjectRoot add -A -- . @hiddenToolWorktreeExcludes *> $null
318
317
  & git -C $ProjectRoot commit --no-verify --amend --no-edit *> $null
319
318
  if ($LASTEXITCODE -eq 0) { return }
320
319
  & git -C $ProjectRoot commit --no-verify -m "chore($ItemId): include remaining session artifacts" *> $null
@@ -534,26 +533,6 @@ function Invoke-PrizmPipeline {
534
533
  }
535
534
  }
536
535
 
537
- function Invoke-PrizmLogCleanup {
538
- if (-not $logCleanupEnabled) { return }
539
- if (-not (Test-Path $stateDir)) { return }
540
-
541
- try {
542
- $cleanup = Invoke-PrizmPythonJson $python @(
543
- (Join-Path $paths.ScriptsDir 'cleanup-logs.py'),
544
- '--state-dir', $stateDir,
545
- '--retention-days', [string]$logRetentionDays,
546
- '--max-total-mb', [string]$logMaxTotalMb
547
- )
548
- if ($cleanup -and $cleanup.deleted_files -gt 0) {
549
- $reclaimedKb = [int]([int64]$cleanup.reclaimed_bytes / 1024)
550
- Write-PrizmInfo "Log cleanup: deleted $($cleanup.deleted_files) files, reclaimed ${reclaimedKb}KB"
551
- }
552
- } catch {
553
- Write-PrizmWarn "Log cleanup failed (continuing)"
554
- }
555
- }
556
-
557
536
  function Get-PrizmDeployIncompleteItems {
558
537
  param([string]$Kind, [string]$ListPath)
559
538
 
@@ -657,7 +636,12 @@ function Invoke-PrizmPipeline {
657
636
  $devBranchName = ''
658
637
  $hadDirtyBaseline = if ($isGitRepository) { Test-PrizmGitWorkDirty $paths.ProjectRoot $stateDir $listPath } else { $false }
659
638
  if ($hadDirtyBaseline) {
660
- Write-PrizmWarn "Dirty working tree detected before pipeline bookkeeping; session success requires a new commit."
639
+ if ($dryRun) {
640
+ Write-PrizmWarn "Dirty working tree detected before $CurrentItemId; would auto-commit snapshot in real run."
641
+ } else {
642
+ Write-PrizmInfo "Dirty working tree detected before $CurrentItemId; auto-committing snapshot."
643
+ Invoke-PrizmGitAutoCommit $paths.ProjectRoot "chore: workspace snapshot ready for run $CurrentItemId" | Out-Null
644
+ }
661
645
  }
662
646
 
663
647
  if ($dryRun) {
@@ -672,7 +656,7 @@ function Invoke-PrizmPipeline {
672
656
  }
673
657
  }
674
658
  } else {
675
- $start = Invoke-PrizmPythonJson $python (@((Join-Path $paths.ScriptsDir $updateScript), $listOption, $listPath, '--state-dir', $stateDir, '--action', 'start', $idOption, $CurrentItemId, '--session-id', $sessionId) + $maxRetryArgs)
659
+ $start = Invoke-PrizmPythonJson $python (@((Join-Path $paths.ScriptsDir $updateScript), $listOption, $listPath, '--state-dir', $stateDir, '--action', 'start', $idOption, $CurrentItemId, '--session-id', $sessionId) + $maxRetryArgs + $maxInfraRetryArgs)
676
660
  if ($start.retry_count -ne $null) { $retryCount = [string]$start.retry_count }
677
661
  if ($start.resume_from_phase -ne $null) { $resumePhase = [string]$start.resume_from_phase }
678
662
 
@@ -683,7 +667,10 @@ function Invoke-PrizmPipeline {
683
667
  $devBranchName = $candidateBranch
684
668
  Write-PrizmInfo "Dev branch: $devBranchName"
685
669
  } else {
686
- Write-PrizmWarn "Failed to create dev branch; running on current branch: $originalBranch"
670
+ Write-PrizmError "Failed to create dev branch: $candidateBranch from $originalBranch - aborting to avoid running on $originalBranch"
671
+ $script:PRIZM_ITEM_LIST_STATUS = ''
672
+ $script:PRIZM_ITEM_EXIT_CODE = 1
673
+ return
687
674
  }
688
675
  }
689
676
  }
@@ -739,20 +726,18 @@ function Invoke-PrizmPipeline {
739
726
 
740
727
  $elapsedSeconds = 0
741
728
  $staleSeconds = 0
729
+ $staleReason = 'stale_session'
742
730
  $previousLogSize = 0
743
731
  $previousProgressSignature = ''
744
732
  $previousChildActivitySignature = ''
745
733
  $previousErrorLoopSignature = ''
746
- $wasTimedOut = $false
747
734
  $staleKillMarker = Join-Path $logsDir 'stale-kill.json'
748
735
  $wasStaleKilled = $false
749
736
  while ($true) {
750
- $remainingTimeout = if ($timeoutSeconds -gt 0) { $timeoutSeconds - $elapsedSeconds } else { $heartbeatInterval }
751
- $waitSeconds = if ($timeoutSeconds -gt 0) { [Math]::Min($heartbeatInterval, [Math]::Max(1, $remainingTimeout)) } else { $heartbeatInterval }
752
- $completed = Wait-Job $job -Timeout $waitSeconds
737
+ $completed = Wait-Job $job -Timeout $heartbeatInterval
753
738
  if ($completed) { break }
754
739
 
755
- $elapsedSeconds += $waitSeconds
740
+ $elapsedSeconds += $heartbeatInterval
756
741
  $currentLogSize = 0
757
742
  if (Test-Path $sessionLog) {
758
743
  $currentLogSize = [int64](Get-Item $sessionLog).Length
@@ -799,17 +784,14 @@ function Invoke-PrizmPipeline {
799
784
  $previousErrorLoopSignature = $errorLoopSignature
800
785
 
801
786
  if ($errorLoopDetected) {
787
+ $staleReason = 'error_loop'
802
788
  $staleSeconds = $effectiveStaleKillThreshold
803
789
  } elseif ($growth -gt 0 -or $childAdvanced -or $progressAdvanced) {
790
+ $staleReason = 'stale_session'
804
791
  $staleSeconds = 0
805
792
  } else {
806
- $staleSeconds += $waitSeconds
807
- }
808
-
809
- if ($timeoutSeconds -gt 0 -and $elapsedSeconds -ge $timeoutSeconds) {
810
- $wasTimedOut = $true
811
- Stop-PrizmSessionProcess $pidPath
812
- break
793
+ $staleReason = 'stale_session'
794
+ $staleSeconds += $heartbeatInterval
813
795
  }
814
796
 
815
797
  $fatalErrorCode = Get-PrizmProgressFatalErrorCode -ProgressFile $progressJson
@@ -825,8 +807,12 @@ function Invoke-PrizmPipeline {
825
807
  }
826
808
  if ($effectiveStaleKillThreshold -gt 0 -and $staleSeconds -ge $effectiveStaleKillThreshold) {
827
809
  $wasStaleKilled = $true
828
- Write-PrizmWarn "Session stale-killed (no progress for ${effectiveStaleKillThreshold}s)"
829
- Write-PrizmStaleKillMarker $staleKillMarker $staleSeconds $effectiveStaleKillThreshold
810
+ if ($staleReason -eq 'error_loop') {
811
+ Write-PrizmWarn "Session stale-killed due to repeated read-offset/wasted-call error loop"
812
+ } else {
813
+ Write-PrizmWarn "Session stale-killed (no progress for ${effectiveStaleKillThreshold}s)"
814
+ }
815
+ Write-PrizmStaleKillMarker $staleKillMarker $staleSeconds $effectiveStaleKillThreshold $staleReason
830
816
  Stop-PrizmSessionProcess $pidPath
831
817
  if ($staleKillGraceSeconds -gt 0) { Start-Sleep -Seconds $staleKillGraceSeconds }
832
818
  break
@@ -834,11 +820,7 @@ function Invoke-PrizmPipeline {
834
820
  }
835
821
 
836
822
  $exitCode = 0
837
- if ($wasTimedOut) {
838
- Stop-Job $job
839
- Remove-Job $job
840
- $exitCode = 124
841
- } elseif ($wasStaleKilled) {
823
+ if ($wasStaleKilled) {
842
824
  Stop-Job $job
843
825
  Remove-Job $job
844
826
  $exitCode = 143
@@ -853,6 +835,15 @@ function Invoke-PrizmPipeline {
853
835
  }
854
836
  Stop-PrizmProgressParser $parserProcess
855
837
 
838
+ $staleKillReason = ''
839
+ if (Test-Path $staleKillMarker) {
840
+ try {
841
+ $staleKillData = Get-Content $staleKillMarker -Raw | ConvertFrom-Json
842
+ if ($staleKillData.PSObject.Properties['reason']) { $staleKillReason = [string]$staleKillData.reason }
843
+ } catch {
844
+ $staleKillReason = ''
845
+ }
846
+ }
856
847
  $wasInfraError = ($exitCode -ne 0 -and (Test-PrizmInfraError -SessionLog $sessionLog -ProgressJson $progressJson))
857
848
  $wasAiRuntimeError = Test-PrizmAiRuntimeError -SessionLog $sessionLog -ProgressJson $progressJson
858
849
  $semanticCompletion = if ($Kind -eq 'feature' -and $isGitRepository) {
@@ -862,7 +853,7 @@ function Invoke-PrizmPipeline {
862
853
  $status = 'crashed'
863
854
  if ($semanticCompletion) {
864
855
  $status = 'success'
865
- if ($exitCode -ne 0 -or $wasStaleKilled -or $wasTimedOut -or $wasAiRuntimeError) {
856
+ if ($exitCode -ne 0 -or $wasStaleKilled -or $wasAiRuntimeError) {
866
857
  Write-PrizmWarn "Session ended with a failure signal after semantic completion; treating as finalized success"
867
858
  Write-PrizmWarn "Semantic completion commit: $($semanticCompletion.CommitSha)"
868
859
  }
@@ -870,24 +861,23 @@ function Invoke-PrizmPipeline {
870
861
  $status = 'infra_error'
871
862
  Write-PrizmWarn "AI session failed due to structured AI runtime/context error"
872
863
  Write-PrizmWarn "AI runtime errors are retried without consuming code retry budget"
873
- } elseif ($wasTimedOut) {
874
- $status = 'timed_out'
875
- Write-PrizmWarn "AI session timed out after $timeoutSeconds seconds"
876
864
  } elseif ($wasInfraError) {
877
865
  $status = 'infra_error'
878
866
  Write-PrizmWarn "AI session failed due to AI CLI/provider infrastructure error"
879
867
  Write-PrizmWarn "Infrastructure errors are retried without consuming code retry budget"
880
868
  } elseif ($wasStaleKilled -or (Test-Path $staleKillMarker)) {
881
- Write-PrizmWarn "Session was stale-killed by heartbeat monitor (no progress for too long)"
882
- Write-PrizmWarn "Stale-killed sessions are treated as failed; dev branch is preserved for inspection"
869
+ if ($staleKillReason -eq 'error_loop') {
870
+ Write-PrizmWarn "Session was killed by heartbeat monitor due to repeated read-offset/wasted-call errors"
871
+ } else {
872
+ Write-PrizmWarn "Session was stale-killed by heartbeat monitor (no progress for too long)"
873
+ }
874
+ Write-PrizmWarn "Heartbeat-killed sessions are treated as failed; dev branch is preserved for inspection"
883
875
  } elseif ($exitCode -ne 0) {
884
876
  Write-PrizmWarn "AI session exited with code $exitCode"
885
877
  } elseif (-not $isGitRepository) {
886
878
  Write-PrizmWarn "AI session exited cleanly, but project is not a git repository; cannot verify work was committed."
887
879
  } elseif (Test-PrizmGitCommitsSince $paths.ProjectRoot $baseCommit) {
888
880
  $status = 'success'
889
- } elseif ($hadDirtyBaseline) {
890
- Write-PrizmWarn "AI session exited cleanly but produced no new commits; pre-existing dirty tree will not be auto-committed."
891
881
  } elseif (Test-PrizmGitWorkDirty $paths.ProjectRoot $stateDir $listPath) {
892
882
  Write-PrizmWarn "AI session exited cleanly but produced no commits; auto-committing dirty work tree."
893
883
  if (Invoke-PrizmGitAutoCommit $paths.ProjectRoot "chore($CurrentItemId): auto-commit session work") {
@@ -903,17 +893,12 @@ function Invoke-PrizmPipeline {
903
893
  $itemListStatus = ''
904
894
  if ($status -eq 'success') {
905
895
  if (Test-PrizmGitDirty $paths.ProjectRoot) {
906
- if ($hadDirtyBaseline) {
907
- Write-PrizmInfo "Auto-committing pipeline bookkeeping artifacts only."
908
- Invoke-PrizmGitIncludeBookkeepingArtifacts $paths.ProjectRoot $stateDir $listPath
909
- } else {
910
- Write-PrizmInfo "Auto-committing remaining session artifacts."
911
- Invoke-PrizmGitIncludeRemainingArtifacts $paths.ProjectRoot $CurrentItemId
912
- }
896
+ Write-PrizmInfo "Auto-committing remaining session artifacts."
897
+ Invoke-PrizmGitIncludeRemainingArtifacts $paths.ProjectRoot $CurrentItemId
913
898
  }
914
899
 
915
900
  if ($status -eq 'success') {
916
- $updateResult = Invoke-PrizmPythonJson $python (@((Join-Path $paths.ScriptsDir $updateScript), $listOption, $listPath, '--state-dir', $stateDir, '--action', 'update', $idOption, $CurrentItemId, '--session-id', $sessionId, '--session-status', $status) + $maxRetryArgs)
901
+ $updateResult = Invoke-PrizmPythonJson $python (@((Join-Path $paths.ScriptsDir $updateScript), $listOption, $listPath, '--state-dir', $stateDir, '--action', 'update', $idOption, $CurrentItemId, '--session-id', $sessionId, '--session-status', $status) + $maxRetryArgs + $maxInfraRetryArgs)
917
902
  if ($updateResult -and $updateResult.PSObject.Properties['new_status']) {
918
903
  $itemListStatus = [string]$updateResult.new_status
919
904
  }
@@ -950,7 +935,7 @@ function Invoke-PrizmPipeline {
950
935
  Write-PrizmRuntimeFailureLog $failureLog $CurrentItemId $sessionId $status $exitCode $staleKillMarker $progressJson $checkpointPath $paths.ProjectRoot $baseCommit
951
936
  }
952
937
  }
953
- $updateResult = Invoke-PrizmPythonJson $python (@((Join-Path $paths.ScriptsDir $updateScript), $listOption, $listPath, '--state-dir', $stateDir, '--action', 'update', $idOption, $CurrentItemId, '--session-id', $sessionId, '--session-status', $status) + $maxRetryArgs)
938
+ $updateResult = Invoke-PrizmPythonJson $python (@((Join-Path $paths.ScriptsDir $updateScript), $listOption, $listPath, '--state-dir', $stateDir, '--action', 'update', $idOption, $CurrentItemId, '--session-id', $sessionId, '--session-status', $status) + $maxRetryArgs + $maxInfraRetryArgs)
954
939
  if ($updateResult -and $updateResult.PSObject.Properties['new_status']) {
955
940
  $itemListStatus = [string]$updateResult.new_status
956
941
  }
@@ -969,10 +954,6 @@ function Invoke-PrizmPipeline {
969
954
  return
970
955
  }
971
956
 
972
- if (-not $dryRun) {
973
- Invoke-PrizmLogCleanup
974
- }
975
-
976
957
  if ($itemId) {
977
958
  Invoke-PrizmPipelineItem $itemId
978
959
  $global:PRIZM_EXIT_CODE = $script:PRIZM_ITEM_EXIT_CODE
@@ -1030,7 +1011,7 @@ function Invoke-PrizmPipeline {
1030
1011
  $global:PRIZM_EXIT_CODE = $lastExitCode
1031
1012
  return
1032
1013
  } elseif ($lastExitCode -ne 0 -and $stopOnFailure) {
1033
- Write-PrizmInfo "STOP_ON_FAILURE: $nextItemId is $($script:PRIZM_ITEM_LIST_STATUS); retry budget not exhausted, continuing."
1014
+ Write-PrizmInfo "STOP_ON_FAILURE: $nextItemId is $($script:PRIZM_ITEM_LIST_STATUS); code/infra retry budget not exhausted, continuing."
1034
1015
  }
1035
1016
  }
1036
1017
  }
@@ -62,7 +62,6 @@ function Get-PrizmRecoveryIntEnv {
62
62
  return $parsed
63
63
  }
64
64
 
65
- $timeoutSeconds = Get-PrizmRecoveryIntEnv 'SESSION_TIMEOUT' 0 0
66
65
  $heartbeatInterval = Get-PrizmRecoveryIntEnv 'HEARTBEAT_INTERVAL' 30 1
67
66
  $staleKillThreshold = Get-PrizmRecoveryIntEnv 'STALE_KILL_THRESHOLD' 900 0
68
67
  $staleKillGraceSeconds = Get-PrizmRecoveryIntEnv 'STALE_KILL_GRACE_SECONDS' 10 0
@@ -123,15 +122,12 @@ $staleSeconds = 0
123
122
  $previousLogSize = 0
124
123
  $previousProgressSignature = ''
125
124
  $previousChildActivitySignature = ''
126
- $wasTimedOut = $false
127
125
  $wasStaleKilled = $false
128
126
  while ($true) {
129
- $remainingTimeout = if ($timeoutSeconds -gt 0) { $timeoutSeconds - $elapsedSeconds } else { $heartbeatInterval }
130
- $waitSeconds = if ($timeoutSeconds -gt 0) { [Math]::Min($heartbeatInterval, [Math]::Max(1, $remainingTimeout)) } else { $heartbeatInterval }
131
- $completed = Wait-Job $job -Timeout $waitSeconds
127
+ $completed = Wait-Job $job -Timeout $heartbeatInterval
132
128
  if ($completed) { break }
133
129
 
134
- $elapsedSeconds += $waitSeconds
130
+ $elapsedSeconds += $heartbeatInterval
135
131
  $currentLogSize = 0
136
132
  if (Test-Path $logPath) { $currentLogSize = [int64](Get-Item $logPath).Length }
137
133
  $growth = $currentLogSize - $previousLogSize
@@ -145,13 +141,7 @@ while ($true) {
145
141
  $childAdvanced = ($childSignature -and $childSignature -ne $previousChildActivitySignature)
146
142
  $previousChildActivitySignature = $childSignature
147
143
 
148
- if ($growth -gt 0 -or $childAdvanced -or $progressAdvanced) { $staleSeconds = 0 } else { $staleSeconds += $waitSeconds }
149
-
150
- if ($timeoutSeconds -gt 0 -and $elapsedSeconds -ge $timeoutSeconds) {
151
- $wasTimedOut = $true
152
- Stop-PrizmSessionProcess $pidPath
153
- break
154
- }
144
+ if ($growth -gt 0 -or $childAdvanced -or $progressAdvanced) { $staleSeconds = 0 } else { $staleSeconds += $heartbeatInterval }
155
145
 
156
146
  if ($staleKillThreshold -gt 0 -and $staleSeconds -ge $staleKillThreshold) {
157
147
  $wasStaleKilled = $true
@@ -164,11 +154,7 @@ while ($true) {
164
154
  }
165
155
 
166
156
  $exitCode = 0
167
- if ($wasTimedOut) {
168
- Stop-Job $job
169
- Remove-Job $job
170
- $exitCode = 124
171
- } elseif ($wasStaleKilled) {
157
+ if ($wasStaleKilled) {
172
158
  Stop-Job $job
173
159
  Remove-Job $job
174
160
  $exitCode = 143
@@ -188,7 +174,6 @@ if (Test-Path $logPath) {
188
174
  $sizeKb = [int](([int64](Get-Item $logPath).Length) / 1024)
189
175
  Write-PrizmInfo "Session log: $lineCount lines, ${sizeKb}KB"
190
176
  }
191
- if ($wasTimedOut) { Write-PrizmWarn "Recovery session timed out after $timeoutSeconds seconds." }
192
177
  if ($wasStaleKilled -or (Test-Path $staleKillMarker)) { Write-PrizmWarn "Recovery session was stale-killed." }
193
178
  if ($exitCode -eq 0) { Write-PrizmSuccess "Recovery session completed." } else { Write-PrizmError "Recovery session failed. Log: $logPath" }
194
179
  exit $exitCode
@@ -16,7 +16,7 @@ Usage:
16
16
  --bug-list <path> --state-dir <path> \
17
17
  --action <get_next|start|update|status|pause|reset|clean|unskip> \
18
18
  [--bug-id <id>] [--session-status <status>] \
19
- [--session-id <id>] [--max-retries <n>]
19
+ [--session-id <id>] [--max-retries <n>] [--max-infra-retries <n>]
20
20
  """
21
21
 
22
22
  import argparse
@@ -75,7 +75,8 @@ def parse_args():
75
75
  help="Session outcome status (required for 'update' action)",
76
76
  )
77
77
  parser.add_argument("--session-id", default=None, help="Session ID (optional, for 'update' action)")
78
- parser.add_argument("--max-retries", type=int, default=3, help="Maximum retry count (default: 3)")
78
+ parser.add_argument("--max-retries", type=int, default=3, help="Maximum code retry count (default: 3)")
79
+ parser.add_argument("--max-infra-retries", type=int, default=3, help="Maximum infrastructure retry count (default: 3)")
79
80
  parser.add_argument("--project-root", default=None, help="Project root directory. Required for 'clean' action.")
80
81
  return parser.parse_args()
81
82
 
@@ -98,6 +99,9 @@ def load_bug_status(state_dir, bug_id):
98
99
  "bug_id": bug_id,
99
100
  "retry_count": 0,
100
101
  "max_retries": 3,
102
+ "max_infra_retries": 3,
103
+ "infra_error_count": 0,
104
+ "last_infra_error_session_id": None,
101
105
  "sessions": [],
102
106
  "last_session_id": None,
103
107
  "resume_from_phase": None,
@@ -111,6 +115,9 @@ def load_bug_status(state_dir, bug_id):
111
115
  "bug_id": bug_id,
112
116
  "retry_count": 0,
113
117
  "max_retries": 3,
118
+ "max_infra_retries": 3,
119
+ "infra_error_count": 0,
120
+ "last_infra_error_session_id": None,
114
121
  "sessions": [],
115
122
  "last_session_id": None,
116
123
  "resume_from_phase": None,
@@ -234,6 +241,7 @@ def action_get_next(bug_list_data, state_dir):
234
241
  "title": chosen.get("title", ""),
235
242
  "severity": chosen.get("severity", "medium"),
236
243
  "retry_count": chosen_status_data.get("retry_count", 0),
244
+ "infra_error_count": chosen_status_data.get("infra_error_count", 0),
237
245
  "resume_from_phase": chosen_status_data.get("resume_from_phase", None),
238
246
  }
239
247
  print(json.dumps(result, indent=2, ensure_ascii=False))
@@ -248,6 +256,7 @@ def action_update(args, bug_list_path, state_dir):
248
256
  session_status = args.session_status
249
257
  session_id = args.session_id
250
258
  max_retries = args.max_retries
259
+ max_infra_retries = args.max_infra_retries
251
260
 
252
261
  if not bug_id:
253
262
  error_out("--bug-id is required for 'update' action")
@@ -262,6 +271,8 @@ def action_update(args, bug_list_path, state_dir):
262
271
  new_status = get_bug_status_from_list(bug_list_path, bug_id)
263
272
 
264
273
  if session_status == "success":
274
+ bs["infra_error_count"] = 0
275
+ bs["last_infra_error_session_id"] = None
265
276
  new_status = "completed"
266
277
  bs["resume_from_phase"] = None
267
278
  err = update_bug_in_list(bug_list_path, bug_id, "completed")
@@ -286,11 +297,25 @@ def action_update(args, bug_list_path, state_dir):
286
297
  error_out("Failed to update .prizmkit/plans/bug-fix-list.json: {}".format(err))
287
298
  return
288
299
  elif session_status == "infra_error":
289
- new_status = "pending"
290
- bs["infra_error_count"] = bs.get("infra_error_count", 0) + 1
300
+ # AI CLI/provider outage, auth failure, gateway error, etc.
301
+ # Infra failures do not consume the code retry budget, but they still
302
+ # need their own bounded budget so a flaky provider cannot loop forever.
303
+ infra_error_count = bs.get("infra_error_count", 0) + 1
304
+ bs["infra_error_count"] = infra_error_count
291
305
  bs["last_infra_error_session_id"] = session_id
306
+ bs["max_infra_retries"] = max_infra_retries
307
+ bs["degraded_reason"] = "infra_error"
308
+ if session_id:
309
+ bs["last_session_id"] = session_id
292
310
  bs["resume_from_phase"] = None
293
311
 
312
+ if infra_error_count >= max_infra_retries:
313
+ new_status = "failed"
314
+ if session_id:
315
+ bs["last_failed_session_id"] = session_id
316
+ else:
317
+ new_status = "pending"
318
+
294
319
  err = update_bug_in_list(bug_list_path, bug_id, new_status)
295
320
  if err:
296
321
  error_out("Failed to update .prizmkit/plans/bug-fix-list.json: {}".format(err))
@@ -338,6 +363,8 @@ def action_update(args, bug_list_path, state_dir):
338
363
  "session_status": session_status,
339
364
  "new_status": new_status,
340
365
  "retry_count": bs["retry_count"],
366
+ "infra_error_count": bs.get("infra_error_count", 0),
367
+ "max_infra_retries": max_infra_retries,
341
368
  "resume_from_phase": bs.get("resume_from_phase"),
342
369
  "updated_at": bs["updated_at"],
343
370
  }
@@ -577,6 +604,8 @@ def action_reset(args, bug_list_path, state_dir):
577
604
  old_retry = bs.get("retry_count", 0)
578
605
 
579
606
  bs["retry_count"] = 0
607
+ bs["infra_error_count"] = 0
608
+ bs["last_infra_error_session_id"] = None
580
609
  bs["sessions"] = []
581
610
  bs["last_session_id"] = None
582
611
  bs["resume_from_phase"] = None
@@ -652,6 +681,8 @@ def action_clean(args, bug_list_path, state_dir):
652
681
  old_retry = bs.get("retry_count", 0)
653
682
 
654
683
  bs["retry_count"] = 0
684
+ bs["infra_error_count"] = 0
685
+ bs["last_infra_error_session_id"] = None
655
686
  bs["sessions"] = []
656
687
  bs["last_session_id"] = None
657
688
  bs["resume_from_phase"] = None
@@ -740,6 +771,8 @@ def action_start(args, bug_list_path, state_dir):
740
771
  "bug_id": bug_id,
741
772
  "old_status": old_status,
742
773
  "new_status": "in_progress",
774
+ "retry_count": bs.get("retry_count", 0),
775
+ "infra_error_count": bs.get("infra_error_count", 0),
743
776
  "updated_at": bs["updated_at"],
744
777
  }
745
778
  print(json.dumps(result, indent=2, ensure_ascii=False))
@@ -816,6 +849,8 @@ def action_unskip(args, bug_list_path, state_dir):
816
849
  for bid in to_reset:
817
850
  bs = load_bug_status(state_dir, bid)
818
851
  bs["retry_count"] = 0
852
+ bs["infra_error_count"] = 0
853
+ bs["last_infra_error_session_id"] = None
819
854
  bs["sessions"] = []
820
855
  bs["last_session_id"] = None
821
856
  bs["resume_from_phase"] = None