prizmkit 1.1.90 → 1.1.92

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.
@@ -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] [--timeout seconds]"
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,6 +87,8 @@ 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
93
  $timeoutSeconds = 0
83
94
  if ($env:SESSION_TIMEOUT) {
@@ -153,6 +164,15 @@ function Invoke-PrizmPipeline {
153
164
  $maxRetries = $parsedMaxRetries
154
165
  continue
155
166
  }
167
+ '^--max-infra-retries$' {
168
+ $i++
169
+ $parsedMaxInfraRetries = 0
170
+ if (-not [int]::TryParse($remaining[$i], [ref]$parsedMaxInfraRetries) -or $parsedMaxInfraRetries -lt 1) {
171
+ throw "--max-infra-retries must be a positive integer: $($remaining[$i])"
172
+ }
173
+ $maxInfraRetries = $parsedMaxInfraRetries
174
+ continue
175
+ }
156
176
  '^--timeout$' {
157
177
  $i++
158
178
  if ($i -ge $remaining.Count) { throw '--timeout requires a seconds value.' }
@@ -177,12 +197,15 @@ function Invoke-PrizmPipeline {
177
197
  }
178
198
  $maxRetryArgs = @()
179
199
  if ($maxRetries -ne $null) { $maxRetryArgs = @('--max-retries', [string]$maxRetries) }
200
+ $maxInfraRetryArgs = @()
201
+ if ($maxInfraRetries -ne $null) { $maxInfraRetryArgs = @('--max-infra-retries', [string]$maxInfraRetries) }
180
202
  if ($verboseEnabled) {
181
203
  $modeLabel = if ($mode) { $mode } else { 'auto' }
182
204
  $criticLabel = if ($critic) { $critic } else { 'plan-default' }
183
205
  $retryLabel = if ($maxRetries -ne $null) { [string]$maxRetries } else { 'default' }
206
+ $infraRetryLabel = if ($maxInfraRetries -ne $null) { [string]$maxInfraRetries } else { 'default' }
184
207
  Write-PrizmInfo "Verbose mode enabled."
185
- Write-PrizmInfo "Effective options: mode=$modeLabel critic=$criticLabel maxRetries=$retryLabel timeoutSeconds=$timeoutSeconds staleKillThreshold=$staleKillThreshold dryRun=$dryRun"
208
+ Write-PrizmInfo "Effective options: mode=$modeLabel critic=$criticLabel maxRetries=$retryLabel maxInfraRetries=$infraRetryLabel timeoutSeconds=$timeoutSeconds staleKillThreshold=$staleKillThreshold dryRun=$dryRun"
186
209
  }
187
210
 
188
211
  # Validate PRIZMKIT_EFFORT early (fail-fast before any sessions are spawned)
@@ -241,17 +264,27 @@ function Invoke-PrizmPipeline {
241
264
 
242
265
  function Get-PrizmGitDirtyPaths {
243
266
  param([string]$ProjectRoot)
244
- $lines = & git -C $ProjectRoot status --porcelain 2>$null
267
+ $hiddenToolWorktreeExcludes = Get-PrizmHiddenToolWorktreeExcludes
268
+ $lines = & git -C $ProjectRoot status --porcelain -- . @hiddenToolWorktreeExcludes 2>$null
245
269
  $paths = @()
246
270
  foreach ($line in $lines) {
247
271
  if ([string]::IsNullOrWhiteSpace($line) -or $line.Length -lt 4) { continue }
248
272
  $path = $line.Substring(3).Trim()
249
273
  if ($path -match ' -> ') { $path = ($path -split ' -> ')[-1].Trim() }
250
- $paths += (($path -replace '\\', '/').Trim('"').TrimStart([char[]]@('.', '/')))
274
+ $paths += (($path -replace '\\', '/').Trim('"').TrimStart([char[]]@('/')))
251
275
  }
252
276
  return @($paths)
253
277
  }
254
278
 
279
+ function Test-PrizmPipelineIgnoredPath {
280
+ param([string]$Path)
281
+ $normalizedPath = (($Path -replace '\\', '/').Trim('"').TrimStart([char[]]@('/')))
282
+ $segments = @($normalizedPath -split '/' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
283
+ if ($segments.Count -lt 2) { return $false }
284
+ if (-not $segments[0].StartsWith('.')) { return $false }
285
+ return $segments[1] -in @('worktree', 'worktrees')
286
+ }
287
+
255
288
  function Test-PrizmPipelineBookkeepingPath {
256
289
  param(
257
290
  [string]$ProjectRoot,
@@ -270,6 +303,7 @@ function Invoke-PrizmPipeline {
270
303
  function Test-PrizmGitWorkDirty {
271
304
  param([string]$ProjectRoot, [string]$StateDir, [string]$ListPath)
272
305
  foreach ($path in (Get-PrizmGitDirtyPaths $ProjectRoot)) {
306
+ if (Test-PrizmPipelineIgnoredPath $path) { continue }
273
307
  if (-not (Test-PrizmPipelineBookkeepingPath $ProjectRoot $path $StateDir $ListPath)) {
274
308
  return $true
275
309
  }
@@ -279,7 +313,8 @@ function Invoke-PrizmPipeline {
279
313
 
280
314
  function Test-PrizmGitDirty {
281
315
  param([string]$ProjectRoot)
282
- $dirty = & git -C $ProjectRoot status --porcelain 2>$null | Select-Object -First 1
316
+ $hiddenToolWorktreeExcludes = Get-PrizmHiddenToolWorktreeExcludes
317
+ $dirty = & git -C $ProjectRoot status --porcelain -- . @hiddenToolWorktreeExcludes 2>$null | Select-Object -First 1
283
318
  return -not [string]::IsNullOrWhiteSpace([string]$dirty)
284
319
  }
285
320
 
@@ -294,12 +329,12 @@ function Invoke-PrizmPipeline {
294
329
  }
295
330
 
296
331
  function Write-PrizmStaleKillMarker {
297
- param([string]$MarkerPath, [int]$StaleSeconds, [int]$Threshold)
332
+ param([string]$MarkerPath, [int]$StaleSeconds, [int]$Threshold, [string]$Reason = 'stale_session')
298
333
  $markerDir = Split-Path $MarkerPath -Parent
299
334
  if ($markerDir) { New-Item -ItemType Directory -Force -Path $markerDir | Out-Null }
300
335
  [ordered]@{
301
336
  killed_at = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
302
- reason = 'stale_session'
337
+ reason = $Reason
303
338
  stale_seconds = $StaleSeconds
304
339
  threshold = $Threshold
305
340
  } | ConvertTo-Json -Compress | Set-Content -Path $MarkerPath -Encoding UTF8
@@ -307,14 +342,16 @@ function Invoke-PrizmPipeline {
307
342
 
308
343
  function Invoke-PrizmGitAutoCommit {
309
344
  param([string]$ProjectRoot, [string]$Message)
310
- & git -C $ProjectRoot add -A *> $null
345
+ $hiddenToolWorktreeExcludes = Get-PrizmHiddenToolWorktreeExcludes
346
+ & git -C $ProjectRoot add -A -- . @hiddenToolWorktreeExcludes *> $null
311
347
  & git -C $ProjectRoot commit --no-verify -m $Message *> $null
312
348
  return $LASTEXITCODE -eq 0
313
349
  }
314
350
 
315
351
  function Invoke-PrizmGitIncludeRemainingArtifacts {
316
352
  param([string]$ProjectRoot, [string]$ItemId)
317
- & git -C $ProjectRoot add -A *> $null
353
+ $hiddenToolWorktreeExcludes = Get-PrizmHiddenToolWorktreeExcludes
354
+ & git -C $ProjectRoot add -A -- . @hiddenToolWorktreeExcludes *> $null
318
355
  & git -C $ProjectRoot commit --no-verify --amend --no-edit *> $null
319
356
  if ($LASTEXITCODE -eq 0) { return }
320
357
  & git -C $ProjectRoot commit --no-verify -m "chore($ItemId): include remaining session artifacts" *> $null
@@ -657,7 +694,11 @@ function Invoke-PrizmPipeline {
657
694
  $devBranchName = ''
658
695
  $hadDirtyBaseline = if ($isGitRepository) { Test-PrizmGitWorkDirty $paths.ProjectRoot $stateDir $listPath } else { $false }
659
696
  if ($hadDirtyBaseline) {
660
- Write-PrizmWarn "Dirty working tree detected before pipeline bookkeeping; session success requires a new commit."
697
+ Write-PrizmError "Dirty working tree detected before $CurrentItemId; refusing to run on $originalBranch."
698
+ Write-PrizmError "Commit/stash your changes or start from a clean branch, then rerun the pipeline."
699
+ $script:PRIZM_ITEM_LIST_STATUS = ''
700
+ $script:PRIZM_ITEM_EXIT_CODE = 1
701
+ return
661
702
  }
662
703
 
663
704
  if ($dryRun) {
@@ -672,7 +713,7 @@ function Invoke-PrizmPipeline {
672
713
  }
673
714
  }
674
715
  } else {
675
- $start = Invoke-PrizmPythonJson $python (@((Join-Path $paths.ScriptsDir $updateScript), $listOption, $listPath, '--state-dir', $stateDir, '--action', 'start', $idOption, $CurrentItemId, '--session-id', $sessionId) + $maxRetryArgs)
716
+ $start = Invoke-PrizmPythonJson $python (@((Join-Path $paths.ScriptsDir $updateScript), $listOption, $listPath, '--state-dir', $stateDir, '--action', 'start', $idOption, $CurrentItemId, '--session-id', $sessionId) + $maxRetryArgs + $maxInfraRetryArgs)
676
717
  if ($start.retry_count -ne $null) { $retryCount = [string]$start.retry_count }
677
718
  if ($start.resume_from_phase -ne $null) { $resumePhase = [string]$start.resume_from_phase }
678
719
 
@@ -683,7 +724,10 @@ function Invoke-PrizmPipeline {
683
724
  $devBranchName = $candidateBranch
684
725
  Write-PrizmInfo "Dev branch: $devBranchName"
685
726
  } else {
686
- Write-PrizmWarn "Failed to create dev branch; running on current branch: $originalBranch"
727
+ Write-PrizmError "Failed to create dev branch: $candidateBranch from $originalBranch - aborting to avoid running on $originalBranch"
728
+ $script:PRIZM_ITEM_LIST_STATUS = ''
729
+ $script:PRIZM_ITEM_EXIT_CODE = 1
730
+ return
687
731
  }
688
732
  }
689
733
  }
@@ -739,8 +783,11 @@ function Invoke-PrizmPipeline {
739
783
 
740
784
  $elapsedSeconds = 0
741
785
  $staleSeconds = 0
786
+ $staleReason = 'stale_session'
742
787
  $previousLogSize = 0
788
+ $previousProgressSignature = ''
743
789
  $previousChildActivitySignature = ''
790
+ $previousErrorLoopSignature = ''
744
791
  $wasTimedOut = $false
745
792
  $staleKillMarker = Join-Path $logsDir 'stale-kill.json'
746
793
  $wasStaleKilled = $false
@@ -758,16 +805,22 @@ function Invoke-PrizmPipeline {
758
805
  $growth = $currentLogSize - $previousLogSize
759
806
  $previousLogSize = $currentLogSize
760
807
 
761
- $childActivity = Get-PrizmProgressChildActivity -ProgressFile $progressJson
762
- $childSignature = [string]$childActivity.Signature
808
+ $progressActivity = Get-PrizmProgressActivity -ProgressFile $progressJson
809
+ $progressSignature = [string]$progressActivity.ProgressSignature
810
+ $progressAdvanced = ($progressSignature -and $progressSignature -ne $previousProgressSignature)
811
+ $previousProgressSignature = $progressSignature
812
+ $childSignature = [string]$progressActivity.ChildSignature
763
813
  $childAdvanced = ($childSignature -and $childSignature -ne $previousChildActivitySignature)
764
814
  $previousChildActivitySignature = $childSignature
765
815
 
766
816
  $effectiveStaleKillThreshold = Get-PrizmEffectiveStaleKillThreshold -ProgressFile $progressJson -BaseThreshold $staleKillThreshold
767
817
 
768
818
  # Check for error-loop: agent is actively producing output but results are
769
- # all read-offset errors or wasted calls.
819
+ # all read-offset errors or wasted calls. Only a newly advancing error
820
+ # signature counts; stale historical errors must not turn later normal output
821
+ # into a stale kill.
770
822
  $errorLoopDetected = $false
823
+ $errorLoopSignature = ''
771
824
  if ($effectiveStaleKillThreshold -gt 0 -and $growth -gt 0 -and (Test-Path $progressJson)) {
772
825
  try {
773
826
  $progress = Get-Content $progressJson -Raw | ConvertFrom-Json
@@ -778,19 +831,26 @@ function Invoke-PrizmPipeline {
778
831
  $_.type -in @("read_offset_overflow", "wasted_call")
779
832
  }).Count -eq 5
780
833
  if ($allBad) {
781
- $errorLoopDetected = $true
834
+ $errorLoopSignature = ($recent | ConvertTo-Json -Compress)
835
+ if ($previousErrorLoopSignature -and $errorLoopSignature -ne $previousErrorLoopSignature) {
836
+ $errorLoopDetected = $true
837
+ }
782
838
  }
783
839
  }
784
840
  } catch {
785
841
  # Ignore JSON parse errors — progress file may be incomplete or malformed
786
842
  }
787
843
  }
844
+ $previousErrorLoopSignature = $errorLoopSignature
788
845
 
789
846
  if ($errorLoopDetected) {
847
+ $staleReason = 'error_loop'
790
848
  $staleSeconds = $effectiveStaleKillThreshold
791
- } elseif ($growth -gt 0 -or $childAdvanced) {
849
+ } elseif ($growth -gt 0 -or $childAdvanced -or $progressAdvanced) {
850
+ $staleReason = 'stale_session'
792
851
  $staleSeconds = 0
793
852
  } else {
853
+ $staleReason = 'stale_session'
794
854
  $staleSeconds += $waitSeconds
795
855
  }
796
856
 
@@ -813,8 +873,12 @@ function Invoke-PrizmPipeline {
813
873
  }
814
874
  if ($effectiveStaleKillThreshold -gt 0 -and $staleSeconds -ge $effectiveStaleKillThreshold) {
815
875
  $wasStaleKilled = $true
816
- Write-PrizmWarn "Session stale-killed (no progress for ${effectiveStaleKillThreshold}s)"
817
- Write-PrizmStaleKillMarker $staleKillMarker $staleSeconds $effectiveStaleKillThreshold
876
+ if ($staleReason -eq 'error_loop') {
877
+ Write-PrizmWarn "Session stale-killed due to repeated read-offset/wasted-call error loop"
878
+ } else {
879
+ Write-PrizmWarn "Session stale-killed (no progress for ${effectiveStaleKillThreshold}s)"
880
+ }
881
+ Write-PrizmStaleKillMarker $staleKillMarker $staleSeconds $effectiveStaleKillThreshold $staleReason
818
882
  Stop-PrizmSessionProcess $pidPath
819
883
  if ($staleKillGraceSeconds -gt 0) { Start-Sleep -Seconds $staleKillGraceSeconds }
820
884
  break
@@ -841,6 +905,15 @@ function Invoke-PrizmPipeline {
841
905
  }
842
906
  Stop-PrizmProgressParser $parserProcess
843
907
 
908
+ $staleKillReason = ''
909
+ if (Test-Path $staleKillMarker) {
910
+ try {
911
+ $staleKillData = Get-Content $staleKillMarker -Raw | ConvertFrom-Json
912
+ if ($staleKillData.PSObject.Properties['reason']) { $staleKillReason = [string]$staleKillData.reason }
913
+ } catch {
914
+ $staleKillReason = ''
915
+ }
916
+ }
844
917
  $wasInfraError = ($exitCode -ne 0 -and (Test-PrizmInfraError -SessionLog $sessionLog -ProgressJson $progressJson))
845
918
  $wasAiRuntimeError = Test-PrizmAiRuntimeError -SessionLog $sessionLog -ProgressJson $progressJson
846
919
  $semanticCompletion = if ($Kind -eq 'feature' -and $isGitRepository) {
@@ -866,16 +939,18 @@ function Invoke-PrizmPipeline {
866
939
  Write-PrizmWarn "AI session failed due to AI CLI/provider infrastructure error"
867
940
  Write-PrizmWarn "Infrastructure errors are retried without consuming code retry budget"
868
941
  } elseif ($wasStaleKilled -or (Test-Path $staleKillMarker)) {
869
- Write-PrizmWarn "Session was stale-killed by heartbeat monitor (no progress for too long)"
870
- Write-PrizmWarn "Stale-killed sessions are treated as failed; dev branch is preserved for inspection"
942
+ if ($staleKillReason -eq 'error_loop') {
943
+ Write-PrizmWarn "Session was killed by heartbeat monitor due to repeated read-offset/wasted-call errors"
944
+ } else {
945
+ Write-PrizmWarn "Session was stale-killed by heartbeat monitor (no progress for too long)"
946
+ }
947
+ Write-PrizmWarn "Heartbeat-killed sessions are treated as failed; dev branch is preserved for inspection"
871
948
  } elseif ($exitCode -ne 0) {
872
949
  Write-PrizmWarn "AI session exited with code $exitCode"
873
950
  } elseif (-not $isGitRepository) {
874
951
  Write-PrizmWarn "AI session exited cleanly, but project is not a git repository; cannot verify work was committed."
875
952
  } elseif (Test-PrizmGitCommitsSince $paths.ProjectRoot $baseCommit) {
876
953
  $status = 'success'
877
- } elseif ($hadDirtyBaseline) {
878
- Write-PrizmWarn "AI session exited cleanly but produced no new commits; pre-existing dirty tree will not be auto-committed."
879
954
  } elseif (Test-PrizmGitWorkDirty $paths.ProjectRoot $stateDir $listPath) {
880
955
  Write-PrizmWarn "AI session exited cleanly but produced no commits; auto-committing dirty work tree."
881
956
  if (Invoke-PrizmGitAutoCommit $paths.ProjectRoot "chore($CurrentItemId): auto-commit session work") {
@@ -891,17 +966,12 @@ function Invoke-PrizmPipeline {
891
966
  $itemListStatus = ''
892
967
  if ($status -eq 'success') {
893
968
  if (Test-PrizmGitDirty $paths.ProjectRoot) {
894
- if ($hadDirtyBaseline) {
895
- Write-PrizmInfo "Auto-committing pipeline bookkeeping artifacts only."
896
- Invoke-PrizmGitIncludeBookkeepingArtifacts $paths.ProjectRoot $stateDir $listPath
897
- } else {
898
- Write-PrizmInfo "Auto-committing remaining session artifacts."
899
- Invoke-PrizmGitIncludeRemainingArtifacts $paths.ProjectRoot $CurrentItemId
900
- }
969
+ Write-PrizmInfo "Auto-committing remaining session artifacts."
970
+ Invoke-PrizmGitIncludeRemainingArtifacts $paths.ProjectRoot $CurrentItemId
901
971
  }
902
972
 
903
973
  if ($status -eq 'success') {
904
- $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)
974
+ $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)
905
975
  if ($updateResult -and $updateResult.PSObject.Properties['new_status']) {
906
976
  $itemListStatus = [string]$updateResult.new_status
907
977
  }
@@ -938,7 +1008,7 @@ function Invoke-PrizmPipeline {
938
1008
  Write-PrizmRuntimeFailureLog $failureLog $CurrentItemId $sessionId $status $exitCode $staleKillMarker $progressJson $checkpointPath $paths.ProjectRoot $baseCommit
939
1009
  }
940
1010
  }
941
- $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)
1011
+ $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)
942
1012
  if ($updateResult -and $updateResult.PSObject.Properties['new_status']) {
943
1013
  $itemListStatus = [string]$updateResult.new_status
944
1014
  }
@@ -1018,7 +1088,7 @@ function Invoke-PrizmPipeline {
1018
1088
  $global:PRIZM_EXIT_CODE = $lastExitCode
1019
1089
  return
1020
1090
  } elseif ($lastExitCode -ne 0 -and $stopOnFailure) {
1021
- Write-PrizmInfo "STOP_ON_FAILURE: $nextItemId is $($script:PRIZM_ITEM_LIST_STATUS); retry budget not exhausted, continuing."
1091
+ Write-PrizmInfo "STOP_ON_FAILURE: $nextItemId is $($script:PRIZM_ITEM_LIST_STATUS); code/infra retry budget not exhausted, continuing."
1022
1092
  }
1023
1093
  }
1024
1094
  }
@@ -121,6 +121,7 @@ $job = Start-Job -ScriptBlock {
121
121
  $elapsedSeconds = 0
122
122
  $staleSeconds = 0
123
123
  $previousLogSize = 0
124
+ $previousProgressSignature = ''
124
125
  $previousChildActivitySignature = ''
125
126
  $wasTimedOut = $false
126
127
  $wasStaleKilled = $false
@@ -136,12 +137,15 @@ while ($true) {
136
137
  $growth = $currentLogSize - $previousLogSize
137
138
  $previousLogSize = $currentLogSize
138
139
 
139
- $childActivity = Get-PrizmProgressChildActivity -ProgressFile $progressPath
140
- $childSignature = [string]$childActivity.Signature
140
+ $progressActivity = Get-PrizmProgressActivity -ProgressFile $progressPath
141
+ $progressSignature = [string]$progressActivity.ProgressSignature
142
+ $progressAdvanced = ($progressSignature -and $progressSignature -ne $previousProgressSignature)
143
+ $previousProgressSignature = $progressSignature
144
+ $childSignature = [string]$progressActivity.ChildSignature
141
145
  $childAdvanced = ($childSignature -and $childSignature -ne $previousChildActivitySignature)
142
146
  $previousChildActivitySignature = $childSignature
143
147
 
144
- if ($growth -gt 0 -or $childAdvanced) { $staleSeconds = 0 } else { $staleSeconds += $waitSeconds }
148
+ if ($growth -gt 0 -or $childAdvanced -or $progressAdvanced) { $staleSeconds = 0 } else { $staleSeconds += $waitSeconds }
145
149
 
146
150
  if ($timeoutSeconds -gt 0 -and $elapsedSeconds -ge $timeoutSeconds) {
147
151
  $wasTimedOut = $true
@@ -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
@@ -17,7 +17,7 @@ Usage:
17
17
  --feature-list <path> --state-dir <path> \
18
18
  --action <get_next|start|update|status|pause|reset|clean|complete|unskip> \
19
19
  [--feature-id <id>] [--session-status <status>] \
20
- [--session-id <id>] [--max-retries <n>] \
20
+ [--session-id <id>] [--max-retries <n>] [--max-infra-retries <n>] \
21
21
  [--features <filter>]
22
22
  """
23
23
 
@@ -94,7 +94,13 @@ def parse_args():
94
94
  "--max-retries",
95
95
  type=int,
96
96
  default=3,
97
- help="Maximum retry count before marking as failed (default: 3)",
97
+ help="Maximum code retry count before marking as failed (default: 3)",
98
+ )
99
+ parser.add_argument(
100
+ "--max-infra-retries",
101
+ type=int,
102
+ default=3,
103
+ help="Maximum infrastructure retry count before marking as failed (default: 3)",
98
104
  )
99
105
  parser.add_argument(
100
106
  "--feature-slug",
@@ -170,6 +176,9 @@ def load_feature_status(state_dir, feature_id):
170
176
  "feature_id": feature_id,
171
177
  "retry_count": 0,
172
178
  "max_retries": 3,
179
+ "max_infra_retries": 3,
180
+ "infra_error_count": 0,
181
+ "last_infra_error_session_id": None,
173
182
  "sessions": [],
174
183
  "last_session_id": None,
175
184
  "resume_from_phase": None,
@@ -183,6 +192,9 @@ def load_feature_status(state_dir, feature_id):
183
192
  "feature_id": feature_id,
184
193
  "retry_count": 0,
185
194
  "max_retries": 3,
195
+ "max_infra_retries": 3,
196
+ "infra_error_count": 0,
197
+ "last_infra_error_session_id": None,
186
198
  "sessions": [],
187
199
  "last_session_id": None,
188
200
  "resume_from_phase": None,
@@ -565,6 +577,7 @@ def action_get_next(feature_list_data, state_dir, feature_filter=None):
565
577
  "feature_id": chosen_id,
566
578
  "title": chosen.get("title", ""),
567
579
  "retry_count": chosen_status_data.get("retry_count", 0),
580
+ "infra_error_count": chosen_status_data.get("infra_error_count", 0),
568
581
  "resume_from_phase": chosen_status_data.get("resume_from_phase", None),
569
582
  }
570
583
  print(json.dumps(result, indent=2, ensure_ascii=False))
@@ -585,6 +598,7 @@ def action_update(args, feature_list_path, state_dir):
585
598
  session_status = args.session_status
586
599
  session_id = args.session_id
587
600
  max_retries = args.max_retries
601
+ max_infra_retries = args.max_infra_retries
588
602
 
589
603
  if not feature_id:
590
604
  error_out("--feature-id is required for 'update' action")
@@ -600,6 +614,8 @@ def action_update(args, feature_list_path, state_dir):
600
614
  new_status = current_list_status
601
615
 
602
616
  if session_status == "success":
617
+ fs["infra_error_count"] = 0
618
+ fs["last_infra_error_session_id"] = None
603
619
  # No-op guard: if this exact successful session was already recorded,
604
620
  # avoid rewriting state files again (prevents post-commit dirty changes).
605
621
  existing_sessions = fs.get("sessions", [])
@@ -616,6 +632,8 @@ def action_update(args, feature_list_path, state_dir):
616
632
  "session_status": session_status,
617
633
  "new_status": "completed",
618
634
  "retry_count": fs.get("retry_count", 0),
635
+ "infra_error_count": fs.get("infra_error_count", 0),
636
+ "max_infra_retries": max_infra_retries,
619
637
  "resume_from_phase": fs.get("resume_from_phase"),
620
638
  "updated_at": fs.get("updated_at"),
621
639
  "no_op": True,
@@ -654,15 +672,24 @@ def action_update(args, feature_list_path, state_dir):
654
672
  return
655
673
  elif session_status == "infra_error":
656
674
  # AI CLI/provider outage, auth failure, gateway error, etc.
657
- # This is outside the code's control, so keep the item pending without
658
- # consuming the task's retry budget.
659
- new_status = "pending"
660
- fs["infra_error_count"] = fs.get("infra_error_count", 0) + 1
675
+ # Infra failures do not consume the code retry budget, but they still
676
+ # need their own bounded budget so a flaky provider cannot loop forever.
677
+ infra_error_count = fs.get("infra_error_count", 0) + 1
678
+ fs["infra_error_count"] = infra_error_count
661
679
  fs["last_infra_error_session_id"] = session_id
680
+ fs["max_infra_retries"] = max_infra_retries
681
+ fs["degraded_reason"] = "infra_error"
662
682
  if session_id:
663
683
  fs["last_session_id"] = session_id
664
684
  fs["resume_from_phase"] = None
665
685
 
686
+ if infra_error_count >= max_infra_retries:
687
+ new_status = "failed"
688
+ if session_id:
689
+ fs["last_failed_session_id"] = session_id
690
+ else:
691
+ new_status = "pending"
692
+
666
693
  err = update_feature_in_list(feature_list_path, feature_id, new_status)
667
694
  if err:
668
695
  error_out("Failed to update .prizmkit/plans/feature-list.json: {}".format(err))
@@ -714,6 +741,8 @@ def action_update(args, feature_list_path, state_dir):
714
741
  "session_status": session_status,
715
742
  "new_status": new_status,
716
743
  "retry_count": fs["retry_count"],
744
+ "infra_error_count": fs.get("infra_error_count", 0),
745
+ "max_infra_retries": max_infra_retries,
717
746
  "resume_from_phase": fs.get("resume_from_phase"),
718
747
  "updated_at": fs["updated_at"],
719
748
  }
@@ -1143,6 +1172,8 @@ def action_start(args, feature_list_path, state_dir):
1143
1172
  "feature_id": feature_id,
1144
1173
  "old_status": old_status,
1145
1174
  "new_status": "in_progress",
1175
+ "retry_count": fs.get("retry_count", 0),
1176
+ "infra_error_count": fs.get("infra_error_count", 0),
1146
1177
  "updated_at": fs["updated_at"],
1147
1178
  }
1148
1179
  print(json.dumps(result, indent=2, ensure_ascii=False))
@@ -1171,6 +1202,8 @@ def action_reset(args, feature_list_path, state_dir):
1171
1202
 
1172
1203
  # Reset runtime fields
1173
1204
  fs["retry_count"] = 0
1205
+ fs["infra_error_count"] = 0
1206
+ fs["last_infra_error_session_id"] = None
1174
1207
  fs["sessions"] = []
1175
1208
  fs["last_session_id"] = None
1176
1209
  fs["resume_from_phase"] = None
@@ -1264,6 +1297,8 @@ def action_clean(args, feature_list_path, state_dir):
1264
1297
  old_retry = fs.get("retry_count", 0)
1265
1298
 
1266
1299
  fs["retry_count"] = 0
1300
+ fs["infra_error_count"] = 0
1301
+ fs["last_infra_error_session_id"] = None
1267
1302
  fs["sessions"] = []
1268
1303
  fs["last_session_id"] = None
1269
1304
  fs["resume_from_phase"] = None
@@ -1423,6 +1458,8 @@ def action_unskip(args, feature_list_path, state_dir):
1423
1458
  for fid in to_reset:
1424
1459
  fs = load_feature_status(state_dir, fid)
1425
1460
  fs["retry_count"] = 0
1461
+ fs["infra_error_count"] = 0
1462
+ fs["last_infra_error_session_id"] = None
1426
1463
  fs["sessions"] = []
1427
1464
  fs["last_session_id"] = None
1428
1465
  fs["resume_from_phase"] = None