prizmkit 1.1.152 → 1.1.154

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 (44) hide show
  1. package/bundled/VERSION.json +3 -3
  2. package/bundled/dev-pipeline/README.md +100 -87
  3. package/bundled/dev-pipeline/assets/skill-subagent-integration.md +1 -1
  4. package/bundled/dev-pipeline/prizmkit_runtime/checkpoint_state.py +14 -0
  5. package/bundled/dev-pipeline/prizmkit_runtime/cli.py +192 -110
  6. package/bundled/dev-pipeline/prizmkit_runtime/commands.py +146 -111
  7. package/bundled/dev-pipeline/prizmkit_runtime/daemon.py +3 -5
  8. package/bundled/dev-pipeline/prizmkit_runtime/interoperability.py +0 -1
  9. package/bundled/dev-pipeline/prizmkit_runtime/paths.py +0 -3
  10. package/bundled/dev-pipeline/prizmkit_runtime/reset.py +122 -15
  11. package/bundled/dev-pipeline/prizmkit_runtime/reset_preserve.py +1 -1
  12. package/bundled/dev-pipeline/prizmkit_runtime/runner_models.py +1 -1
  13. package/bundled/dev-pipeline/prizmkit_runtime/runners.py +19 -52
  14. package/bundled/dev-pipeline/prizmkit_runtime/status.py +106 -1
  15. package/bundled/dev-pipeline/scripts/init-bugfix-pipeline.py +2 -2
  16. package/bundled/dev-pipeline/scripts/parse-stream-progress.py +10 -12
  17. package/bundled/dev-pipeline/scripts/update-bug-status.py +196 -67
  18. package/bundled/dev-pipeline/scripts/update-checkpoint.py +16 -3
  19. package/bundled/dev-pipeline/scripts/update-feature-status.py +45 -117
  20. package/bundled/dev-pipeline/scripts/update-refactor-status.py +45 -119
  21. package/bundled/dev-pipeline/scripts/utils.py +119 -0
  22. package/bundled/dev-pipeline/templates/bug-fix-list-schema.json +3 -2
  23. package/bundled/dev-pipeline/tests/test_auto_skip.py +111 -32
  24. package/bundled/dev-pipeline/tests/test_checkpoint_state.py +148 -12
  25. package/bundled/dev-pipeline/tests/test_generate_bootstrap_prompt.py +0 -19
  26. package/bundled/dev-pipeline/tests/test_python_runner_parity.py +113 -188
  27. package/bundled/dev-pipeline/tests/test_recovery_workflow.py +211 -0
  28. package/bundled/dev-pipeline/tests/test_reset_modes.py +935 -0
  29. package/bundled/dev-pipeline/tests/test_reset_preserve.py +5 -5
  30. package/bundled/dev-pipeline/tests/test_unified_cli.py +544 -181
  31. package/bundled/skills/_metadata.json +1 -1
  32. package/bundled/skills/bug-planner/references/schema-validation.md +1 -1
  33. package/bundled/skills/bug-planner/scripts/validate-bug-list.py +1 -1
  34. package/bundled/skills/bugfix-pipeline-launcher/SKILL.md +21 -13
  35. package/bundled/skills/feature-pipeline-launcher/SKILL.md +22 -14
  36. package/bundled/skills/recovery-workflow/SKILL.md +7 -5
  37. package/bundled/skills/recovery-workflow/evals/evals.json +2 -2
  38. package/bundled/skills/recovery-workflow/references/detection.md +3 -3
  39. package/bundled/skills/recovery-workflow/scripts/detect-recovery-state.py +1 -1
  40. package/bundled/skills/refactor-pipeline-launcher/SKILL.md +21 -13
  41. package/bundled/templates/project-memory-template.md +19 -11
  42. package/package.json +1 -1
  43. package/bundled/dev-pipeline/prizmkit_runtime/runner_recovery.py +0 -228
  44. package/bundled/dev-pipeline/scripts/generate-recovery-prompt.py +0 -767
@@ -9,12 +9,12 @@ Handles eight actions:
9
9
  - pause: Save pipeline state for graceful shutdown
10
10
  - reset: Reset a bug to pending (status + retry count)
11
11
  - clean: Reset + delete session history + delete bugfix artifacts
12
- - unskip: Reset skipped bugs back to pending
12
+ - reset_state: Recover blocking roots and causally recoverable auto-skipped descendants
13
13
 
