prizmkit 1.1.91 → 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,6 +783,7 @@ function Invoke-PrizmPipeline {
739
783
 
740
784
  $elapsedSeconds = 0
741
785
  $staleSeconds = 0
786
+ $staleReason = 'stale_session'
742
787
  $previousLogSize = 0
743
788
  $previousProgressSignature = ''
744
789
  $previousChildActivitySignature = ''
@@ -799,10 +844,13 @@ function Invoke-PrizmPipeline {
799
844
  $previousErrorLoopSignature = $errorLoopSignature
800
845
 
801
846
  if ($errorLoopDetected) {
847
+ $staleReason = 'error_loop'
802
848
  $staleSeconds = $effectiveStaleKillThreshold
803
849
  } elseif ($growth -gt 0 -or $childAdvanced -or $progressAdvanced) {
850
+ $staleReason = 'stale_session'
804
851
  $staleSeconds = 0
805
852
  } else {
853
+ $staleReason = 'stale_session'
806
854
  $staleSeconds += $waitSeconds
807
855
  }
808
856
 
@@ -825,8 +873,12 @@ function Invoke-PrizmPipeline {
825
873
  }
826
874
  if ($effectiveStaleKillThreshold -gt 0 -and $staleSeconds -ge $effectiveStaleKillThreshold) {
827
875
  $wasStaleKilled = $true
828
- Write-PrizmWarn "Session stale-killed (no progress for ${effectiveStaleKillThreshold}s)"
829
- 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
830
882
  Stop-PrizmSessionProcess $pidPath
831
883
  if ($staleKillGraceSeconds -gt 0) { Start-Sleep -Seconds $staleKillGraceSeconds }
832
884
  break
@@ -853,6 +905,15 @@ function Invoke-PrizmPipeline {
853
905
  }
854
906
  Stop-PrizmProgressParser $parserProcess
855
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
+ }
856
917
  $wasInfraError = ($exitCode -ne 0 -and (Test-PrizmInfraError -SessionLog $sessionLog -ProgressJson $progressJson))
857
918
  $wasAiRuntimeError = Test-PrizmAiRuntimeError -SessionLog $sessionLog -ProgressJson $progressJson
858
919
  $semanticCompletion = if ($Kind -eq 'feature' -and $isGitRepository) {
@@ -878,16 +939,18 @@ function Invoke-PrizmPipeline {
878
939
  Write-PrizmWarn "AI session failed due to AI CLI/provider infrastructure error"
879
940
  Write-PrizmWarn "Infrastructure errors are retried without consuming code retry budget"
880
941
  } 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"
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"
883
948
  } elseif ($exitCode -ne 0) {
884
949
  Write-PrizmWarn "AI session exited with code $exitCode"
885
950
  } elseif (-not $isGitRepository) {
886
951
  Write-PrizmWarn "AI session exited cleanly, but project is not a git repository; cannot verify work was committed."
887
952
  } elseif (Test-PrizmGitCommitsSince $paths.ProjectRoot $baseCommit) {
888
953
  $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
954
  } elseif (Test-PrizmGitWorkDirty $paths.ProjectRoot $stateDir $listPath) {
892
955
  Write-PrizmWarn "AI session exited cleanly but produced no commits; auto-committing dirty work tree."
893
956
  if (Invoke-PrizmGitAutoCommit $paths.ProjectRoot "chore($CurrentItemId): auto-commit session work") {
@@ -903,17 +966,12 @@ function Invoke-PrizmPipeline {
903
966
  $itemListStatus = ''
904
967
  if ($status -eq 'success') {
905
968
  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
- }
969
+ Write-PrizmInfo "Auto-committing remaining session artifacts."
970
+ Invoke-PrizmGitIncludeRemainingArtifacts $paths.ProjectRoot $CurrentItemId
913
971
  }
914
972
 
915
973
  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)
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)
917
975
  if ($updateResult -and $updateResult.PSObject.Properties['new_status']) {
918
976
  $itemListStatus = [string]$updateResult.new_status
919
977
  }
@@ -950,7 +1008,7 @@ function Invoke-PrizmPipeline {
950
1008
  Write-PrizmRuntimeFailureLog $failureLog $CurrentItemId $sessionId $status $exitCode $staleKillMarker $progressJson $checkpointPath $paths.ProjectRoot $baseCommit
951
1009
  }
952
1010
  }
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)
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)
954
1012
  if ($updateResult -and $updateResult.PSObject.Properties['new_status']) {
955
1013
  $itemListStatus = [string]$updateResult.new_status
956
1014
  }
@@ -1030,7 +1088,7 @@ function Invoke-PrizmPipeline {
1030
1088
  $global:PRIZM_EXIT_CODE = $lastExitCode
1031
1089
  return
1032
1090
  } elseif ($lastExitCode -ne 0 -and $stopOnFailure) {
1033
- 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."
1034
1092
  }
1035
1093
  }
1036
1094
  }
@@ -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