14
14
  Usage:
15
15
  python3 update-bug-status.py \
16
16
  --bug-list <path> --state-dir <path> \
17
- --action <get_next|start|update|status|pause|reset|clean|unskip> \
17
+ --action <get_next|start|update|status|pause|reset|clean|reset_state> \
18
18
  [--bug-id <id>] [--session-status <status>] \
19
19
  [--session-id <id>] [--max-retries <n>] [--max-infra-retries <n>]
20
20
  """
@@ -23,6 +23,7 @@ import argparse
23
23
  import json
24
24
  import os
25
25
  import shutil
26
+ import sys
26
27
  from datetime import datetime, timezone
27
28
 
28
29
  from utils import (
@@ -33,6 +34,7 @@ from utils import (
33
34
  _build_progress_bar,
34
35
  read_checkpoint_cursor,
35
36
  dependency_rank_map,
37
+ causal_reset_selection,
36
38
  )
37
39
 
38
40
 
@@ -53,7 +55,8 @@ SESSION_STATUS_VALUES = [
53
55
  "merge_conflict",
54
56
  ]
55
57
 
56
- TERMINAL_STATUSES = {"completed", "failed", "skipped", "needs_info"}
58
+ TERMINAL_STATUSES = {"completed", "failed", "skipped", "needs_info", "auto_skipped"}
59
+ RESET_BLOCKING_ROOT_STATUSES = {"failed", "skipped", "needs_info"}
57
60
 
58
61
  # Severity priority (lower value = higher priority)
59
62
  SEVERITY_PRIORITY = {
@@ -72,7 +75,7 @@ def parse_args():
72
75
  parser.add_argument("--state-dir", required=True, help="Path to the state directory (default: .prizmkit/state/bugfix)")
73
76
  parser.add_argument(
74
77
  "--action", required=True,
75
- choices=["get_next", "start", "update", "status", "pause", "reset", "clean", "unskip", "complete"],
78
+ choices=["get_next", "start", "update", "status", "pause", "reset", "clean", "reset_state", "complete"],
76
79
  help="Action to perform",
77
80
  )
78
81
  parser.add_argument("--bug-id", default=None, help="Bug ID (required for 'update'/'reset'/'clean' actions)")
@@ -383,19 +386,117 @@ def continuation_metadata_summary(status_data):
383
386
  }
384
387
 
385
388
 
389
+ # ---------------------------------------------------------------------------
390
+ # Auto-skip: cascade terminal blockers to pending downstream bugs
391
+ # ---------------------------------------------------------------------------
392
+
393
+ def auto_skip_blocked_bugs(bug_list_path, state_dir, blocking_bug_id=None):
394
+ """Mark pending descendants of terminal Bugfix blockers as auto_skipped."""
395
+ data, err = load_json_file(bug_list_path)
396
+ if err:
397
+ error_out("Cannot load bug fix list for auto-skip: {}".format(err))
398
+ return []
399
+ bugs = data.get("bugs", [])
400
+ status_map = {
401
+ bug.get("id"): bug.get("status", "pending")
402
+ for bug in bugs
403
+ if isinstance(bug, dict) and bug.get("id")
404
+ }
405
+
406
+ to_skip = set()
407
+ changed = True
408
+ while changed:
409
+ changed = False
410
+ for bug in bugs:
411
+ if not isinstance(bug, dict):
412
+ continue
413
+ bug_id = bug.get("id")
414
+ if not bug_id or bug_id in to_skip:
415
+ continue
416
+ if status_map.get(bug_id, "pending") != "pending":
417
+ continue
418
+ dependencies = bug.get("dependencies", [])
419
+ if not isinstance(dependencies, list):
420
+ continue
421
+ if any(
422
+ status_map.get(dependency_id, "pending")
423
+ in RESET_BLOCKING_ROOT_STATUSES | {"auto_skipped"}
424
+ or dependency_id in to_skip
425
+ for dependency_id in dependencies
426
+ ):
427
+ to_skip.add(bug_id)
428
+ status_map[bug_id] = "auto_skipped"
429
+ changed = True
430
+
431
+ if not to_skip:
432
+ return []
433
+
434
+ for bug in bugs:
435
+ if isinstance(bug, dict) and bug.get("id") in to_skip:
436
+ bug["status"] = "auto_skipped"
437
+ err = write_json_file(bug_list_path, data)
438
+ if err:
439
+ error_out("Failed to write auto-skipped bugs: {}".format(err))
440
+ return []
441
+
442
+ for bug_id in to_skip:
443
+ bs = load_bug_status(state_dir, bug_id)
444
+ bs["updated_at"] = now_iso()
445
+ err = save_bug_status(state_dir, bug_id, bs)
446
+ if err:
447
+ error_out("Failed to save auto-skipped bug status: {}".format(err))
448
+ return []
449
+
450
+ skipped_info = []
451
+ for bug in bugs:
452
+ if not isinstance(bug, dict) or bug.get("id") not in to_skip:
453
+ continue
454
+ dependencies = bug.get("dependencies", [])
455
+ blockers = [
456
+ dependency_id
457
+ for dependency_id in dependencies
458
+ if dependency_id == blocking_bug_id
459
+ or status_map.get(dependency_id, "pending")
460
+ in RESET_BLOCKING_ROOT_STATUSES | {"auto_skipped"}
461
+ ]
462
+ skipped_info.append({
463
+ "bug_id": bug["id"],
464
+ "title": bug.get("title", ""),
465
+ "blocked_by": blockers,
466
+ })
467
+
468
+ cause = blocking_bug_id or "existing terminal blockers"
469
+ print(
470
+ "[auto-skip] {} bug(s) auto-skipped due to {}".format(
471
+ len(skipped_info), cause
472
+ ),
473
+ file=sys.stderr,
474
+ )
475
+ return skipped_info
476
+
477
+
386
478
  # ---------------------------------------------------------------------------
387
479
  # Action: get_next
388
480
  # ---------------------------------------------------------------------------
389
481
 
390
- def action_get_next(bug_list_data, state_dir, project_root=None):
482
+ def action_get_next(bug_list_data, state_dir, project_root=None, bug_list_path=None):
391
483
  """Find the next bug to process.
392
484
 
393
485
  Priority logic:
394
- 1. Skip terminal statuses (completed, failed, skipped, needs_info)
395
- 2. Only consider bugs whose dependencies are all completed
396
- 3. Prefer in_progress bugs (interrupted session resume) over pending
397
- 4. Within each status class, use stable topological list order
486
+ 1. Lazily normalize pending descendants of terminal blockers to auto_skipped
487
+ 2. Skip terminal statuses (completed, failed, skipped, needs_info, auto_skipped)
488
+ 3. Only consider bugs whose dependencies are all completed
489
+ 4. Prefer in_progress bugs (interrupted session resume) over pending
490
+ 5. Within each status class, use stable topological list order
398
491
  """
492
+ if bug_list_path:
493
+ auto_skip_blocked_bugs(bug_list_path, state_dir)
494
+ refreshed, refresh_error = load_json_file(bug_list_path)
495
+ if refresh_error:
496
+ error_out("Cannot reload bug fix list after auto-skip: {}".format(refresh_error))
497
+ return
498
+ bug_list_data = refreshed
499
+
399
500
  bugs = bug_list_data.get("bugs", [])
400
501
  if not bugs:
401
502
  print("PIPELINE_COMPLETE")
@@ -513,6 +614,8 @@ def action_update(args, bug_list_path, state_dir):
513
614
  bs = load_bug_status(state_dir, bug_id)
514
615
 
515
616
  current_list_status = get_bug_status_from_list(bug_list_path, bug_id)
617
+ bs["max_retries"] = max_retries
618
+ bs["max_infra_retries"] = max_infra_retries
516
619
 
517
620
  # Track what status we write to bug-fix-list.json
518
621
  new_status = current_list_status
@@ -677,6 +780,12 @@ def action_update(args, bug_list_path, state_dir):
677
780
  error_out("Failed to save bug status: {}".format(err))
678
781
  return
679
782
 
783
+ auto_skipped_bugs = []
784
+ if new_status in RESET_BLOCKING_ROOT_STATUSES:
785
+ auto_skipped_bugs = auto_skip_blocked_bugs(
786
+ bug_list_path, state_dir, bug_id
787
+ )
788
+
680
789
  summary = {
681
790
  "action": "update",
682
791
  "bug_id": bug_id,
@@ -689,6 +798,8 @@ def action_update(args, bug_list_path, state_dir):
689
798
  "checkpoint_state": semantic_checkpoint,
690
799
  "updated_at": bs["updated_at"],
691
800
  }
801
+ if auto_skipped_bugs:
802
+ summary["auto_skipped"] = [info["bug_id"] for info in auto_skipped_bugs]
692
803
  if session_status in ("commit_missing", "docs_missing", "merge_conflict"):
693
804
  summary["degraded_reason"] = session_status
694
805
  summary["restart_policy"] = "finalization_retry"
@@ -863,7 +974,20 @@ def action_status(bug_list_data, state_dir, project_root=None):
863
974
  project_name = bug_list_data.get("project_name", "Unknown")
864
975
  project_root = os.path.abspath(project_root) if project_root else _default_project_root()
865
976
 
866
- counts = {"completed": 0, "in_progress": 0, "failed": 0, "pending": 0, "needs_info": 0, "skipped": 0}
977
+ counts = {
978
+ "completed": 0,
979
+ "in_progress": 0,
980
+ "failed": 0,
981
+ "pending": 0,
982
+ "needs_info": 0,
983
+ "skipped": 0,
984
+ "auto_skipped": 0,
985
+ }
986
+ status_map = {
987
+ bug.get("id"): bug.get("status", "pending")
988
+ for bug in bugs
989
+ if isinstance(bug, dict) and bug.get("id")
990
+ }
867
991
  bug_lines = []
868
992
 
869
993
  for bug in bugs:
@@ -901,6 +1025,8 @@ def action_status(bug_list_data, state_dir, project_root=None):
901
1025
  icon = COLOR_MAGENTA + "[?]" + COLOR_RESET
902
1026
  elif bstatus == "skipped":
903
1027
  icon = COLOR_GRAY + "[—]" + COLOR_RESET
1028
+ elif bstatus == "auto_skipped":
1029
+ icon = COLOR_GRAY + "[⊘]" + COLOR_RESET
904
1030
  else:
905
1031
  icon = COLOR_GRAY + "[ ]" + COLOR_RESET
906
1032
 
@@ -921,6 +1047,16 @@ def action_status(bug_list_data, state_dir, project_root=None):
921
1047
  detail = " (failed after {} retries)".format(retry_count)
922
1048
  elif bstatus == "needs_info":
923
1049
  detail = " (needs more info)"
1050
+ elif bstatus == "auto_skipped":
1051
+ blockers = [
1052
+ dependency_id
1053
+ for dependency_id in bug.get("dependencies", [])
1054
+ if status_map.get(dependency_id, "pending")
1055
+ in RESET_BLOCKING_ROOT_STATUSES | {"auto_skipped"}
1056
+ ]
1057
+ detail = " (blocked by {})".format(
1058
+ ", ".join(blockers) if blockers else "dependency chain"
1059
+ )
924
1060
 
925
1061
  # Colorize
926
1062
  if bstatus == "completed":
@@ -954,8 +1090,12 @@ def action_status(bug_list_data, state_dir, project_root=None):
954
1090
  summary_line = "Total: {} bugs | Completed: {} | In Progress: {}".format(
955
1091
  total, completed, counts["in_progress"]
956
1092
  )
957
- summary_line2 = "Failed: {} | Pending: {} | Needs Info: {} | Skipped: {}".format(
958
- counts["failed"], counts["pending"], counts["needs_info"], counts["skipped"]
1093
+ summary_line2 = "Failed: {} | Pending: {} | Needs Info: {} | Skipped: {} | Auto-skipped: {}".format(
1094
+ counts["failed"],
1095
+ counts["pending"],
1096
+ counts["needs_info"],
1097
+ counts["skipped"],
1098
+ counts["auto_skipped"],
959
1099
  )
960
1100
 
961
1101
  inner = BOX_WIDTH - 2
@@ -1146,6 +1286,8 @@ def action_start(args, bug_list_path, state_dir):
1146
1286
  bs = load_bug_status(state_dir, bug_id)
1147
1287
  old_status = get_bug_status_from_list(bug_list_path, bug_id)
1148
1288
 
1289
+ bs["max_retries"] = args.max_retries
1290
+ bs["max_infra_retries"] = args.max_infra_retries
1149
1291
  bs["updated_at"] = now_iso()
1150
1292
 
1151
1293
  err = save_bug_status(state_dir, bug_id, bs)
@@ -1172,64 +1314,37 @@ def action_start(args, bug_list_path, state_dir):
1172
1314
 
1173
1315
 
1174
1316
  # ---------------------------------------------------------------------------
1175
- # Action: unskip
1317
+ # Action: reset_state
1176
1318
  # ---------------------------------------------------------------------------
1177
1319
 
1178
- def action_unskip(args, bug_list_path, state_dir):
1179
- """Reset skipped bugs back to pending.
1180
-
1181
- Two modes:
1182
- - --bug-id B-001: Reset the specified skipped bug to pending.
1183
- - No --bug-id: Reset ALL skipped bugs to pending.
1184
- """
1185
- bug_id = args.bug_id
1186
-
1320
+ def action_reset_state(args, bug_list_path, state_dir):
1321
+ """Reset one causal bug closure, or every recoverable closure, to pending."""
1187
1322
  data, err = load_json_file(bug_list_path)
1188
1323
  if err:
1189
1324
  error_out("Cannot load bug fix list: {}".format(err))
1190
1325
  return
1191
1326
  bugs = data.get("bugs", [])
1192
1327
 
1193
- to_reset = set()
1194
-
1195
- if bug_id:
1196
- # Find the target bug
1197
- target = None
1198
- for b in bugs:
1199
- if isinstance(b, dict) and b.get("id") == bug_id:
1200
- target = b
1201
- break
1202
- if not target:
1203
- error_out("Bug '{}' not found in .prizmkit/plans/bug-fix-list.json".format(bug_id))
1204
- return
1205
- if target.get("status") not in ("failed", "skipped", "needs_info"):
1206
- error_out(
1207
- "Bug '{}' has status '{}', expected 'failed', 'skipped', or 'needs_info'".format(
1208
- bug_id, target.get("status", "unknown")
1209
- )
1210
- )
1211
- return
1212
- to_reset.add(bug_id)
1213
- else:
1214
- # No bug-id: reset ALL skipped/failed/needs_info bugs
1215
- for b in bugs:
1216
- if isinstance(b, dict) and b.get("id"):
1217
- if b.get("status") in ("failed", "skipped", "needs_info"):
1218
- to_reset.add(b["id"])
1219
-
1220
- if not to_reset:
1221
- error_out("No bugs to unskip")
1328
+ try:
1329
+ recovery_ids = causal_reset_selection(
1330
+ bugs,
1331
+ selected_id=args.bug_id,
1332
+ blocking_root_statuses=RESET_BLOCKING_ROOT_STATUSES,
1333
+ item_label="Bug",
1334
+ )
1335
+ except ValueError as exc:
1336
+ error_out(str(exc))
1222
1337
  return
1223
1338
 
1224
- # Reset all collected bugs in bug-fix-list.json
1339
+ to_reset = set(recovery_ids)
1225
1340
  reset_details = []
1226
- for b in bugs:
1227
- if isinstance(b, dict) and b.get("id") in to_reset:
1228
- old_status = b.get("status", "unknown")
1229
- b["status"] = "pending"
1341
+ for bug in bugs:
1342
+ if isinstance(bug, dict) and bug.get("id") in to_reset:
1343
+ old_status = bug.get("status", "unknown")
1344
+ bug["status"] = "pending"
1230
1345
  reset_details.append({
1231
- "bug_id": b["id"],
1232
- "title": b.get("title", ""),
1346
+ "bug_id": bug["id"],
1347
+ "title": bug.get("title", ""),
1233
1348
  "old_status": old_status,
1234
1349
  })
1235
1350
 
@@ -1238,22 +1353,31 @@ def action_unskip(args, bug_list_path, state_dir):
1238
1353
  error_out("Failed to write .prizmkit/plans/bug-fix-list.json: {}".format(err))
1239
1354
  return
1240
1355
 
1241
- # Reset runtime fields in status.json for each bug
1242
- for bid in to_reset:
1243
- bs = load_bug_status(state_dir, bid)
1356
+ for bug_id in recovery_ids:
1357
+ bs = load_bug_status(state_dir, bug_id)
1358
+ checkout_identity = {
1359
+ key: bs[key]
1360
+ for key in ("active_dev_branch", "base_branch")
1361
+ if key in bs
1362
+ }
1244
1363
  bs["retry_count"] = 0
1245
1364
  bs["infra_error_count"] = 0
1246
1365
  bs["last_infra_error_session_id"] = None
1247
1366
  bs["sessions"] = []
1248
1367
  bs["last_session_id"] = None
1368
+ bs["last_failed_session_id"] = None
1249
1369
  bs["resume_from_phase"] = None
1250
1370
  reset_continuation_metadata(bs)
1371
+ bs.update(checkout_identity)
1251
1372
  bs["updated_at"] = now_iso()
1252
- save_bug_status(state_dir, bid, bs)
1373
+ err = save_bug_status(state_dir, bug_id, bs)
1374
+ if err:
1375
+ error_out("Failed to save bug status: {}".format(err))
1376
+ return
1253
1377
 
1254
1378
  result = {
1255
- "action": "unskip",
1256
- "reset_count": len(to_reset),
1379
+ "action": "reset_state",
1380
+ "reset_count": len(recovery_ids),
1257
1381
  "bugs": reset_details,
1258
1382
  }
1259
1383
  print(json.dumps(result, indent=2, ensure_ascii=False))
@@ -1283,7 +1407,12 @@ def main():
1283
1407
  error_out("Cannot load bug fix list: {}".format(err))
1284
1408
 
1285
1409
  if args.action == "get_next":
1286
- action_get_next(bug_list_data, args.state_dir, args.project_root)
1410
+ action_get_next(
1411
+ bug_list_data,
1412
+ args.state_dir,
1413
+ args.project_root,
1414
+ args.bug_list,
1415
+ )
1287
1416
  elif args.action == "start":
1288
1417
  action_start(args, args.bug_list, args.state_dir)
1289
1418
  elif args.action == "update":
@@ -1296,8 +1425,8 @@ def main():
1296
1425
  action_clean(args, args.bug_list, args.state_dir)
1297
1426
  elif args.action == "pause":
1298
1427
  action_pause(args.state_dir)
1299
- elif args.action == "unskip":
1300
- action_unskip(args, args.bug_list, args.state_dir)
1428
+ elif args.action == "reset_state":
1429
+ action_reset_state(args, args.bug_list, args.state_dir)
1301
1430
  elif args.action == "complete":
1302
1431
  # Shortcut: 'complete' is equivalent to 'update --session-status success'
1303
1432
  args.session_status = "success"
@@ -153,7 +153,18 @@ def _validate_semantic_update(step, semantic_update):
153
153
  terminal_status = semantic_update.get("terminal_status")
154
154
  if terminal_status is not None and terminal_status not in VALID_TERMINAL_STATUSES:
155
155
  return "Invalid terminal_status '{}'".format(terminal_status)
156
- if terminal_status == "WORKFLOW_BLOCKED" and not semantic_update.get("blocked_reason"):
156
+ blocked_reason = semantic_update.get("blocked_reason")
157
+ blocked_policy_result = stage_result in {"TEST_NEEDS_FIXES", "TEST_BLOCKED"}
158
+ if terminal_status == "WORKFLOW_COMPLETED" and blocked_policy_result:
159
+ return "WORKFLOW_COMPLETED conflicts with blocking stage result {}".format(stage_result)
160
+ if blocked_reason is not None:
161
+ if not isinstance(blocked_reason, str) or not blocked_reason.strip():
162
+ return "blocked_reason must be a non-empty string"
163
+ if terminal_status == "WORKFLOW_COMPLETED":
164
+ return "WORKFLOW_COMPLETED cannot retain blocked_reason"
165
+ if terminal_status != "WORKFLOW_BLOCKED" and not blocked_policy_result:
166
+ return "blocked_reason requires terminal_status=WORKFLOW_BLOCKED"
167
+ if terminal_status == "WORKFLOW_BLOCKED" and not blocked_reason:
157
168
  return "WORKFLOW_BLOCKED requires blocked_reason"
158
169
  return None
159
170
 
@@ -361,11 +372,13 @@ def update_checkpoint(
361
372
  exists=True,
362
373
  project_root=_checkpoint_project_root(checkpoint_path),
363
374
  )
364
- if not final_state.valid:
375
+ if not final_state.valid or final_state.semantic.error_code:
376
+ error_code = final_state.error_code or final_state.semantic.error_code
377
+ error_message = final_state.error_message or final_state.semantic.error_message
365
378
  return {
366
379
  "ok": False,
367
380
  "error": "Update would create invalid checkpoint ({}): {}".format(
368
- final_state.error_code, final_state.error_message
381
+ error_code, error_message
369
382
  ),
370
383
  }
371
384